| package json |
| |
| import ( |
| "bufio" |
| "bytes" |
| "fmt" |
| "sort" |
| "strings" |
| |
| "github.com/hashicorp/hcl/v2" |
| "github.com/hashicorp/hcl/v2/hcled" |
| "github.com/hashicorp/hcl/v2/hclparse" |
| "github.com/hashicorp/hcl/v2/hclsyntax" |
| "github.com/hashicorp/terraform/internal/lang/marks" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| "github.com/zclconf/go-cty/cty" |
| ) |
| |
| // These severities map to the tfdiags.Severity values, plus an explicit |
| // unknown in case that enum grows without us noticing here. |
| const ( |
| DiagnosticSeverityUnknown = "unknown" |
| DiagnosticSeverityError = "error" |
| DiagnosticSeverityWarning = "warning" |
| ) |
| |
| // Diagnostic represents any tfdiags.Diagnostic value. The simplest form has |
| // just a severity, single line summary, and optional detail. If there is more |
| // information about the source of the diagnostic, this is represented in the |
| // range field. |
| type Diagnostic struct { |
| Severity string `json:"severity"` |
| Summary string `json:"summary"` |
| Detail string `json:"detail"` |
| Address string `json:"address,omitempty"` |
| Range *DiagnosticRange `json:"range,omitempty"` |
| Snippet *DiagnosticSnippet `json:"snippet,omitempty"` |
| } |
| |
| // Pos represents a position in the source code. |
| type Pos struct { |
| // Line is a one-based count for the line in the indicated file. |
| Line int `json:"line"` |
| |
| // Column is a one-based count of Unicode characters from the start of the line. |
| Column int `json:"column"` |
| |
| // Byte is a zero-based offset into the indicated file. |
| Byte int `json:"byte"` |
| } |
| |
| // DiagnosticRange represents the filename and position of the diagnostic |
| // subject. This defines the range of the source to be highlighted in the |
| // output. Note that the snippet may include additional surrounding source code |
| // if the diagnostic has a context range. |
| // |
| // The Start position is inclusive, and the End position is exclusive. Exact |
| // positions are intended for highlighting for human interpretation only and |
| // are subject to change. |
| type DiagnosticRange struct { |
| Filename string `json:"filename"` |
| Start Pos `json:"start"` |
| End Pos `json:"end"` |
| } |
| |
| // DiagnosticSnippet represents source code information about the diagnostic. |
| // It is possible for a diagnostic to have a source (and therefore a range) but |
| // no source code can be found. In this case, the range field will be present and |
| // the snippet field will not. |
| type DiagnosticSnippet struct { |
| // Context is derived from HCL's hcled.ContextString output. This gives a |
| // high-level summary of the root context of the diagnostic: for example, |
| // the resource block in which an expression causes an error. |
| Context *string `json:"context"` |
| |
| // Code is a possibly-multi-line string of Terraform configuration, which |
| // includes both the diagnostic source and any relevant context as defined |
| // by the diagnostic. |
| Code string `json:"code"` |
| |
| // StartLine is the line number in the source file for the first line of |
| // the snippet code block. This is not necessarily the same as the value of |
| // Range.Start.Line, as it is possible to have zero or more lines of |
| // context source code before the diagnostic range starts. |
| StartLine int `json:"start_line"` |
| |
| // HighlightStartOffset is the character offset into Code at which the |
| // diagnostic source range starts, which ought to be highlighted as such by |
| // the consumer of this data. |
| HighlightStartOffset int `json:"highlight_start_offset"` |
| |
| // HighlightEndOffset is the character offset into Code at which the |
| // diagnostic source range ends. |
| HighlightEndOffset int `json:"highlight_end_offset"` |
| |
| // Values is a sorted slice of expression values which may be useful in |
| // understanding the source of an error in a complex expression. |
| Values []DiagnosticExpressionValue `json:"values"` |
| |
| // FunctionCall is information about a function call whose failure is |
| // being reported by this diagnostic, if any. |
| FunctionCall *DiagnosticFunctionCall `json:"function_call,omitempty"` |
| } |
| |
| // DiagnosticExpressionValue represents an HCL traversal string (e.g. |
| // "var.foo") and a statement about its value while the expression was |
| // evaluated (e.g. "is a string", "will be known only after apply"). These are |
| // intended to help the consumer diagnose why an expression caused a diagnostic |
| // to be emitted. |
| type DiagnosticExpressionValue struct { |
| Traversal string `json:"traversal"` |
| Statement string `json:"statement"` |
| } |
| |
| // DiagnosticFunctionCall represents a function call whose information is |
| // being included as part of a diagnostic snippet. |
| type DiagnosticFunctionCall struct { |
| // CalledAs is the full name that was used to call this function, |
| // potentially including namespace prefixes if the function does not belong |
| // to the default function namespace. |
| CalledAs string `json:"called_as"` |
| |
| // Signature is a description of the signature of the function that was |
| // called, if any. Might be omitted if we're reporting that a call failed |
| // because the given function name isn't known, for example. |
| Signature *Function `json:"signature,omitempty"` |
| } |
| |
| // NewDiagnostic takes a tfdiags.Diagnostic and a map of configuration sources, |
| // and returns a Diagnostic struct. |
| func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnostic { |
| var sev string |
| switch diag.Severity() { |
| case tfdiags.Error: |
| sev = DiagnosticSeverityError |
| case tfdiags.Warning: |
| sev = DiagnosticSeverityWarning |
| default: |
| sev = DiagnosticSeverityUnknown |
| } |
| |
| desc := diag.Description() |
| |
| diagnostic := &Diagnostic{ |
| Severity: sev, |
| Summary: desc.Summary, |
| Detail: desc.Detail, |
| Address: desc.Address, |
| } |
| |
| sourceRefs := diag.Source() |
| if sourceRefs.Subject != nil { |
| // We'll borrow HCL's range implementation here, because it has some |
| // handy features to help us produce a nice source code snippet. |
| highlightRange := sourceRefs.Subject.ToHCL() |
| |
| // Some diagnostic sources fail to set the end of the subject range. |
| if highlightRange.End == (hcl.Pos{}) { |
| highlightRange.End = highlightRange.Start |
| } |
| |
| snippetRange := highlightRange |
| if sourceRefs.Context != nil { |
| snippetRange = sourceRefs.Context.ToHCL() |
| } |
| |
| // Make sure the snippet includes the highlight. This should be true |
| // for any reasonable diagnostic, but we'll make sure. |
| snippetRange = hcl.RangeOver(snippetRange, highlightRange) |
| |
| // Empty ranges result in odd diagnostic output, so extend the end to |
| // ensure there's at least one byte in the snippet or highlight. |
| if snippetRange.Empty() { |
| snippetRange.End.Byte++ |
| snippetRange.End.Column++ |
| } |
| if highlightRange.Empty() { |
| highlightRange.End.Byte++ |
| highlightRange.End.Column++ |
| } |
| |
| diagnostic.Range = &DiagnosticRange{ |
| Filename: highlightRange.Filename, |
| Start: Pos{ |
| Line: highlightRange.Start.Line, |
| Column: highlightRange.Start.Column, |
| Byte: highlightRange.Start.Byte, |
| }, |
| End: Pos{ |
| Line: highlightRange.End.Line, |
| Column: highlightRange.End.Column, |
| Byte: highlightRange.End.Byte, |
| }, |
| } |
| |
| var src []byte |
| if sources != nil { |
| src = sources[highlightRange.Filename] |
| } |
| |
| // If we have a source file for the diagnostic, we can emit a code |
| // snippet. |
| if src != nil { |
| diagnostic.Snippet = &DiagnosticSnippet{ |
| StartLine: snippetRange.Start.Line, |
| |
| // Ensure that the default Values struct is an empty array, as this |
| // makes consuming the JSON structure easier in most languages. |
| Values: []DiagnosticExpressionValue{}, |
| } |
| |
| file, offset := parseRange(src, highlightRange) |
| |
| // Some diagnostics may have a useful top-level context to add to |
| // the code snippet output. |
| contextStr := hcled.ContextString(file, offset-1) |
| if contextStr != "" { |
| diagnostic.Snippet.Context = &contextStr |
| } |
| |
| // Build the string of the code snippet, tracking at which byte of |
| // the file the snippet starts. |
| var codeStartByte int |
| sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines) |
| var code strings.Builder |
| for sc.Scan() { |
| lineRange := sc.Range() |
| if lineRange.Overlaps(snippetRange) { |
| if codeStartByte == 0 && code.Len() == 0 { |
| codeStartByte = lineRange.Start.Byte |
| } |
| code.Write(lineRange.SliceBytes(src)) |
| code.WriteRune('\n') |
| } |
| } |
| codeStr := strings.TrimSuffix(code.String(), "\n") |
| diagnostic.Snippet.Code = codeStr |
| |
| // Calculate the start and end byte of the highlight range relative |
| // to the code snippet string. |
| start := highlightRange.Start.Byte - codeStartByte |
| end := start + (highlightRange.End.Byte - highlightRange.Start.Byte) |
| |
| // We can end up with some quirky results here in edge cases like |
| // when a source range starts or ends at a newline character, |
| // so we'll cap the results at the bounds of the highlight range |
| // so that consumers of this data don't need to contend with |
| // out-of-bounds errors themselves. |
| if start < 0 { |
| start = 0 |
| } else if start > len(codeStr) { |
| start = len(codeStr) |
| } |
| if end < 0 { |
| end = 0 |
| } else if end > len(codeStr) { |
| end = len(codeStr) |
| } |
| |
| diagnostic.Snippet.HighlightStartOffset = start |
| diagnostic.Snippet.HighlightEndOffset = end |
| |
| if fromExpr := diag.FromExpr(); fromExpr != nil { |
| // We may also be able to generate information about the dynamic |
| // values of relevant variables at the point of evaluation, then. |
| // This is particularly useful for expressions that get evaluated |
| // multiple times with different values, such as blocks using |
| // "count" and "for_each", or within "for" expressions. |
| expr := fromExpr.Expression |
| ctx := fromExpr.EvalContext |
| vars := expr.Variables() |
| values := make([]DiagnosticExpressionValue, 0, len(vars)) |
| seen := make(map[string]struct{}, len(vars)) |
| includeUnknown := tfdiags.DiagnosticCausedByUnknown(diag) |
| includeSensitive := tfdiags.DiagnosticCausedBySensitive(diag) |
| Traversals: |
| for _, traversal := range vars { |
| for len(traversal) > 1 { |
| val, diags := traversal.TraverseAbs(ctx) |
| if diags.HasErrors() { |
| // Skip anything that generates errors, since we probably |
| // already have the same error in our diagnostics set |
| // already. |
| traversal = traversal[:len(traversal)-1] |
| continue |
| } |
| |
| traversalStr := traversalStr(traversal) |
| if _, exists := seen[traversalStr]; exists { |
| continue Traversals // don't show duplicates when the same variable is referenced multiple times |
| } |
| value := DiagnosticExpressionValue{ |
| Traversal: traversalStr, |
| } |
| switch { |
| case val.HasMark(marks.Sensitive): |
| // We only mention a sensitive value if the diagnostic |
| // we're rendering is explicitly marked as being |
| // caused by sensitive values, because otherwise |
| // readers tend to be misled into thinking the error |
| // is caused by the sensitive value even when it isn't. |
| if !includeSensitive { |
| continue Traversals |
| } |
| // Even when we do mention one, we keep it vague |
| // in order to minimize the chance of giving away |
| // whatever was sensitive about it. |
| value.Statement = "has a sensitive value" |
| case !val.IsKnown(): |
| // We'll avoid saying anything about unknown or |
| // "known after apply" unless the diagnostic is |
| // explicitly marked as being caused by unknown |
| // values, because otherwise readers tend to be |
| // misled into thinking the error is caused by the |
| // unknown value even when it isn't. |
| if ty := val.Type(); ty != cty.DynamicPseudoType { |
| if includeUnknown { |
| value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) |
| } else { |
| value.Statement = fmt.Sprintf("is a %s", ty.FriendlyName()) |
| } |
| } else { |
| if !includeUnknown { |
| continue Traversals |
| } |
| value.Statement = "will be known only after apply" |
| } |
| default: |
| value.Statement = fmt.Sprintf("is %s", compactValueStr(val)) |
| } |
| values = append(values, value) |
| seen[traversalStr] = struct{}{} |
| } |
| } |
| sort.Slice(values, func(i, j int) bool { |
| return values[i].Traversal < values[j].Traversal |
| }) |
| diagnostic.Snippet.Values = values |
| |
| if callInfo := tfdiags.ExtraInfo[hclsyntax.FunctionCallDiagExtra](diag); callInfo != nil && callInfo.CalledFunctionName() != "" { |
| calledAs := callInfo.CalledFunctionName() |
| baseName := calledAs |
| if idx := strings.LastIndex(baseName, "::"); idx >= 0 { |
| baseName = baseName[idx+2:] |
| } |
| callInfo := &DiagnosticFunctionCall{ |
| CalledAs: calledAs, |
| } |
| if f, ok := ctx.Functions[calledAs]; ok { |
| callInfo.Signature = DescribeFunction(baseName, f) |
| } |
| diagnostic.Snippet.FunctionCall = callInfo |
| } |
| |
| } |
| |
| } |
| } |
| |
| return diagnostic |
| } |
| |
| func parseRange(src []byte, rng hcl.Range) (*hcl.File, int) { |
| filename := rng.Filename |
| offset := rng.Start.Byte |
| |
| // We need to re-parse here to get a *hcl.File we can interrogate. This |
| // is not awesome since we presumably already parsed the file earlier too, |
| // but this re-parsing is architecturally simpler than retaining all of |
| // the hcl.File objects and we only do this in the case of an error anyway |
| // so the overhead here is not a big problem. |
| parser := hclparse.NewParser() |
| var file *hcl.File |
| |
| // Ignore diagnostics here as there is nothing we can do with them. |
| if strings.HasSuffix(filename, ".json") { |
| file, _ = parser.ParseJSON(src, filename) |
| } else { |
| file, _ = parser.ParseHCL(src, filename) |
| } |
| |
| return file, offset |
| } |
| |
| // compactValueStr produces a compact, single-line summary of a given value |
| // that is suitable for display in the UI. |
| // |
| // For primitives it returns a full representation, while for more complex |
| // types it instead summarizes the type, size, etc to produce something |
| // that is hopefully still somewhat useful but not as verbose as a rendering |
| // of the entire data structure. |
| func compactValueStr(val cty.Value) string { |
| // This is a specialized subset of value rendering tailored to producing |
| // helpful but concise messages in diagnostics. It is not comprehensive |
| // nor intended to be used for other purposes. |
| |
| if val.HasMark(marks.Sensitive) { |
| // We check this in here just to make sure, but note that the caller |
| // of compactValueStr ought to have already checked this and skipped |
| // calling into compactValueStr anyway, so this shouldn't actually |
| // be reachable. |
| return "(sensitive value)" |
| } |
| |
| // WARNING: We've only checked that the value isn't sensitive _shallowly_ |
| // here, and so we must never show any element values from complex types |
| // in here. However, it's fine to show map keys and attribute names because |
| // those are never sensitive in isolation: the entire value would be |
| // sensitive in that case. |
| |
| ty := val.Type() |
| switch { |
| case val.IsNull(): |
| return "null" |
| case !val.IsKnown(): |
| // Should never happen here because we should filter before we get |
| // in here, but we'll do something reasonable rather than panic. |
| return "(not yet known)" |
| case ty == cty.Bool: |
| if val.True() { |
| return "true" |
| } |
| return "false" |
| case ty == cty.Number: |
| bf := val.AsBigFloat() |
| return bf.Text('g', 10) |
| case ty == cty.String: |
| // Go string syntax is not exactly the same as HCL native string syntax, |
| // but we'll accept the minor edge-cases where this is different here |
| // for now, just to get something reasonable here. |
| return fmt.Sprintf("%q", val.AsString()) |
| case ty.IsCollectionType() || ty.IsTupleType(): |
| l := val.LengthInt() |
| switch l { |
| case 0: |
| return "empty " + ty.FriendlyName() |
| case 1: |
| return ty.FriendlyName() + " with 1 element" |
| default: |
| return fmt.Sprintf("%s with %d elements", ty.FriendlyName(), l) |
| } |
| case ty.IsObjectType(): |
| atys := ty.AttributeTypes() |
| l := len(atys) |
| switch l { |
| case 0: |
| return "object with no attributes" |
| case 1: |
| var name string |
| for k := range atys { |
| name = k |
| } |
| return fmt.Sprintf("object with 1 attribute %q", name) |
| default: |
| return fmt.Sprintf("object with %d attributes", l) |
| } |
| default: |
| return ty.FriendlyName() |
| } |
| } |
| |
| // traversalStr produces a representation of an HCL traversal that is compact, |
| // resembles HCL native syntax, and is suitable for display in the UI. |
| func traversalStr(traversal hcl.Traversal) string { |
| // This is a specialized subset of traversal rendering tailored to |
| // producing helpful contextual messages in diagnostics. It is not |
| // comprehensive nor intended to be used for other purposes. |
| |
| var buf bytes.Buffer |
| for _, step := range traversal { |
| switch tStep := step.(type) { |
| case hcl.TraverseRoot: |
| buf.WriteString(tStep.Name) |
| case hcl.TraverseAttr: |
| buf.WriteByte('.') |
| buf.WriteString(tStep.Name) |
| case hcl.TraverseIndex: |
| buf.WriteByte('[') |
| if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() { |
| buf.WriteString(compactValueStr(tStep.Key)) |
| } else { |
| // We'll just use a placeholder for more complex values, |
| // since otherwise our result could grow ridiculously long. |
| buf.WriteString("...") |
| } |
| buf.WriteByte(']') |
| } |
| } |
| return buf.String() |
| } |