// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package jsonentities import ( "bufio" "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/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/command/jsonplan" "github.com/opentofu/opentofu/internal/lang/marks" "github.com/opentofu/opentofu/internal/tfdiags" ) // 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"` Difference *jsonplan.Change `json:"difference,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 OpenTofu 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] object as a "UI-flavored" representation of the // diagnostic. func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string]*hcl.File) *Diagnostic { var sev string switch diag.Severity() { case tfdiags.Error: sev = DiagnosticSeverityError case tfdiags.Warning: sev = DiagnosticSeverityWarning default: sev = DiagnosticSeverityUnknown } sourceRefs := diag.Source() highlightRange, snippetRange := prepareDiagnosticRanges(sourceRefs.Subject, sourceRefs.Context) // If the diagnostic has source location information then we will try to construct a snippet // showing a relevant portion of the source code. snippet := newDiagnosticSnippet(snippetRange, highlightRange, sources) if snippet != nil { // We might be able to annotate the snippet with some dynamic-expression-related information, // if this is a suitably-enriched diagnostic. These are not strictly part of the "snippet", // but we return them all together because the human-readable UI presents this information // all together as one UI element. snippet.Values = newDiagnosticExpressionValues(diag) snippet.FunctionCall = newDiagnosticSnippetFunctionCall(diag) } difference := newDiagnosticDifference(diag) desc := diag.Description() return &Diagnostic{ Severity: sev, Summary: desc.Summary, Detail: desc.Detail, Address: desc.Address, Range: newDiagnosticRange(highlightRange), Snippet: snippet, Difference: difference, } } // prepareDiagnosticRanges takes the raw subject and context source ranges from a // diagnostic message and returns the more UI-oriented "highlight" and "snippet" // ranges. // // The "highlight" range describes the characters that are considered to be the // direct cause of the problem, and which are typically presented as underlined // when producing human-readable diagnostics in a terminal that can support that. // // The "snippet" range describes a potentially-larger range of characters that // should all be included in the source code snippet included in the diagnostic // message. The highlight range is guaranteed to be contained within the // snippet range. Some of our diagnostic messages use this, for example, to // ensure that the whole of an expression gets included in the snippet even if // the problem is just one operand of the expression and the expression is wrapped // over multiple lines. func prepareDiagnosticRanges(subject, context *tfdiags.SourceRange) (highlight, snippet *tfdiags.SourceRange) { if subject == nil { // If we don't even have a "subject" then we have no ranges to report at all. return nil, 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 := 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 context != nil { snippetRange = 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 highlightRange.Empty() { highlightRange.End.Byte++ highlightRange.End.Column++ } if snippetRange.Empty() { snippetRange.End.Byte++ snippetRange.End.Column++ } retHighlight := tfdiags.SourceRangeFromHCL(highlightRange) retSnippet := tfdiags.SourceRangeFromHCL(snippetRange) return &retHighlight, &retSnippet } func newDiagnosticRange(highlightRange *tfdiags.SourceRange) *DiagnosticRange { if highlightRange == nil { // No particular range to report, then. return nil } return &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, }, } } func newDiagnosticSnippet(snippetRange, highlightRange *tfdiags.SourceRange, sources map[string]*hcl.File) *DiagnosticSnippet { if snippetRange == nil || highlightRange == nil { // There is no code that is relevant to show in a snippet for this diagnostic. return nil } file, ok := sources[snippetRange.Filename] if !ok { // If we don't have the source code for the file that the snippet is supposed // to come from then we can't produce a snippet. (This tends to happen when // we're rendering a diagnostic from an unusual location that isn't actually // a source file, like an expression entered into the "tofu console" prompt.) return nil } src := file.Bytes if src == nil { // A file without any source bytes? Weird, but perhaps constructed artificially // for testing or for other unusual reasons. return nil } // If we get this far then we're going to do our best to return at least a minimal // snippet, though the level of detail depends on what other information we have // available. ret := &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{}, } // Some callers pass us *hcl.File objects they directly constructed rather than // using the HCL parser, in which case they lack the "navigation metadata" // that HCL's parsers would generate. We need that metadata to extract the // context string below, so we'll make a best effort to obtain that metadata. file = tryHCLFileWithNavMetadata(file, snippetRange.Filename) // Some diagnostics may have a useful top-level context to add to // the code snippet output. This function needs a file with nav metadata // to return a useful result, but it will happily return an empty string // if given a file without that metadata. contextStr := hcled.ContextString(file, highlightRange.Start.Byte-1) if contextStr != "" { ret.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.ToHCL()) { if codeStartByte == 0 && code.Len() == 0 { codeStartByte = lineRange.Start.Byte } code.Write(lineRange.SliceBytes(src)) code.WriteRune('\n') } } codeStr := strings.TrimSuffix(code.String(), "\n") ret.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) } ret.HighlightStartOffset = start ret.HighlightEndOffset = end return ret } // newDiagnosticDifference covers expressions in the binary // expression operation format of x == y. // It's used to create a pretty and descriptive output for the test suite assertions. // For context: https://github.com/opentofu/opentofu/issues/2545 func newDiagnosticDifference(diag tfdiags.Diagnostic) *jsonplan.Change { fromExpr := diag.FromExpr() if fromExpr == nil { return nil } expr := fromExpr.Expression ctx := fromExpr.EvalContext binExpr, ok := expr.(*hclsyntax.BinaryOpExpr) if !ok || binExpr.Op != hclsyntax.OpEqual { // We're not dealing with a binary op expr, return it early return nil } lhs, _ := binExpr.LHS.Value(ctx) rhs, _ := binExpr.RHS.Value(ctx) change, err := jsonplan.GenerateChange(lhs, rhs) if err != nil { return nil } return change } func newDiagnosticExpressionValues(diag tfdiags.Diagnostic) []DiagnosticExpressionValue { fromExpr := diag.FromExpr() if fromExpr == nil { // no expression-related information on this diagnostic, but our // callers always want a non-nil slice in this case because that's // friendlier for JSON serialization. return make([]DiagnosticExpressionValue, 0) } // 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 { // We want to describe as specific a value as possible but since // evaluation failed it's possible that the full traversal is // not actually valid, so we'll try gradually-shorter prefixes // of the traversal until we're able to find an associated // value, reporting the value of the entire top-level symbol // as our worst-case successful outcome. 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 } statement := newDiagnosticSnippetValueDescription(val, includeUnknown, includeSensitive) if statement == "" { // If we don't have anything to say about this value then we won't include // an entry for it at all. continue Traversals } values = append(values, DiagnosticExpressionValue{ Traversal: traversalStr, Statement: statement, }) seen[traversalStr] = struct{}{} continue Traversals } } sort.Slice(values, func(i, j int) bool { return values[i].Traversal < values[j].Traversal }) return values } func newDiagnosticSnippetFunctionCall(diag tfdiags.Diagnostic) *DiagnosticFunctionCall { fromExpr := diag.FromExpr() if fromExpr == nil { return nil // no expression-related information on this diagnostic } callInfo := tfdiags.ExtraInfo[hclsyntax.FunctionCallDiagExtra](diag) if callInfo == nil || callInfo.CalledFunctionName() == "" { return nil // no function call information } ctx := fromExpr.EvalContext calledAs := callInfo.CalledFunctionName() baseName := calledAs if idx := strings.LastIndex(baseName, "::"); idx >= 0 { baseName = baseName[idx+2:] } ret := &DiagnosticFunctionCall{ CalledAs: calledAs, } if f, ok := ctx.Functions[calledAs]; ok { ret.Signature = DescribeFunction(baseName, f) } return ret } func newDiagnosticSnippetValueDescription(val cty.Value, includeUnknown, includeSensitive bool) string { 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 { return "" } // Even when we do mention one, we keep it vague // in order to minimize the chance of giving away // whatever was sensitive about it. return "has a sensitive value" case !val.IsKnown(): ty := val.Type() // 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 !includeUnknown { if ty == cty.DynamicPseudoType { return "" // if we can't even name the type then we'll say nothing at all } // We can at least say what the type is, without mentioning "known after apply" at all return fmt.Sprintf("is a %s", ty.FriendlyName()) } switch { case ty == cty.DynamicPseudoType: return "will be known only after apply" // we don't even know what the type will be case ty.IsCollectionType(): // If the unknown value has collection length refinements then we might at least // be able to give some hints about the expected length. valRng := val.Range() minLen := valRng.LengthLowerBound() maxLen := valRng.LengthUpperBound() const maxLimit = 1024 // (upper limit is just an arbitrary value to avoid showing distracting large numbers in the UI) switch { case minLen == maxLen: return fmt.Sprintf("is a %s of length %d, known only after apply", ty.FriendlyName(), minLen) case minLen != 0 && maxLen <= maxLimit: return fmt.Sprintf("is a %s with between %d and %d elements, known only after apply", ty.FriendlyName(), minLen, maxLen) case minLen != 0: return fmt.Sprintf("is a %s with at least %d elements, known only after apply", ty.FriendlyName(), minLen) case maxLen <= maxLimit: return fmt.Sprintf("is a %s with up to %d elements, known only after apply", ty.FriendlyName(), maxLen) default: return fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) } default: return fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) } default: return fmt.Sprintf("is %s", compactValueStr(val)) } } // 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)" } // val could have deprecated marks as well, so we want to // unmark it first to eliminate the risk of panics. val = marks.RemoveDeepDeprecated(val) // 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 strings.Builder 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() } // tryHCLFileWithNavMetadata takes an hcl.File that might have been directly // constructed rather than produced by an HCL parser, and tries to pass it // through a suitable HCL parser if it lacks the metadata that an HCL parser // would normally add. // // If parsing would be necessary to produce the metadata but parsing fails // then this returns the given file verbatim, so the caller must still be // prepared to deal with a file lacking navigation metadata. func tryHCLFileWithNavMetadata(file *hcl.File, filename string) *hcl.File { if file.Nav != nil { // If there's _something_ in this field then we'll assume that // an HCL parser put it there. The details of this field are // HCL-parser-specific so we don't try to dig any deeper. return file } // If we have a nil nav then we'll try to construct a fully-fledged // file by parsing what we were given. This is best-effort, because // the file might well have been lacking navigation metadata due to // having been invalid in the first place. // Re-parsing a file that might well have already been parsed already // earlier is a little wasteful, but we only get here when we're // returning diagnostics and so we'd rather do a little extra work // if it might allow us to return a better diagnostic. parser := hclparse.NewParser() var newFile *hcl.File if strings.HasSuffix(filename, ".json") { newFile, _ = parser.ParseJSON(file.Bytes, filename) } else { newFile, _ = parser.ParseHCL(file.Bytes, filename) } if newFile == nil { // Our best efforts have failed, then. We'll just return what we had. return file } return newFile }