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:
parent
fff2ed1d7e
commit
bd10b84a8e
|
@ -118,7 +118,10 @@ func (b *Local) context(op *backend.Operation) (*terraform.Context, state.State,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b.CLI != nil {
|
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 {
|
} else {
|
||||||
desc := diag.Description()
|
desc := diag.Description()
|
||||||
log.Printf("[WARN] backend/local: %s", desc.Summary)
|
log.Printf("[WARN] backend/local: %s", desc.Summary)
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
package format
|
package format
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl2/hcl"
|
||||||
|
"github.com/hashicorp/hcl2/hcled"
|
||||||
|
"github.com/hashicorp/hcl2/hclparse"
|
||||||
"github.com/hashicorp/terraform/tfdiags"
|
"github.com/hashicorp/terraform/tfdiags"
|
||||||
"github.com/mitchellh/colorstring"
|
"github.com/mitchellh/colorstring"
|
||||||
wordwrap "github.com/mitchellh/go-wordwrap"
|
wordwrap "github.com/mitchellh/go-wordwrap"
|
||||||
|
@ -16,7 +21,7 @@ import (
|
||||||
// at all. Although the long-form text parts of the message are wrapped,
|
// 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
|
// not all aspects of the message are guaranteed to fit within the specified
|
||||||
// terminal width.
|
// 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 {
|
if diag == nil {
|
||||||
// No good reason to pass a nil diagnostic in here...
|
// No good reason to pass a nil diagnostic in here...
|
||||||
return ""
|
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
|
// 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
|
// 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.
|
// 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)
|
||||||
|
|
||||||
|
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++
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: also print out the relevant snippet of config source with the
|
var src []byte
|
||||||
// relevant section highlighted, so the user doesn't need to manually
|
if sources != nil {
|
||||||
// correlate back to config. Before we can do this, the HCL2 parser
|
src = sources[snippetRange.Filename]
|
||||||
// needs to be more deeply integrated so that we can use it to obtain
|
}
|
||||||
// the parsed source code and AST.
|
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 != "" {
|
if desc.Detail != "" {
|
||||||
detail := desc.Detail
|
detail := desc.Detail
|
||||||
|
@ -63,3 +125,33 @@ func Diagnostic(diag tfdiags.Diagnostic, color *colorstring.Colorize, width int)
|
||||||
|
|
||||||
return buf.String()
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -542,7 +542,7 @@ func (m *Meta) showDiagnostics(vals ...interface{}) {
|
||||||
// For now, we don't have easy access to the writer that
|
// For now, we don't have easy access to the writer that
|
||||||
// ui.Error (etc) are writing to and thus can't interrogate
|
// ui.Error (etc) are writing to and thus can't interrogate
|
||||||
// to see if it's a terminal and what size it is.
|
// 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() {
|
switch diag.Severity() {
|
||||||
case tfdiags.Error:
|
case tfdiags.Error:
|
||||||
m.Ui.Error(msg)
|
m.Ui.Error(msg)
|
||||||
|
|
5
main.go
5
main.go
|
@ -135,7 +135,10 @@ func wrappedMain() int {
|
||||||
Disable: true, // Disable color to be conservative until we know better
|
Disable: true, // Disable color to be conservative until we know better
|
||||||
Reset: true,
|
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() {
|
if diags.HasErrors() {
|
||||||
Ui.Error("As a result of the above problems, Terraform may not behave as intended.\n\n")
|
Ui.Error("As a result of the above problems, Terraform may not behave as intended.\n\n")
|
||||||
|
|
Loading…
Reference in New Issue