github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/format/diagnostic.go (about)

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