github.com/opentofu/opentofu@v1.7.1/internal/command/format/diagnostic.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package format
     7  
     8  import (
     9  	"bufio"
    10  	"bytes"
    11  	"fmt"
    12  	"sort"
    13  	"strings"
    14  
    15  	viewsjson "github.com/opentofu/opentofu/internal/command/views/json"
    16  	"github.com/opentofu/opentofu/internal/tfdiags"
    17  
    18  	"github.com/mitchellh/colorstring"
    19  	wordwrap "github.com/mitchellh/go-wordwrap"
    20  )
    21  
    22  var disabledColorize = &colorstring.Colorize{
    23  	Colors:  colorstring.DefaultColors,
    24  	Disable: true,
    25  }
    26  
    27  // Diagnostic formats a single diagnostic message.
    28  //
    29  // The width argument specifies at what column the diagnostic messages will
    30  // be wrapped. If set to zero, messages will not be wrapped by this function
    31  // at all. Although the long-form text parts of the message are wrapped,
    32  // not all aspects of the message are guaranteed to fit within the specified
    33  // terminal width.
    34  func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize, width int) string {
    35  	return DiagnosticFromJSON(viewsjson.NewDiagnostic(diag, sources), color, width)
    36  }
    37  
    38  func DiagnosticFromJSON(diag *viewsjson.Diagnostic, color *colorstring.Colorize, width int) string {
    39  	if diag == nil {
    40  		// No good reason to pass a nil diagnostic in here...
    41  		return ""
    42  	}
    43  
    44  	var buf bytes.Buffer
    45  
    46  	// these leftRule* variables are markers for the beginning of the lines
    47  	// containing the diagnostic that are intended to help sighted users
    48  	// better understand the information hierarchy when diagnostics appear
    49  	// alongside other information or alongside other diagnostics.
    50  	//
    51  	// Without this, it seems (based on folks sharing incomplete messages when
    52  	// asking questions, or including extra content that's not part of the
    53  	// diagnostic) that some readers have trouble easily identifying which
    54  	// text belongs to the diagnostic and which does not.
    55  	var leftRuleLine, leftRuleStart, leftRuleEnd string
    56  	var leftRuleWidth int // in visual character cells
    57  
    58  	switch diag.Severity {
    59  	case viewsjson.DiagnosticSeverityError:
    60  		buf.WriteString(color.Color("[bold][red]Error: [reset]"))
    61  		leftRuleLine = color.Color("[red]│[reset] ")
    62  		leftRuleStart = color.Color("[red]╷[reset]")
    63  		leftRuleEnd = color.Color("[red]╵[reset]")
    64  		leftRuleWidth = 2
    65  	case viewsjson.DiagnosticSeverityWarning:
    66  		buf.WriteString(color.Color("[bold][yellow]Warning: [reset]"))
    67  		leftRuleLine = color.Color("[yellow]│[reset] ")
    68  		leftRuleStart = color.Color("[yellow]╷[reset]")
    69  		leftRuleEnd = color.Color("[yellow]╵[reset]")
    70  		leftRuleWidth = 2
    71  	default:
    72  		// Clear out any coloring that might be applied by OpenTofu's UI helper,
    73  		// so our result is not context-sensitive.
    74  		buf.WriteString(color.Color("\n[reset]"))
    75  	}
    76  
    77  	// We don't wrap the summary, since we expect it to be terse, and since
    78  	// this is where we put the text of a native Go error it may not always
    79  	// be pure text that lends itself well to word-wrapping.
    80  	fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), diag.Summary)
    81  
    82  	appendSourceSnippets(&buf, diag, color)
    83  
    84  	if diag.Detail != "" {
    85  		paraWidth := width - leftRuleWidth - 1 // leave room for the left rule
    86  		if paraWidth > 0 {
    87  			lines := strings.Split(diag.Detail, "\n")
    88  			for _, line := range lines {
    89  				if !strings.HasPrefix(line, " ") {
    90  					line = wordwrap.WrapString(line, uint(paraWidth))
    91  				}
    92  				fmt.Fprintf(&buf, "%s\n", line)
    93  			}
    94  		} else {
    95  			fmt.Fprintf(&buf, "%s\n", diag.Detail)
    96  		}
    97  	}
    98  
    99  	// Before we return, we'll finally add the left rule prefixes to each
   100  	// line so that the overall message is visually delimited from what's
   101  	// around it. We'll do that by scanning over what we already generated
   102  	// and adding the prefix for each line.
   103  	var ruleBuf strings.Builder
   104  	sc := bufio.NewScanner(&buf)
   105  	ruleBuf.WriteString(leftRuleStart)
   106  	ruleBuf.WriteByte('\n')
   107  	for sc.Scan() {
   108  		line := sc.Text()
   109  		prefix := leftRuleLine
   110  		if line == "" {
   111  			// Don't print the space after the line if there would be nothing
   112  			// after it anyway.
   113  			prefix = strings.TrimSpace(prefix)
   114  		}
   115  		ruleBuf.WriteString(prefix)
   116  		ruleBuf.WriteString(line)
   117  		ruleBuf.WriteByte('\n')
   118  	}
   119  	ruleBuf.WriteString(leftRuleEnd)
   120  	ruleBuf.WriteByte('\n')
   121  
   122  	return ruleBuf.String()
   123  }
   124  
   125  // DiagnosticPlain is an alternative to Diagnostic which minimises the use of
   126  // virtual terminal formatting sequences.
   127  //
   128  // It is intended for use in automation and other contexts in which diagnostic
   129  // messages are parsed from the OpenTofu output.
   130  func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string {
   131  	return DiagnosticPlainFromJSON(viewsjson.NewDiagnostic(diag, sources), width)
   132  }
   133  
   134  func DiagnosticPlainFromJSON(diag *viewsjson.Diagnostic, width int) string {
   135  	if diag == nil {
   136  		// No good reason to pass a nil diagnostic in here...
   137  		return ""
   138  	}
   139  
   140  	var buf bytes.Buffer
   141  
   142  	switch diag.Severity {
   143  	case viewsjson.DiagnosticSeverityError:
   144  		buf.WriteString("\nError: ")
   145  	case viewsjson.DiagnosticSeverityWarning:
   146  		buf.WriteString("\nWarning: ")
   147  	default:
   148  		buf.WriteString("\n")
   149  	}
   150  
   151  	// We don't wrap the summary, since we expect it to be terse, and since
   152  	// this is where we put the text of a native Go error it may not always
   153  	// be pure text that lends itself well to word-wrapping.
   154  	fmt.Fprintf(&buf, "%s\n\n", diag.Summary)
   155  
   156  	appendSourceSnippets(&buf, diag, disabledColorize)
   157  
   158  	if diag.Detail != "" {
   159  		if width > 1 {
   160  			lines := strings.Split(diag.Detail, "\n")
   161  			for _, line := range lines {
   162  				if !strings.HasPrefix(line, " ") {
   163  					line = wordwrap.WrapString(line, uint(width-1))
   164  				}
   165  				fmt.Fprintf(&buf, "%s\n", line)
   166  			}
   167  		} else {
   168  			fmt.Fprintf(&buf, "%s\n", diag.Detail)
   169  		}
   170  	}
   171  
   172  	return buf.String()
   173  }
   174  
   175  // DiagnosticWarningsCompact is an alternative to Diagnostic for when all of
   176  // the given diagnostics are warnings and we want to show them compactly,
   177  // with only two lines per warning and excluding all of the detail information.
   178  //
   179  // The caller may optionally pre-process the given diagnostics with
   180  // ConsolidateWarnings, in which case this function will recognize consolidated
   181  // messages and include an indication that they are consolidated.
   182  //
   183  // Do not pass non-warning diagnostics to this function, or the result will
   184  // be nonsense.
   185  func DiagnosticWarningsCompact(diags tfdiags.Diagnostics, color *colorstring.Colorize) string {
   186  	var b strings.Builder
   187  	b.WriteString(color.Color("[bold][yellow]Warnings:[reset]\n\n"))
   188  	for _, diag := range diags {
   189  		sources := tfdiags.WarningGroupSourceRanges(diag)
   190  		b.WriteString(fmt.Sprintf("- %s\n", diag.Description().Summary))
   191  		if len(sources) > 0 {
   192  			mainSource := sources[0]
   193  			if mainSource.Subject != nil {
   194  				if len(sources) > 1 {
   195  					b.WriteString(fmt.Sprintf(
   196  						"  on %s line %d (and %d more)\n",
   197  						mainSource.Subject.Filename,
   198  						mainSource.Subject.Start.Line,
   199  						len(sources)-1,
   200  					))
   201  				} else {
   202  					b.WriteString(fmt.Sprintf(
   203  						"  on %s line %d\n",
   204  						mainSource.Subject.Filename,
   205  						mainSource.Subject.Start.Line,
   206  					))
   207  				}
   208  			} else if len(sources) > 1 {
   209  				b.WriteString(fmt.Sprintf(
   210  					"  (%d occurences of this warning)\n",
   211  					len(sources),
   212  				))
   213  			}
   214  		}
   215  	}
   216  
   217  	return b.String()
   218  }
   219  
   220  func appendSourceSnippets(buf *bytes.Buffer, diag *viewsjson.Diagnostic, color *colorstring.Colorize) {
   221  	if diag.Address != "" {
   222  		fmt.Fprintf(buf, "  with %s,\n", diag.Address)
   223  	}
   224  
   225  	if diag.Range == nil {
   226  		return
   227  	}
   228  
   229  	if diag.Snippet == nil {
   230  		// This should generally not happen, as long as sources are always
   231  		// loaded through the main loader. We may load things in other
   232  		// ways in weird cases, so we'll tolerate it at the expense of
   233  		// a not-so-helpful error message.
   234  		fmt.Fprintf(buf, "  on %s line %d:\n  (source code not available)\n", diag.Range.Filename, diag.Range.Start.Line)
   235  	} else {
   236  		snippet := diag.Snippet
   237  		code := snippet.Code
   238  
   239  		var contextStr string
   240  		if snippet.Context != nil {
   241  			contextStr = fmt.Sprintf(", in %s", *snippet.Context)
   242  		}
   243  		fmt.Fprintf(buf, "  on %s line %d%s:\n", diag.Range.Filename, diag.Range.Start.Line, contextStr)
   244  
   245  		// Split the snippet and render the highlighted section with underlines
   246  		start := snippet.HighlightStartOffset
   247  		end := snippet.HighlightEndOffset
   248  
   249  		// Only buggy diagnostics can have an end range before the start, but
   250  		// we need to ensure we don't crash here if that happens.
   251  		if end < start {
   252  			end = start + 1
   253  			if end > len(code) {
   254  				end = len(code)
   255  			}
   256  		}
   257  
   258  		// If either start or end is out of range for the code buffer then
   259  		// we'll cap them at the bounds just to avoid a panic, although
   260  		// this would happen only if there's a bug in the code generating
   261  		// the snippet objects.
   262  		if start < 0 {
   263  			start = 0
   264  		} else if start > len(code) {
   265  			start = len(code)
   266  		}
   267  		if end < 0 {
   268  			end = 0
   269  		} else if end > len(code) {
   270  			end = len(code)
   271  		}
   272  
   273  		before, highlight, after := code[0:start], code[start:end], code[end:]
   274  		code = fmt.Sprintf(color.Color("%s[underline]%s[reset]%s"), before, highlight, after)
   275  
   276  		// Split the snippet into lines and render one at a time
   277  		lines := strings.Split(code, "\n")
   278  		for i, line := range lines {
   279  			fmt.Fprintf(
   280  				buf, "%4d: %s\n",
   281  				snippet.StartLine+i,
   282  				line,
   283  			)
   284  		}
   285  
   286  		if len(snippet.Values) > 0 || (snippet.FunctionCall != nil && snippet.FunctionCall.Signature != nil) {
   287  			// The diagnostic may also have information about the dynamic
   288  			// values of relevant variables at the point of evaluation.
   289  			// This is particularly useful for expressions that get evaluated
   290  			// multiple times with different values, such as blocks using
   291  			// "count" and "for_each", or within "for" expressions.
   292  			values := make([]viewsjson.DiagnosticExpressionValue, len(snippet.Values))
   293  			copy(values, snippet.Values)
   294  			sort.Slice(values, func(i, j int) bool {
   295  				return values[i].Traversal < values[j].Traversal
   296  			})
   297  
   298  			fmt.Fprint(buf, color.Color("    [dark_gray]├────────────────[reset]\n"))
   299  			if callInfo := snippet.FunctionCall; callInfo != nil && callInfo.Signature != nil {
   300  
   301  				fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset] while calling [bold]%s[reset]("), callInfo.CalledAs)
   302  				for i, param := range callInfo.Signature.Params {
   303  					if i > 0 {
   304  						buf.WriteString(", ")
   305  					}
   306  					buf.WriteString(param.Name)
   307  				}
   308  				if param := callInfo.Signature.VariadicParam; param != nil {
   309  					if len(callInfo.Signature.Params) > 0 {
   310  						buf.WriteString(", ")
   311  					}
   312  					buf.WriteString(param.Name)
   313  					buf.WriteString("...")
   314  				}
   315  				buf.WriteString(")\n")
   316  			}
   317  			for _, value := range values {
   318  				fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset] [bold]%s[reset] %s\n"), value.Traversal, value.Statement)
   319  			}
   320  		}
   321  	}
   322  
   323  	buf.WriteByte('\n')
   324  }