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" +} +`