| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package format |
| |
| import ( |
| "bufio" |
| "bytes" |
| "fmt" |
| "iter" |
| "slices" |
| "sort" |
| "strings" |
| |
| viewsjson "github.com/hashicorp/terraform/internal/command/views/json" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| |
| "github.com/mitchellh/colorstring" |
| wordwrap "github.com/mitchellh/go-wordwrap" |
| ) |
| |
| var disabledColorize = &colorstring.Colorize{ |
| Colors: colorstring.DefaultColors, |
| Disable: true, |
| } |
| |
| // Diagnostic formats a single diagnostic message. |
| // |
| // The width argument specifies at what column the diagnostic messages will |
| // be wrapped. If set to zero, messages will not be wrapped by this function |
| // at all. Although the long-form text parts of the message are wrapped, |
| // not all aspects of the message are guaranteed to fit within the specified |
| // terminal width. |
| func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize, width int) string { |
| return DiagnosticFromJSON(viewsjson.NewDiagnostic(diag, sources), color, width) |
| } |
| |
| func DiagnosticFromJSON(diag *viewsjson.Diagnostic, color *colorstring.Colorize, width int) string { |
| if diag == nil { |
| // No good reason to pass a nil diagnostic in here... |
| return "" |
| } |
| |
| var buf bytes.Buffer |
| |
| // these leftRule* variables are markers for the beginning of the lines |
| // containing the diagnostic that are intended to help sighted users |
| // better understand the information hierarchy when diagnostics appear |
| // alongside other information or alongside other diagnostics. |
| // |
| // Without this, it seems (based on folks sharing incomplete messages when |
| // asking questions, or including extra content that's not part of the |
| // diagnostic) that some readers have trouble easily identifying which |
| // text belongs to the diagnostic and which does not. |
| var leftRuleLine, leftRuleStart, leftRuleEnd string |
| var leftRuleWidth int // in visual character cells |
| |
| switch diag.Severity { |
| case viewsjson.DiagnosticSeverityError: |
| buf.WriteString(color.Color("[bold][red]Error: [reset]")) |
| leftRuleLine = color.Color("[red]│[reset] ") |
| leftRuleStart = color.Color("[red]╷[reset]") |
| leftRuleEnd = color.Color("[red]╵[reset]") |
| leftRuleWidth = 2 |
| case viewsjson.DiagnosticSeverityWarning: |
| buf.WriteString(color.Color("[bold][yellow]Warning: [reset]")) |
| leftRuleLine = color.Color("[yellow]│[reset] ") |
| leftRuleStart = color.Color("[yellow]╷[reset]") |
| leftRuleEnd = color.Color("[yellow]╵[reset]") |
| leftRuleWidth = 2 |
| default: |
| // Clear out any coloring that might be applied by Terraform's UI helper, |
| // so our result is not context-sensitive. |
| buf.WriteString(color.Color("\n[reset]")) |
| } |
| |
| // We don't wrap the summary, since we expect it to be terse, and since |
| // this is where we put the text of a native Go error it may not always |
| // be pure text that lends itself well to word-wrapping. |
| fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), diag.Summary) |
| |
| f := &snippetFormatter{&buf, diag, color} |
| f.write() |
| |
| if diag.Detail != "" { |
| paraWidth := width - leftRuleWidth - 1 // leave room for the left rule |
| if paraWidth > 0 { |
| lines := strings.Split(diag.Detail, "\n") |
| for _, line := range lines { |
| if !strings.HasPrefix(line, " ") { |
| line = wordwrap.WrapString(line, uint(paraWidth)) |
| } |
| fmt.Fprintf(&buf, "%s\n", line) |
| } |
| } else { |
| fmt.Fprintf(&buf, "%s\n", diag.Detail) |
| } |
| } |
| |
| // Before we return, we'll finally add the left rule prefixes to each |
| // line so that the overall message is visually delimited from what's |
| // around it. We'll do that by scanning over what we already generated |
| // and adding the prefix for each line. |
| var ruleBuf strings.Builder |
| sc := bufio.NewScanner(&buf) |
| ruleBuf.WriteString(leftRuleStart) |
| ruleBuf.WriteByte('\n') |
| for sc.Scan() { |
| line := sc.Text() |
| prefix := leftRuleLine |
| if line == "" { |
| // Don't print the space after the line if there would be nothing |
| // after it anyway. |
| prefix = strings.TrimSpace(prefix) |
| } |
| ruleBuf.WriteString(prefix) |
| ruleBuf.WriteString(line) |
| ruleBuf.WriteByte('\n') |
| } |
| ruleBuf.WriteString(leftRuleEnd) |
| ruleBuf.WriteByte('\n') |
| |
| return ruleBuf.String() |
| } |
| |
| // DiagnosticPlain is an alternative to Diagnostic which minimises the use of |
| // virtual terminal formatting sequences. |
| // |
| // It is intended for use in automation and other contexts in which diagnostic |
| // messages are parsed from the Terraform output. |
| func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string { |
| return DiagnosticPlainFromJSON(viewsjson.NewDiagnostic(diag, sources), width) |
| } |
| |
| func DiagnosticPlainFromJSON(diag *viewsjson.Diagnostic, width int) string { |
| if diag == nil { |
| // No good reason to pass a nil diagnostic in here... |
| return "" |
| } |
| |
| var buf bytes.Buffer |
| |
| switch diag.Severity { |
| case viewsjson.DiagnosticSeverityError: |
| buf.WriteString("\nError: ") |
| case viewsjson.DiagnosticSeverityWarning: |
| buf.WriteString("\nWarning: ") |
| default: |
| buf.WriteString("\n") |
| } |
| |
| // We don't wrap the summary, since we expect it to be terse, and since |
| // this is where we put the text of a native Go error it may not always |
| // be pure text that lends itself well to word-wrapping. |
| fmt.Fprintf(&buf, "%s\n\n", diag.Summary) |
| |
| f := &snippetFormatter{&buf, diag, disabledColorize} |
| f.write() |
| |
| if diag.Detail != "" { |
| if width > 1 { |
| lines := strings.Split(diag.Detail, "\n") |
| for _, line := range lines { |
| if !strings.HasPrefix(line, " ") { |
| line = wordwrap.WrapString(line, uint(width-1)) |
| } |
| fmt.Fprintf(&buf, "%s\n", line) |
| } |
| } else { |
| fmt.Fprintf(&buf, "%s\n", diag.Detail) |
| } |
| } |
| |
| return buf.String() |
| } |
| |
| // DiagnosticWarningsCompact is an alternative to Diagnostic for when all of |
| // the given diagnostics are warnings and we want to show them compactly, |
| // with only two lines per warning and excluding all of the detail information. |
| // |
| // The caller may optionally pre-process the given diagnostics with |
| // ConsolidateWarnings, in which case this function will recognize consolidated |
| // messages and include an indication that they are consolidated. |
| // |
| // Do not pass non-warning diagnostics to this function, or the result will |
| // be nonsense. |
| func DiagnosticWarningsCompact(diags tfdiags.Diagnostics, color *colorstring.Colorize) string { |
| var b strings.Builder |
| b.WriteString(color.Color("[bold][yellow]Warnings:[reset]\n\n")) |
| for _, diag := range diags { |
| sources := tfdiags.WarningGroupSourceRanges(diag) |
| b.WriteString(fmt.Sprintf("- %s\n", diag.Description().Summary)) |
| if len(sources) > 0 { |
| mainSource := sources[0] |
| if mainSource.Subject != nil { |
| if len(sources) > 1 { |
| b.WriteString(fmt.Sprintf( |
| " on %s line %d (and %d more)\n", |
| mainSource.Subject.Filename, |
| mainSource.Subject.Start.Line, |
| len(sources)-1, |
| )) |
| } else { |
| b.WriteString(fmt.Sprintf( |
| " on %s line %d\n", |
| mainSource.Subject.Filename, |
| mainSource.Subject.Start.Line, |
| )) |
| } |
| } else if len(sources) > 1 { |
| b.WriteString(fmt.Sprintf( |
| " (%d occurences of this warning)\n", |
| len(sources), |
| )) |
| } |
| } |
| } |
| |
| return b.String() |
| } |
| |
| // snippetFormatter handles formatting diagnostic information with source snippets |
| type snippetFormatter struct { |
| buf *bytes.Buffer |
| diag *viewsjson.Diagnostic |
| color *colorstring.Colorize |
| } |
| |
| func (f *snippetFormatter) write() { |
| diag := f.diag |
| buf := f.buf |
| color := f.color |
| if diag.Address != "" { |
| fmt.Fprintf(buf, " with %s,\n", diag.Address) |
| } |
| |
| if diag.Range == nil { |
| return |
| } |
| |
| if diag.Snippet == nil { |
| // This should generally not happen, as long as sources are always |
| // loaded through the main loader. We may load things in other |
| // ways in weird cases, so we'll tolerate it at the expense of |
| // a not-so-helpful error message. |
| fmt.Fprintf(buf, " on %s line %d:\n (source code not available)\n", diag.Range.Filename, diag.Range.Start.Line) |
| } else { |
| snippet := diag.Snippet |
| code := snippet.Code |
| |
| var contextStr string |
| if snippet.Context != nil { |
| contextStr = fmt.Sprintf(", in %s", *snippet.Context) |
| } |
| fmt.Fprintf(buf, " on %s line %d%s:\n", diag.Range.Filename, diag.Range.Start.Line, contextStr) |
| |
| // Split the snippet and render the highlighted section with underlines |
| start := snippet.HighlightStartOffset |
| end := snippet.HighlightEndOffset |
| |
| // Only buggy diagnostics can have an end range before the start, but |
| // we need to ensure we don't crash here if that happens. |
| if end < start { |
| end = start + 1 |
| if end > len(code) { |
| end = len(code) |
| } |
| } |
| |
| // If either start or end is out of range for the code buffer then |
| // we'll cap them at the bounds just to avoid a panic, although |
| // this would happen only if there's a bug in the code generating |
| // the snippet objects. |
| if start < 0 { |
| start = 0 |
| } else if start > len(code) { |
| start = len(code) |
| } |
| if end < 0 { |
| end = 0 |
| } else if end > len(code) { |
| end = len(code) |
| } |
| |
| before, highlight, after := code[0:start], code[start:end], code[end:] |
| code = fmt.Sprintf(color.Color("%s[underline]%s[reset]%s"), before, highlight, after) |
| |
| // Split the snippet into lines and render one at a time |
| lines := strings.Split(code, "\n") |
| for i, line := range lines { |
| fmt.Fprintf( |
| buf, "%4d: %s\n", |
| snippet.StartLine+i, |
| line, |
| ) |
| } |
| |
| if len(snippet.Values) > 0 || (snippet.FunctionCall != nil && snippet.FunctionCall.Signature != nil) || snippet.TestAssertionExpr != nil { |
| // The diagnostic may also have information about the dynamic |
| // values of relevant variables at the point of evaluation. |
| // This is particularly useful for expressions that get evaluated |
| // multiple times with different values, such as blocks using |
| // "count" and "for_each", or within "for" expressions. |
| values := slices.Clone(snippet.Values) |
| sort.Slice(values, func(i, j int) bool { |
| return values[i].Traversal < values[j].Traversal |
| }) |
| |
| fmt.Fprint(buf, color.Color(" [dark_gray]├────────────────[reset]\n")) |
| if callInfo := snippet.FunctionCall; callInfo != nil && callInfo.Signature != nil { |
| |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] while calling [bold]%s[reset]("), callInfo.CalledAs) |
| for i, param := range callInfo.Signature.Params { |
| if i > 0 { |
| buf.WriteString(", ") |
| } |
| buf.WriteString(param.Name) |
| } |
| if param := callInfo.Signature.VariadicParam; param != nil { |
| if len(callInfo.Signature.Params) > 0 { |
| buf.WriteString(", ") |
| } |
| buf.WriteString(param.Name) |
| buf.WriteString("...") |
| } |
| buf.WriteString(")\n") |
| } |
| |
| // always print the values unless in the case of a test assertion, where we only print them if the user has requested verbose output |
| printValues := snippet.TestAssertionExpr == nil || snippet.TestAssertionExpr.ShowVerbose |
| |
| // The diagnostic may also have information about failures from test assertions |
| // in a `terraform test` run. This is useful for understanding the values that |
| // were being compared when the assertion failed. |
| // Also, we'll print a JSON diff of the two values to make it easier to see the |
| // differences. |
| if snippet.TestAssertionExpr != nil { |
| f.printTestDiagOutput(snippet.TestAssertionExpr) |
| } |
| |
| if printValues { |
| for _, value := range values { |
| // if the statement is one line, we'll just print it as is |
| // otherwise, we have to ensure that each line is indented correctly |
| // and that the first line has the traversal information |
| valSlice := strings.Split(value.Statement, "\n") |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] [bold]%s[reset] %s\n"), |
| value.Traversal, valSlice[0]) |
| |
| for _, line := range valSlice[1:] { |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] %s\n"), line) |
| } |
| } |
| } |
| } |
| } |
| |
| buf.WriteByte('\n') |
| } |
| |
| func (f *snippetFormatter) printTestDiagOutput(diag *viewsjson.DiagnosticTestBinaryExpr) { |
| buf := f.buf |
| color := f.color |
| // We only print the LHS and RHS if the user has requested verbose output |
| // for the test assertion. |
| if diag.ShowVerbose { |
| fmt.Fprint(buf, color.Color(" [dark_gray]│[reset] [bold]LHS[reset]:\n")) |
| for line := range strings.SplitSeq(diag.LHS, "\n") { |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] %s\n"), line) |
| } |
| fmt.Fprint(buf, color.Color(" [dark_gray]│[reset] [bold]RHS[reset]:\n")) |
| for line := range strings.SplitSeq(diag.RHS, "\n") { |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] %s\n"), line) |
| } |
| } |
| if diag.Warning != "" { |
| fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] [bold]Warning[reset]: %s\n"), diag.Warning) |
| } |
| f.printJSONDiff(diag.LHS, diag.RHS) |
| buf.WriteByte('\n') |
| } |
| |
| // printJSONDiff prints a colorized line-by-line diff of the JSON values of the LHS and RHS expressions |
| // in a test assertion. |
| // It visually distinguishes removed and added lines, helping users identify |
| // discrepancies between an "actual" (lhsStr) and an "expected" (rhsStr) JSON output. |
| func (f *snippetFormatter) printJSONDiff(lhsStr, rhsStr string) { |
| |
| buf := f.buf |
| color := f.color |
| // No visible difference in the JSON, so we'll just return |
| if lhsStr == rhsStr { |
| return |
| } |
| fmt.Fprint(buf, color.Color(" [dark_gray]│[reset] [bold]Diff[reset]:\n")) |
| fmt.Fprint(buf, color.Color(" [dark_gray]│[reset] [red][bold]--- actual[reset]\n")) |
| fmt.Fprint(buf, color.Color(" [dark_gray]│[reset] [green][bold]+++ expected[reset]\n")) |
| nextLhs, stopLhs := iter.Pull(strings.SplitSeq(lhsStr, "\n")) |
| nextRhs, stopRhs := iter.Pull(strings.SplitSeq(rhsStr, "\n")) |
| |
| printLine := func(prefix, line string) { |
| var colour string |
| switch prefix { |
| case "-": |
| colour = "[red]" |
| case "+": |
| colour = "[green]" |
| default: |
| } |
| msg := fmt.Sprintf(" [dark_gray]│[reset] %s%s[reset] %s\n", colour, prefix, line) |
| fmt.Fprint(buf, color.Color(msg)) |
| } |
| |
| // Collect differing lines separately for each side |
| removedLines := []string{} |
| addedLines := []string{} |
| |
| // Function to print collected diffs and reset buffers |
| printDiffs := func() { |
| for _, line := range removedLines { |
| printLine("-", line) |
| } |
| for _, line := range addedLines { |
| printLine("+", line) |
| } |
| removedLines = []string{} |
| addedLines = []string{} |
| } |
| |
| // We'll iterate over both sides of the expression and collect the differences |
| // along the way. When a match is found, we'll then print all the collected diffs |
| // and the matching line, and then reset the buffers. |
| for { |
| lhsLine, lhsOk := nextLhs() |
| rhsLine, rhsOk := nextRhs() |
| |
| if !lhsOk && !rhsOk { // Both sides are done, so we'll print the diffs and break |
| printDiffs() |
| break |
| } |
| |
| // If one side is done, we'll just print the remaining lines from the other side |
| if !lhsOk { |
| addedLines = append(addedLines, rhsLine) |
| continue |
| } |
| if !rhsOk { |
| removedLines = append(removedLines, lhsLine) |
| continue |
| } |
| |
| if lhsLine == rhsLine { |
| printDiffs() |
| printLine(" ", lhsLine) |
| } else { |
| removedLines = append(removedLines, lhsLine) |
| addedLines = append(addedLines, rhsLine) |
| } |
| } |
| |
| stopLhs() |
| stopRhs() |
| } |