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.
This commit is contained in:
Martin Atkins 2018-02-28 17:06:21 -08:00
parent fff2ed1d7e
commit bd10b84a8e
4 changed files with 112 additions and 14 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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")