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