From ca6c6b127c02048aa4aaffc31c71009b3b3155b8 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 18 May 2017 19:01:41 -0700 Subject: [PATCH] Initial support for printing out diagnostics The indication of specific characters in the source code that are in error is not yet implemented, but this gets at the main functionality of printing diagnostics. --- zcl/diagnostic.go | 6 ++ zcl/diagnostic_text.go | 152 ++++++++++++++++++++++++++++++++++++ zcl/diagnostic_text_test.go | 135 ++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 zcl/diagnostic_text.go create mode 100644 zcl/diagnostic_text_test.go diff --git a/zcl/diagnostic.go b/zcl/diagnostic.go index 327b806..e80e233 100644 --- a/zcl/diagnostic.go +++ b/zcl/diagnostic.go @@ -95,3 +95,9 @@ func (d Diagnostics) HasErrors() bool { } return false } + +// A DiagnosticWriter emits diagnostics somehow. +type DiagnosticWriter interface { + WriteDiagnostic(*Diagnostic) error + WriteDiagnostics(Diagnostics) error +} diff --git a/zcl/diagnostic_text.go b/zcl/diagnostic_text.go new file mode 100644 index 0000000..1bdd39d --- /dev/null +++ b/zcl/diagnostic_text.go @@ -0,0 +1,152 @@ +package zcl + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "strings" + + wordwrap "github.com/mitchellh/go-wordwrap" +) + +type diagnosticTextWriter struct { + sources map[string][]byte + 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, sources map[string][]byte, width uint, color bool) DiagnosticWriter { + return &diagnosticTextWriter{ + sources: sources, + wr: wr, + width: width, + color: color, + } +} + +func (w *diagnosticTextWriter) WriteDiagnostic(diag *Diagnostic) error { + if diag == nil { + return errors.New("nil diagnostic") + } + + var colorCode, resetCode string + if w.color { + switch diag.Severity { + case DiagError: + colorCode = "\x1b[31m" + case DiagWarning: + colorCode = "\x1b[33m" + } + resetCode = "\x1b[0m" + } + + 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 { + + src := w.sources[diag.Subject.Filename] + if src == nil { + fmt.Fprintf(w.wr, " on %s line %d:\n (source code not available)\n\n", diag.Subject.Filename, diag.Subject.Start.Line) + } else { + r := bytes.NewReader(src) + sc := bufio.NewScanner(r) + sc.Split(bufio.ScanLines) + contextLine := "" + + var startLine, endLine int + if diag.Context != nil { + startLine = diag.Context.Start.Line + endLine = diag.Context.End.Line + } else { + startLine = diag.Subject.Start.Line + endLine = diag.Subject.End.Line + } + + li := 1 + var ls string + for sc.Scan() { + ls = sc.Text() + if len(ls) > 0 && strings.Contains(ls, "{") && ls[0] != ' ' && ls[0] != '\t' && ls[0] != '#' && ls[0] != '/' && ls[0] != '{' { + // Keep track of the latest non-space-prefixed line we've + // seen, to use as context. This is a pretty sloppy way + // to do this, but it works well enough for most normal + // files. (In particular though, it doesn't work for JSON sources.) + contextLine = fmt.Sprintf(", in %s", strings.Replace(ls, " {", "", 1)) + } + + if li == startLine { + break + } + li++ + } + + fmt.Fprintf(w.wr, " on %s line %d%s:\n", diag.Subject.Filename, diag.Subject.Start.Line, contextLine) + + // TODO: Generate markers for the specific characters that are in the Context and Subject ranges. + // For now, we just print out the lines. + + fmt.Fprintf(w.wr, "%4d: %s\n", li, ls) + + if endLine > li { + for sc.Scan() { + ls = sc.Text() + li++ + + fmt.Fprintf(w.wr, "%4d: %s\n", li, ls) + + if li == endLine { + break + } + } + } + + 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 +} diff --git a/zcl/diagnostic_text_test.go b/zcl/diagnostic_text_test.go new file mode 100644 index 0000000..6930bae --- /dev/null +++ b/zcl/diagnostic_text_test.go @@ -0,0 +1,135 @@ +package zcl + +import ( + "bytes" + "fmt" + "testing" +) + +func TestDiagnosticTextWriter(t *testing.T) { + tests := []struct { + Input *Diagnostic + Want string + }{ + { + &Diagnostic{ + Severity: DiagError, + Summary: "Splines not reticulated", + Detail: "All splines must be pre-reticulated.", + Subject: &Range{ + Start: Pos{ + Byte: 0, + Column: 1, + Line: 1, + }, + End: Pos{ + Byte: 3, + Column: 4, + Line: 1, + }, + }, + }, + `Error: Splines not reticulated + + on line 1: + 1: foo = 1 + +All splines must be pre-reticulated. + +`, + }, + { + &Diagnostic{ + Severity: DiagError, + Summary: "Unsupported attribute", + Detail: `"baz" is not a supported top-level attribute. Did you mean "bam"?`, + Subject: &Range{ + Start: Pos{ + Column: 1, + Line: 3, + }, + End: Pos{ + Column: 4, + Line: 3, + }, + }, + }, + `Error: Unsupported attribute + + on line 3: + 3: baz = 3 + +"baz" is not a supported top-level +attribute. Did you mean "bam"? + +`, + }, + { + &Diagnostic{ + Severity: DiagError, + Summary: "Unsupported attribute", + Detail: `"pizza" is not a supported attribute. Did you mean "pizzetta"?`, + Subject: &Range{ + Start: Pos{ + Column: 3, + Line: 5, + }, + End: Pos{ + Column: 8, + Line: 5, + }, + }, + // This is actually not a great example of a context, but is here to test + // whether we're able to show a multi-line context when needed. + Context: &Range{ + Start: Pos{ + Column: 1, + Line: 4, + }, + End: Pos{ + Column: 2, + Line: 6, + }, + }, + }, + `Error: Unsupported attribute + + on line 5, in block "party": + 4: block "party" { + 5: pizza = "cheese" + 6: } + +"pizza" is not a supported attribute. +Did you mean "pizzetta"? + +`, + }, + } + + sources := map[string][]byte{ + "": []byte(testDiagnosticTextWriterSource), + } + + for i, test := range tests { + t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) { + bwr := &bytes.Buffer{} + dwr := NewDiagnosticTextWriter(bwr, sources, 40, false) + err := dwr.WriteDiagnostic(test.Input) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + got := bwr.String() + if got != test.Want { + t.Errorf("wrong result\n\ngot:\n%swant:\n%s", got, test.Want) + } + }) + } +} + +const testDiagnosticTextWriterSource = `foo = 1 +bar = 2 +baz = 3 +block "party" { + pizza = "cheese" +} +`