diff --git a/backend/local/backend_local.go b/backend/local/backend_local.go index 4c810f50e..9aaf1c486 100644 --- a/backend/local/backend_local.go +++ b/backend/local/backend_local.go @@ -118,7 +118,10 @@ func (b *Local) context(op *backend.Operation) (*terraform.Context, state.State, continue } if b.CLI != nil { - b.CLI.Warn(format.Diagnostic(diag, b.Colorize(), 72)) + // FIXME: We don't have access to the source code cache + // in here, so we can't produce source code snippets + // from this codepath. + b.CLI.Warn(format.Diagnostic(diag, nil, b.Colorize(), 72)) } else { desc := diag.Description() log.Printf("[WARN] backend/local: %s", desc.Summary) diff --git a/command/format/diagnostic.go b/command/format/diagnostic.go index 4c5f7fff0..7fc2a508c 100644 --- a/command/format/diagnostic.go +++ b/command/format/diagnostic.go @@ -1,9 +1,14 @@ package format import ( + "bufio" "bytes" "fmt" + "strings" + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcled" + "github.com/hashicorp/hcl2/hclparse" "github.com/hashicorp/terraform/tfdiags" "github.com/mitchellh/colorstring" wordwrap "github.com/mitchellh/go-wordwrap" @@ -16,7 +21,7 @@ import ( // 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, color *colorstring.Colorize, width int) string { +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 "" @@ -41,17 +46,74 @@ func Diagnostic(diag tfdiags.Diagnostic, color *colorstring.Colorize, width int) // 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. - if sourceRefs.Subject != nil { - fmt.Fprintf(&buf, color.Color("[bold]%s[reset] at %s\n\n"), desc.Summary, sourceRefs.Subject.StartString()) - } else { - fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), desc.Summary) - } + fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), desc.Summary) - // TODO: also print out the relevant snippet of config source with the - // relevant section highlighted, so the user doesn't need to manually - // correlate back to config. Before we can do this, the HCL2 parser - // needs to be more deeply integrated so that we can use it to obtain - // the parsed source code and AST. + if sourceRefs.Subject != nil { + // 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) + + // 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 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\n", highlightRange.Filename, highlightRange.Start.Line) + } else { + contextStr := sourceCodeContextStr(src, highlightRange) + if contextStr != "" { + contextStr = ", in " + contextStr + } + fmt.Fprintf(&buf, " on %s line %d%s:\n", highlightRange.Filename, highlightRange.Start.Line, contextStr) + + sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines) + for sc.Scan() { + lineRange := sc.Range() + if !lineRange.Overlaps(snippetRange) { + continue + } + beforeRange, highlightedRange, afterRange := lineRange.PartitionAround(highlightRange) + if highlightedRange.Empty() { + fmt.Fprintf(&buf, "%4d: %s\n", lineRange.Start.Line, sc.Bytes()) + } else { + 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, + ) + } + } + + buf.WriteByte('\n') + } + } if desc.Detail != "" { detail := desc.Detail @@ -63,3 +125,33 @@ func Diagnostic(diag tfdiags.Diagnostic, color *colorstring.Colorize, width int) return buf.String() } + +// sourceCodeContextStr attempts to find a user-friendly description of +// the location of the given range in the given source code. +// +// An empty string is returned if no suitable description is available, e.g. +// because the source is invalid, or because the offset is not inside any sort +// of identifiable container. +func sourceCodeContextStr(src []byte, rng hcl.Range) string { + 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 "" + } + + return hcled.ContextString(file, offset) +} diff --git a/command/meta.go b/command/meta.go index f154f2d6c..66ca6ea22 100644 --- a/command/meta.go +++ b/command/meta.go @@ -542,7 +542,7 @@ func (m *Meta) showDiagnostics(vals ...interface{}) { // For now, we don't have easy access to the writer that // ui.Error (etc) are writing to and thus can't interrogate // to see if it's a terminal and what size it is. - msg := format.Diagnostic(diag, m.Colorize(), 78) + msg := format.Diagnostic(diag, nil, m.Colorize(), 78) switch diag.Severity() { case tfdiags.Error: m.Ui.Error(msg) diff --git a/main.go b/main.go index 108f49035..7b88b2925 100644 --- a/main.go +++ b/main.go @@ -135,7 +135,10 @@ func wrappedMain() int { Disable: true, // Disable color to be conservative until we know better Reset: true, } - Ui.Error(format.Diagnostic(diag, earlyColor, 78)) + // We don't currently have access to the source code cache for + // the parser used to load the CLI config, so we can't show + // source code snippets in early diagnostics. + Ui.Error(format.Diagnostic(diag, nil, earlyColor, 78)) } if diags.HasErrors() { Ui.Error("As a result of the above problems, Terraform may not behave as intended.\n\n")