// Copyright IBM Corp. 2014, 2026 // 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(color.Color(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(disabledColorize.Color(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 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) f.writeSnippet(snippet, code) if diag.DeprecationOriginDescription != "" { fmt.Fprintf(buf, "\n The deprecation originates from %s\n", diag.DeprecationOriginDescription) } } buf.WriteByte('\n') } func (f *snippetFormatter) writeSnippet(snippet *viewsjson.DiagnosticSnippet, code string) { buf := f.buf color := f.color // 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) } } } } } 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() }