github.com/hugorut/terraform@v1.1.3/src/command/views/json/diagnostic.go (about)

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