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