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 }