package hcl import ( "bufio" "bytes" "errors" "fmt" "io" "sort" wordwrap "github.com/mitchellh/go-wordwrap" "github.com/zclconf/go-cty/cty" ) type diagnosticTextWriter struct { files map[string]*File wr io.Writer width uint color bool } // NewDiagnosticTextWriter creates a DiagnosticWriter that writes diagnostics // to the given writer as formatted text. // // It is designed to produce text appropriate to print in a monospaced font // in a terminal of a particular width, or optionally with no width limit. // // The given width may be zero to disable word-wrapping of the detail text // and truncation of source code snippets. // // If color is set to true, the output will include VT100 escape sequences to // color-code the severity indicators. It is suggested to turn this off if // the target writer is not a terminal. func NewDiagnosticTextWriter(wr io.Writer, files map[string]*File, width uint, color bool) DiagnosticWriter { return &diagnosticTextWriter{ files: files, wr: wr, width: width, color: color, } } func (w *diagnosticTextWriter) WriteDiagnostic(diag *Diagnostic) error { if diag == nil { return errors.New("nil diagnostic") } var colorCode, highlightCode, resetCode string if w.color { switch diag.Severity { case DiagError: colorCode = "\x1b[31m" case DiagWarning: colorCode = "\x1b[33m" } resetCode = "\x1b[0m" highlightCode = "\x1b[1;4m" } var severityStr string switch diag.Severity { case DiagError: severityStr = "Error" case DiagWarning: severityStr = "Warning" default: // should never happen severityStr = "???????" } fmt.Fprintf(w.wr, "%s%s%s: %s\n\n", colorCode, severityStr, resetCode, diag.Summary) if diag.Subject != nil { snipRange := *diag.Subject highlightRange := snipRange if diag.Context != nil { // Show enough of the source code to include both the subject // and context ranges, which overlap in all reasonable // situations. snipRange = RangeOver(snipRange, *diag.Context) } // We can't illustrate an empty range, so we'll turn such ranges into // single-character ranges, which might not be totally valid (may point // off the end of a line, or off the end of the file) but are good // enough for the bounds checks we do below. if snipRange.Empty() { snipRange.End.Byte++ snipRange.End.Column++ } if highlightRange.Empty() { highlightRange.End.Byte++ highlightRange.End.Column++ } file := w.files[diag.Subject.Filename] if file == nil || file.Bytes == nil { fmt.Fprintf(w.wr, " on %s line %d:\n (source code not available)\n\n", diag.Subject.Filename, diag.Subject.Start.Line) } else { var contextLine string if diag.Subject != nil { contextLine = contextString(file, diag.Subject.Start.Byte) if contextLine != "" { contextLine = ", in " + contextLine } } fmt.Fprintf(w.wr, " on %s line %d%s:\n", diag.Subject.Filename, diag.Subject.Start.Line, contextLine) src := file.Bytes sc := NewRangeScanner(src, diag.Subject.Filename, bufio.ScanLines) for sc.Scan() { lineRange := sc.Range() if !lineRange.Overlaps(snipRange) { continue } beforeRange, highlightedRange, afterRange := lineRange.PartitionAround(highlightRange) if highlightedRange.Empty() { fmt.Fprintf(w.wr, "%4d: %s\n", lineRange.Start.Line, sc.Bytes()) } else { before := beforeRange.SliceBytes(src) highlighted := highlightedRange.SliceBytes(src) after := afterRange.SliceBytes(src) fmt.Fprintf( w.wr, "%4d: %s%s%s%s%s\n", lineRange.Start.Line, before, highlightCode, highlighted, resetCode, after, ) } } w.wr.Write([]byte{'\n'}) } if diag.Expression != nil && diag.EvalContext != nil { // We will attempt to render the values for any variables // referenced in the given expression as additional context, for // situations where the same expression is evaluated multiple // times in different scopes. expr := diag.Expression ctx := diag.EvalContext vars := expr.Variables() stmts := make([]string, 0, len(vars)) seen := make(map[string]struct{}, len(vars)) for _, traversal := range vars { 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. continue } traversalStr := w.traversalStr(traversal) if _, exists := seen[traversalStr]; exists { continue // don't show duplicates when the same variable is referenced multiple times } switch { case !val.IsKnown(): // Can't say anything about this yet, then. continue case val.IsNull(): stmts = append(stmts, fmt.Sprintf("%s set to null", traversalStr)) default: stmts = append(stmts, fmt.Sprintf("%s as %s", traversalStr, w.valueStr(val))) } seen[traversalStr] = struct{}{} } sort.Strings(stmts) // FIXME: Should maybe use a traversal-aware sort that can sort numeric indexes properly? last := len(stmts) - 1 for i, stmt := range stmts { switch i { case 0: w.wr.Write([]byte{'w', 'i', 't', 'h', ' '}) default: w.wr.Write([]byte{' ', ' ', ' ', ' ', ' '}) } w.wr.Write([]byte(stmt)) switch i { case last: w.wr.Write([]byte{'.', '\n', '\n'}) default: w.wr.Write([]byte{',', '\n'}) } } } } if diag.Detail != "" { detail := diag.Detail if w.width != 0 { detail = wordwrap.WrapString(detail, w.width) } fmt.Fprintf(w.wr, "%s\n\n", detail) } return nil } func (w *diagnosticTextWriter) WriteDiagnostics(diags Diagnostics) error { for _, diag := range diags { err := w.WriteDiagnostic(diag) if err != nil { return err } } return nil } func (w *diagnosticTextWriter) traversalStr(traversal 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 TraverseRoot: buf.WriteString(tStep.Name) case TraverseAttr: buf.WriteByte('.') buf.WriteString(tStep.Name) case TraverseIndex: buf.WriteByte('[') if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() { buf.WriteString(w.valueStr(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() } func (w *diagnosticTextWriter) valueStr(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. 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 contextString(file *File, offset int) string { type contextStringer interface { ContextString(offset int) string } if cser, ok := file.Nav.(contextStringer); ok { return cser.ContextString(offset) } return "" }