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 }