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

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package json
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"fmt"
    10  	"sort"
    11  	"strings"
    12  
    13  	"github.com/hashicorp/hcl/v2"
    14  	"github.com/hashicorp/hcl/v2/hcled"
    15  	"github.com/hashicorp/hcl/v2/hclparse"
    16  	"github.com/hashicorp/hcl/v2/hclsyntax"
    17  	"github.com/terramate-io/tf/lang/marks"
    18  	"github.com/terramate-io/tf/tfdiags"
    19  	"github.com/zclconf/go-cty/cty"
    20  )
    21  
    22  // These severities map to the tfdiags.Severity values, plus an explicit
    23  // unknown in case that enum grows without us noticing here.
    24  const (
    25  	DiagnosticSeverityUnknown = "unknown"
    26  	DiagnosticSeverityError   = "error"
    27  	DiagnosticSeverityWarning = "warning"
    28  )
    29  
    30  // Diagnostic represents any tfdiags.Diagnostic value. The simplest form has
    31  // just a severity, single line summary, and optional detail. If there is more
    32  // information about the source of the diagnostic, this is represented in the
    33  // range field.
    34  type Diagnostic struct {
    35  	Severity string             `json:"severity"`
    36  	Summary  string             `json:"summary"`
    37  	Detail   string             `json:"detail"`
    38  	Address  string             `json:"address,omitempty"`
    39  	Range    *DiagnosticRange   `json:"range,omitempty"`
    40  	Snippet  *DiagnosticSnippet `json:"snippet,omitempty"`
    41  }
    42  
    43  // Pos represents a position in the source code.
    44  type Pos struct {
    45  	// Line is a one-based count for the line in the indicated file.
    46  	Line int `json:"line"`
    47  
    48  	// Column is a one-based count of Unicode characters from the start of the line.
    49  	Column int `json:"column"`
    50  
    51  	// Byte is a zero-based offset into the indicated file.
    52  	Byte int `json:"byte"`
    53  }
    54  
    55  // DiagnosticRange represents the filename and position of the diagnostic
    56  // subject. This defines the range of the source to be highlighted in the
    57  // output. Note that the snippet may include additional surrounding source code
    58  // if the diagnostic has a context range.
    59  //
    60  // The Start position is inclusive, and the End position is exclusive. Exact
    61  // positions are intended for highlighting for human interpretation only and
    62  // are subject to change.
    63  type DiagnosticRange struct {
    64  	Filename string `json:"filename"`
    65  	Start    Pos    `json:"start"`
    66  	End      Pos    `json:"end"`
    67  }
    68  
    69  // DiagnosticSnippet represents source code information about the diagnostic.
    70  // It is possible for a diagnostic to have a source (and therefore a range) but
    71  // no source code can be found. In this case, the range field will be present and
    72  // the snippet field will not.
    73  type DiagnosticSnippet struct {
    74  	// Context is derived from HCL's hcled.ContextString output. This gives a
    75  	// high-level summary of the root context of the diagnostic: for example,
    76  	// the resource block in which an expression causes an error.
    77  	Context *string `json:"context"`
    78  
    79  	// Code is a possibly-multi-line string of Terraform configuration, which
    80  	// includes both the diagnostic source and any relevant context as defined
    81  	// by the diagnostic.
    82  	Code string `json:"code"`
    83  
    84  	// StartLine is the line number in the source file for the first line of
    85  	// the snippet code block. This is not necessarily the same as the value of
    86  	// Range.Start.Line, as it is possible to have zero or more lines of
    87  	// context source code before the diagnostic range starts.
    88  	StartLine int `json:"start_line"`
    89  
    90  	// HighlightStartOffset is the character offset into Code at which the
    91  	// diagnostic source range starts, which ought to be highlighted as such by
    92  	// the consumer of this data.
    93  	HighlightStartOffset int `json:"highlight_start_offset"`
    94  
    95  	// HighlightEndOffset is the character offset into Code at which the
    96  	// diagnostic source range ends.
    97  	HighlightEndOffset int `json:"highlight_end_offset"`
    98  
    99  	// Values is a sorted slice of expression values which may be useful in
   100  	// understanding the source of an error in a complex expression.
   101  	Values []DiagnosticExpressionValue `json:"values"`
   102  
   103  	// FunctionCall is information about a function call whose failure is
   104  	// being reported by this diagnostic, if any.
   105  	FunctionCall *DiagnosticFunctionCall `json:"function_call,omitempty"`
   106  }
   107  
   108  // DiagnosticExpressionValue represents an HCL traversal string (e.g.
   109  // "var.foo") and a statement about its value while the expression was
   110  // evaluated (e.g. "is a string", "will be known only after apply"). These are
   111  // intended to help the consumer diagnose why an expression caused a diagnostic
   112  // to be emitted.
   113  type DiagnosticExpressionValue struct {
   114  	Traversal string `json:"traversal"`
   115  	Statement string `json:"statement"`
   116  }
   117  
   118  // DiagnosticFunctionCall represents a function call whose information is
   119  // being included as part of a diagnostic snippet.
   120  type DiagnosticFunctionCall struct {
   121  	// CalledAs is the full name that was used to call this function,
   122  	// potentially including namespace prefixes if the function does not belong
   123  	// to the default function namespace.
   124  	CalledAs string `json:"called_as"`
   125  
   126  	// Signature is a description of the signature of the function that was
   127  	// called, if any. Might be omitted if we're reporting that a call failed
   128  	// because the given function name isn't known, for example.
   129  	Signature *Function `json:"signature,omitempty"`
   130  }
   131  
   132  // NewDiagnostic takes a tfdiags.Diagnostic and a map of configuration sources,
   133  // and returns a Diagnostic struct.
   134  func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnostic {
   135  	var sev string
   136  	switch diag.Severity() {
   137  	case tfdiags.Error:
   138  		sev = DiagnosticSeverityError
   139  	case tfdiags.Warning:
   140  		sev = DiagnosticSeverityWarning
   141  	default:
   142  		sev = DiagnosticSeverityUnknown
   143  	}
   144  
   145  	desc := diag.Description()
   146  
   147  	diagnostic := &Diagnostic{
   148  		Severity: sev,
   149  		Summary:  desc.Summary,
   150  		Detail:   desc.Detail,
   151  		Address:  desc.Address,
   152  	}
   153  
   154  	sourceRefs := diag.Source()
   155  	if sourceRefs.Subject != nil {
   156  		// We'll borrow HCL's range implementation here, because it has some
   157  		// handy features to help us produce a nice source code snippet.
   158  		highlightRange := sourceRefs.Subject.ToHCL()
   159  
   160  		// Some diagnostic sources fail to set the end of the subject range.
   161  		if highlightRange.End == (hcl.Pos{}) {
   162  			highlightRange.End = highlightRange.Start
   163  		}
   164  
   165  		snippetRange := highlightRange
   166  		if sourceRefs.Context != nil {
   167  			snippetRange = sourceRefs.Context.ToHCL()
   168  		}
   169  
   170  		// Make sure the snippet includes the highlight. This should be true
   171  		// for any reasonable diagnostic, but we'll make sure.
   172  		snippetRange = hcl.RangeOver(snippetRange, highlightRange)
   173  
   174  		// Empty ranges result in odd diagnostic output, so extend the end to
   175  		// ensure there's at least one byte in the snippet or highlight.
   176  		if snippetRange.Empty() {
   177  			snippetRange.End.Byte++
   178  			snippetRange.End.Column++
   179  		}
   180  		if highlightRange.Empty() {
   181  			highlightRange.End.Byte++
   182  			highlightRange.End.Column++
   183  		}
   184  
   185  		diagnostic.Range = &DiagnosticRange{
   186  			Filename: highlightRange.Filename,
   187  			Start: Pos{
   188  				Line:   highlightRange.Start.Line,
   189  				Column: highlightRange.Start.Column,
   190  				Byte:   highlightRange.Start.Byte,
   191  			},
   192  			End: Pos{
   193  				Line:   highlightRange.End.Line,
   194  				Column: highlightRange.End.Column,
   195  				Byte:   highlightRange.End.Byte,
   196  			},
   197  		}
   198  
   199  		var src []byte
   200  		if sources != nil {
   201  			src = sources[highlightRange.Filename]
   202  		}
   203  
   204  		// If we have a source file for the diagnostic, we can emit a code
   205  		// snippet.
   206  		if src != nil {
   207  			diagnostic.Snippet = &DiagnosticSnippet{
   208  				StartLine: snippetRange.Start.Line,
   209  
   210  				// Ensure that the default Values struct is an empty array, as this
   211  				// makes consuming the JSON structure easier in most languages.
   212  				Values: []DiagnosticExpressionValue{},
   213  			}
   214  
   215  			file, offset := parseRange(src, highlightRange)
   216  
   217  			// Some diagnostics may have a useful top-level context to add to
   218  			// the code snippet output.
   219  			contextStr := hcled.ContextString(file, offset-1)
   220  			if contextStr != "" {
   221  				diagnostic.Snippet.Context = &contextStr
   222  			}
   223  
   224  			// Build the string of the code snippet, tracking at which byte of
   225  			// the file the snippet starts.
   226  			var codeStartByte int
   227  			sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines)
   228  			var code strings.Builder
   229  			for sc.Scan() {
   230  				lineRange := sc.Range()
   231  				if lineRange.Overlaps(snippetRange) {
   232  					if codeStartByte == 0 && code.Len() == 0 {
   233  						codeStartByte = lineRange.Start.Byte
   234  					}
   235  					code.Write(lineRange.SliceBytes(src))
   236  					code.WriteRune('\n')
   237  				}
   238  			}
   239  			codeStr := strings.TrimSuffix(code.String(), "\n")
   240  			diagnostic.Snippet.Code = codeStr
   241  
   242  			// Calculate the start and end byte of the highlight range relative
   243  			// to the code snippet string.
   244  			start := highlightRange.Start.Byte - codeStartByte
   245  			end := start + (highlightRange.End.Byte - highlightRange.Start.Byte)
   246  
   247  			// We can end up with some quirky results here in edge cases like
   248  			// when a source range starts or ends at a newline character,
   249  			// so we'll cap the results at the bounds of the highlight range
   250  			// so that consumers of this data don't need to contend with
   251  			// out-of-bounds errors themselves.
   252  			if start < 0 {
   253  				start = 0
   254  			} else if start > len(codeStr) {
   255  				start = len(codeStr)
   256  			}
   257  			if end < 0 {
   258  				end = 0
   259  			} else if end > len(codeStr) {
   260  				end = len(codeStr)
   261  			}
   262  
   263  			diagnostic.Snippet.HighlightStartOffset = start
   264  			diagnostic.Snippet.HighlightEndOffset = end
   265  
   266  			if fromExpr := diag.FromExpr(); fromExpr != nil {
   267  				// We may also be able to generate information about the dynamic
   268  				// values of relevant variables at the point of evaluation, then.
   269  				// This is particularly useful for expressions that get evaluated
   270  				// multiple times with different values, such as blocks using
   271  				// "count" and "for_each", or within "for" expressions.
   272  				expr := fromExpr.Expression
   273  				ctx := fromExpr.EvalContext
   274  				vars := expr.Variables()
   275  				values := make([]DiagnosticExpressionValue, 0, len(vars))
   276  				seen := make(map[string]struct{}, len(vars))
   277  				includeUnknown := tfdiags.DiagnosticCausedByUnknown(diag)
   278  				includeSensitive := tfdiags.DiagnosticCausedBySensitive(diag)
   279  			Traversals:
   280  				for _, traversal := range vars {
   281  					for len(traversal) > 1 {
   282  						val, diags := traversal.TraverseAbs(ctx)
   283  						if diags.HasErrors() {
   284  							// Skip anything that generates errors, since we probably
   285  							// already have the same error in our diagnostics set
   286  							// already.
   287  							traversal = traversal[:len(traversal)-1]
   288  							continue
   289  						}
   290  
   291  						traversalStr := traversalStr(traversal)
   292  						if _, exists := seen[traversalStr]; exists {
   293  							continue Traversals // don't show duplicates when the same variable is referenced multiple times
   294  						}
   295  						value := DiagnosticExpressionValue{
   296  							Traversal: traversalStr,
   297  						}
   298  						switch {
   299  						case val.HasMark(marks.Sensitive):
   300  							// We only mention a sensitive value if the diagnostic
   301  							// we're rendering is explicitly marked as being
   302  							// caused by sensitive values, because otherwise
   303  							// readers tend to be misled into thinking the error
   304  							// is caused by the sensitive value even when it isn't.
   305  							if !includeSensitive {
   306  								continue Traversals
   307  							}
   308  							// Even when we do mention one, we keep it vague
   309  							// in order to minimize the chance of giving away
   310  							// whatever was sensitive about it.
   311  							value.Statement = "has a sensitive value"
   312  						case !val.IsKnown():
   313  							// We'll avoid saying anything about unknown or
   314  							// "known after apply" unless the diagnostic is
   315  							// explicitly marked as being caused by unknown
   316  							// values, because otherwise readers tend to be
   317  							// misled into thinking the error is caused by the
   318  							// unknown value even when it isn't.
   319  							if ty := val.Type(); ty != cty.DynamicPseudoType {
   320  								if includeUnknown {
   321  									switch {
   322  									case ty.IsCollectionType():
   323  										valRng := val.Range()
   324  										minLen := valRng.LengthLowerBound()
   325  										maxLen := valRng.LengthUpperBound()
   326  										const maxLimit = 1024 // (upper limit is just an arbitrary value to avoid showing distracting large numbers in the UI)
   327  										switch {
   328  										case minLen == maxLen:
   329  											value.Statement = fmt.Sprintf("is a %s of length %d, known only after apply", ty.FriendlyName(), minLen)
   330  										case minLen != 0 && maxLen <= maxLimit:
   331  											value.Statement = fmt.Sprintf("is a %s with between %d and %d elements, known only after apply", ty.FriendlyName(), minLen, maxLen)
   332  										case minLen != 0:
   333  											value.Statement = fmt.Sprintf("is a %s with at least %d elements, known only after apply", ty.FriendlyName(), minLen)
   334  										case maxLen <= maxLimit:
   335  											value.Statement = fmt.Sprintf("is a %s with up to %d elements, known only after apply", ty.FriendlyName(), maxLen)
   336  										default:
   337  											value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName())
   338  										}
   339  									default:
   340  										value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName())
   341  									}
   342  								} else {
   343  									value.Statement = fmt.Sprintf("is a %s", ty.FriendlyName())
   344  								}
   345  							} else {
   346  								if !includeUnknown {
   347  									continue Traversals
   348  								}
   349  								value.Statement = "will be known only after apply"
   350  							}
   351  						default:
   352  							value.Statement = fmt.Sprintf("is %s", compactValueStr(val))
   353  						}
   354  						values = append(values, value)
   355  						seen[traversalStr] = struct{}{}
   356  					}
   357  				}
   358  				sort.Slice(values, func(i, j int) bool {
   359  					return values[i].Traversal < values[j].Traversal
   360  				})
   361  				diagnostic.Snippet.Values = values
   362  
   363  				if callInfo := tfdiags.ExtraInfo[hclsyntax.FunctionCallDiagExtra](diag); callInfo != nil && callInfo.CalledFunctionName() != "" {
   364  					calledAs := callInfo.CalledFunctionName()
   365  					baseName := calledAs
   366  					if idx := strings.LastIndex(baseName, "::"); idx >= 0 {
   367  						baseName = baseName[idx+2:]
   368  					}
   369  					callInfo := &DiagnosticFunctionCall{
   370  						CalledAs: calledAs,
   371  					}
   372  					if f, ok := ctx.Functions[calledAs]; ok {
   373  						callInfo.Signature = DescribeFunction(baseName, f)
   374  					}
   375  					diagnostic.Snippet.FunctionCall = callInfo
   376  				}
   377  
   378  			}
   379  
   380  		}
   381  	}
   382  
   383  	return diagnostic
   384  }
   385  
   386  func parseRange(src []byte, rng hcl.Range) (*hcl.File, int) {
   387  	filename := rng.Filename
   388  	offset := rng.Start.Byte
   389  
   390  	// We need to re-parse here to get a *hcl.File we can interrogate. This
   391  	// is not awesome since we presumably already parsed the file earlier too,
   392  	// but this re-parsing is architecturally simpler than retaining all of
   393  	// the hcl.File objects and we only do this in the case of an error anyway
   394  	// so the overhead here is not a big problem.
   395  	parser := hclparse.NewParser()
   396  	var file *hcl.File
   397  
   398  	// Ignore diagnostics here as there is nothing we can do with them.
   399  	if strings.HasSuffix(filename, ".json") {
   400  		file, _ = parser.ParseJSON(src, filename)
   401  	} else {
   402  		file, _ = parser.ParseHCL(src, filename)
   403  	}
   404  
   405  	return file, offset
   406  }
   407  
   408  // compactValueStr produces a compact, single-line summary of a given value
   409  // that is suitable for display in the UI.
   410  //
   411  // For primitives it returns a full representation, while for more complex
   412  // types it instead summarizes the type, size, etc to produce something
   413  // that is hopefully still somewhat useful but not as verbose as a rendering
   414  // of the entire data structure.
   415  func compactValueStr(val cty.Value) string {
   416  	// This is a specialized subset of value rendering tailored to producing
   417  	// helpful but concise messages in diagnostics. It is not comprehensive
   418  	// nor intended to be used for other purposes.
   419  
   420  	if val.HasMark(marks.Sensitive) {
   421  		// We check this in here just to make sure, but note that the caller
   422  		// of compactValueStr ought to have already checked this and skipped
   423  		// calling into compactValueStr anyway, so this shouldn't actually
   424  		// be reachable.
   425  		return "(sensitive value)"
   426  	}
   427  
   428  	// WARNING: We've only checked that the value isn't sensitive _shallowly_
   429  	// here, and so we must never show any element values from complex types
   430  	// in here. However, it's fine to show map keys and attribute names because
   431  	// those are never sensitive in isolation: the entire value would be
   432  	// sensitive in that case.
   433  
   434  	ty := val.Type()
   435  	switch {
   436  	case val.IsNull():
   437  		return "null"
   438  	case !val.IsKnown():
   439  		// Should never happen here because we should filter before we get
   440  		// in here, but we'll do something reasonable rather than panic.
   441  		return "(not yet known)"
   442  	case ty == cty.Bool:
   443  		if val.True() {
   444  			return "true"
   445  		}
   446  		return "false"
   447  	case ty == cty.Number:
   448  		bf := val.AsBigFloat()
   449  		return bf.Text('g', 10)
   450  	case ty == cty.String:
   451  		// Go string syntax is not exactly the same as HCL native string syntax,
   452  		// but we'll accept the minor edge-cases where this is different here
   453  		// for now, just to get something reasonable here.
   454  		return fmt.Sprintf("%q", val.AsString())
   455  	case ty.IsCollectionType() || ty.IsTupleType():
   456  		l := val.LengthInt()
   457  		switch l {
   458  		case 0:
   459  			return "empty " + ty.FriendlyName()
   460  		case 1:
   461  			return ty.FriendlyName() + " with 1 element"
   462  		default:
   463  			return fmt.Sprintf("%s with %d elements", ty.FriendlyName(), l)
   464  		}
   465  	case ty.IsObjectType():
   466  		atys := ty.AttributeTypes()
   467  		l := len(atys)
   468  		switch l {
   469  		case 0:
   470  			return "object with no attributes"
   471  		case 1:
   472  			var name string
   473  			for k := range atys {
   474  				name = k
   475  			}
   476  			return fmt.Sprintf("object with 1 attribute %q", name)
   477  		default:
   478  			return fmt.Sprintf("object with %d attributes", l)
   479  		}
   480  	default:
   481  		return ty.FriendlyName()
   482  	}
   483  }
   484  
   485  // traversalStr produces a representation of an HCL traversal that is compact,
   486  // resembles HCL native syntax, and is suitable for display in the UI.
   487  func traversalStr(traversal hcl.Traversal) string {
   488  	// This is a specialized subset of traversal rendering tailored to
   489  	// producing helpful contextual messages in diagnostics. It is not
   490  	// comprehensive nor intended to be used for other purposes.
   491  
   492  	var buf bytes.Buffer
   493  	for _, step := range traversal {
   494  		switch tStep := step.(type) {
   495  		case hcl.TraverseRoot:
   496  			buf.WriteString(tStep.Name)
   497  		case hcl.TraverseAttr:
   498  			buf.WriteByte('.')
   499  			buf.WriteString(tStep.Name)
   500  		case hcl.TraverseIndex:
   501  			buf.WriteByte('[')
   502  			if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() {
   503  				buf.WriteString(compactValueStr(tStep.Key))
   504  			} else {
   505  				// We'll just use a placeholder for more complex values,
   506  				// since otherwise our result could grow ridiculously long.
   507  				buf.WriteString("...")
   508  			}
   509  			buf.WriteByte(']')
   510  		}
   511  	}
   512  	return buf.String()
   513  }