package format 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/terraform/tfdiags" "github.com/mitchellh/colorstring" wordwrap "github.com/mitchellh/go-wordwrap" "github.com/zclconf/go-cty/cty" ) var disabledColorize = &colorstring.Colorize{ Colors: colorstring.DefaultColors, Disable: true, } // Diagnostic formats a single diagnostic message. // // The width argument specifies at what column the diagnostic messages will // be wrapped. If set to zero, messages will not be wrapped by this function // at all. Although the long-form text parts of the message are wrapped, // not all aspects of the message are guaranteed to fit within the specified // terminal width. func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize, width int) string { if diag == nil { // No good reason to pass a nil diagnostic in here... return "" } var buf bytes.Buffer // these leftRule* variables are markers for the beginning of the lines // containing the diagnostic that are intended to help sighted users // better understand the information hierarchy when diagnostics appear // alongside other information or alongside other diagnostics. // // Without this, it seems (based on folks sharing incomplete messages when // asking questions, or including extra content that's not part of the // diagnostic) that some readers have trouble easily identifying which // text belongs to the diagnostic and which does not. var leftRuleLine, leftRuleStart, leftRuleEnd string var leftRuleWidth int // in visual character cells switch diag.Severity() { case tfdiags.Error: buf.WriteString(color.Color("[bold][red]Error: [reset]")) leftRuleLine = color.Color("[red]│[reset] ") leftRuleStart = color.Color("[red]╷[reset]") leftRuleEnd = color.Color("[red]╵[reset]") leftRuleWidth = 2 case tfdiags.Warning: buf.WriteString(color.Color("[bold][yellow]Warning: [reset]")) leftRuleLine = color.Color("[yellow]│[reset] ") leftRuleStart = color.Color("[yellow]╷[reset]") leftRuleEnd = color.Color("[yellow]╵[reset]") leftRuleWidth = 2 default: // Clear out any coloring that might be applied by Terraform's UI helper, // so our result is not context-sensitive. buf.WriteString(color.Color("\n[reset]")) } desc := diag.Description() sourceRefs := diag.Source() // We don't wrap the summary, since we expect it to be terse, and since // this is where we put the text of a native Go error it may not always // be pure text that lends itself well to word-wrapping. fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), desc.Summary) if sourceRefs.Subject != nil { buf = appendSourceSnippets(buf, diag, sources, color) } if desc.Detail != "" { paraWidth := width - leftRuleWidth - 1 // leave room for the left rule if paraWidth > 0 { lines := strings.Split(desc.Detail, "\n") for _, line := range lines { if !strings.HasPrefix(line, " ") { line = wordwrap.WrapString(line, uint(paraWidth)) } fmt.Fprintf(&buf, "%s\n", line) } } else { fmt.Fprintf(&buf, "%s\n", desc.Detail) } } // Before we return, we'll finally add the left rule prefixes to each // line so that the overall message is visually delimited from what's // around it. We'll do that by scanning over what we already generated // and adding the prefix for each line. var ruleBuf strings.Builder sc := bufio.NewScanner(&buf) ruleBuf.WriteString(leftRuleStart) ruleBuf.WriteByte('\n') for sc.Scan() { line := sc.Text() prefix := leftRuleLine if line == "" { // Don't print the space after the line if there would be nothing // after it anyway. prefix = strings.TrimSpace(prefix) } ruleBuf.WriteString(prefix) ruleBuf.WriteString(line) ruleBuf.WriteByte('\n') } ruleBuf.WriteString(leftRuleEnd) ruleBuf.WriteByte('\n') return ruleBuf.String() } // DiagnosticPlain is an alternative to Diagnostic which minimises the use of // virtual terminal formatting sequences. // // It is intended for use in automation and other contexts in which diagnostic // messages are parsed from the Terraform output. func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string { if diag == nil { // No good reason to pass a nil diagnostic in here... return "" } var buf bytes.Buffer switch diag.Severity() { case tfdiags.Error: buf.WriteString("\nError: ") case tfdiags.Warning: buf.WriteString("\nWarning: ") default: buf.WriteString("\n") } desc := diag.Description() sourceRefs := diag.Source() // We don't wrap the summary, since we expect it to be terse, and since // this is where we put the text of a native Go error it may not always // be pure text that lends itself well to word-wrapping. fmt.Fprintf(&buf, "%s\n\n", desc.Summary) if sourceRefs.Subject != nil { buf = appendSourceSnippets(buf, diag, sources, disabledColorize) } if desc.Detail != "" { if width > 1 { lines := strings.Split(desc.Detail, "\n") for _, line := range lines { if !strings.HasPrefix(line, " ") { line = wordwrap.WrapString(line, uint(width-1)) } fmt.Fprintf(&buf, "%s\n", line) } } else { fmt.Fprintf(&buf, "%s\n", desc.Detail) } } return buf.String() } // DiagnosticWarningsCompact is an alternative to Diagnostic for when all of // the given diagnostics are warnings and we want to show them compactly, // with only two lines per warning and excluding all of the detail information. // // The caller may optionally pre-process the given diagnostics with // ConsolidateWarnings, in which case this function will recognize consolidated // messages and include an indication that they are consolidated. // // Do not pass non-warning diagnostics to this function, or the result will // be nonsense. func DiagnosticWarningsCompact(diags tfdiags.Diagnostics, color *colorstring.Colorize) string { var b strings.Builder b.WriteString(color.Color("[bold][yellow]Warnings:[reset]\n\n")) for _, diag := range diags { sources := tfdiags.WarningGroupSourceRanges(diag) b.WriteString(fmt.Sprintf("- %s\n", diag.Description().Summary)) if len(sources) > 0 { mainSource := sources[0] if mainSource.Subject != nil { if len(sources) > 1 { b.WriteString(fmt.Sprintf( " on %s line %d (and %d more)\n", mainSource.Subject.Filename, mainSource.Subject.Start.Line, len(sources)-1, )) } else { b.WriteString(fmt.Sprintf( " on %s line %d\n", mainSource.Subject.Filename, mainSource.Subject.Start.Line, )) } } else if len(sources) > 1 { b.WriteString(fmt.Sprintf( " (%d occurences of this warning)\n", len(sources), )) } } } return b.String() } 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 var diags hcl.Diagnostics if strings.HasSuffix(filename, ".json") { file, diags = parser.ParseJSON(src, filename) } else { file, diags = parser.ParseHCL(src, filename) } if diags.HasErrors() { return file, offset } return file, offset } // 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() } // 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.ContainsMarked() { return "(sensitive value)" } 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() } } func appendSourceSnippets(buf bytes.Buffer, diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize) bytes.Buffer { sourceRefs := diag.Source() // 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() 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) if snippetRange.Empty() { snippetRange.End.Byte++ snippetRange.End.Column++ } if highlightRange.Empty() { highlightRange.End.Byte++ highlightRange.End.Column++ } var src []byte if sources != nil { src = sources[snippetRange.Filename] } if src == nil { // This should generally not happen, as long as sources are always // loaded through the main loader. We may load things in other // ways in weird cases, so we'll tolerate it at the expense of // a not-so-helpful error message. fmt.Fprintf(&buf, " on %s line %d:\n (source code not available)\n", highlightRange.Filename, highlightRange.Start.Line) } else { file, offset := parseRange(src, highlightRange) headerRange := highlightRange contextStr := hcled.ContextString(file, offset-1) if contextStr != "" { contextStr = ", in " + contextStr } fmt.Fprintf(&buf, " on %s line %d%s:\n", headerRange.Filename, headerRange.Start.Line, contextStr) // Config snippet rendering sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines) for sc.Scan() { lineRange := sc.Range() if !lineRange.Overlaps(snippetRange) { continue } if !lineRange.Overlap(highlightRange).Empty() { beforeRange, highlightedRange, afterRange := lineRange.PartitionAround(highlightRange) before := beforeRange.SliceBytes(src) highlighted := highlightedRange.SliceBytes(src) after := afterRange.SliceBytes(src) fmt.Fprintf( &buf, color.Color("%4d: %s[underline]%s[reset]%s\n"), lineRange.Start.Line, before, highlighted, after, ) } else { fmt.Fprintf( &buf, "%4d: %s\n", lineRange.Start.Line, lineRange.SliceBytes(src), ) } } } 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() stmts := make([]string, 0, len(vars)) seen := make(map[string]struct{}, len(vars)) 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 } switch { case val.IsMarked(): // We won't say anything at all about sensitive values, // because we might give away something that was // sensitive about them. stmts = append(stmts, fmt.Sprintf(color.Color("[bold]%s[reset] has a sensitive value"), traversalStr)) case !val.IsKnown(): if ty := val.Type(); ty != cty.DynamicPseudoType { stmts = append(stmts, fmt.Sprintf(color.Color("[bold]%s[reset] is a %s, known only after apply"), traversalStr, ty.FriendlyName())) } else { stmts = append(stmts, fmt.Sprintf(color.Color("[bold]%s[reset] will be known only after apply"), traversalStr)) } case val.IsNull(): stmts = append(stmts, fmt.Sprintf(color.Color("[bold]%s[reset] is null"), traversalStr)) default: stmts = append(stmts, fmt.Sprintf(color.Color("[bold]%s[reset] is %s"), traversalStr, compactValueStr(val))) } seen[traversalStr] = struct{}{} } } sort.Strings(stmts) // FIXME: Should maybe use a traversal-aware sort that can sort numeric indexes properly? if len(stmts) > 0 { fmt.Fprint(&buf, color.Color(" [dark_gray]├────────────────[reset]\n")) } for _, stmt := range stmts { fmt.Fprintf(&buf, color.Color(" [dark_gray]│[reset] %s\n"), stmt) } } buf.WriteByte('\n') return buf }