From bd10b84a8e2b42988413d80c0eae3b2618f42b46 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 28 Feb 2018 17:06:21 -0800 Subject: [PATCH] command/format: include source snippets in diagnostics If we get a diagnostic message that references a source range, and if the source code for the referenced file is available, we'll show a snippet of the source code with the source range highlighted. At the moment we have no cache of source code, so in practice this codepath can never be visited. Callers to format.Diagnostic will be gradually updated in subsequent commits. --- backend/local/backend_local.go | 5 +- command/format/diagnostic.go | 114 +++++++++++++++++++++++++++++---- command/meta.go | 2 +- main.go | 5 +- 4 files changed, 112 insertions(+), 14 deletions(-) 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")