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