diff --git a/cmd/dang-playground/main.go b/cmd/dang-playground/main.go index e611c936..1983f578 100644 --- a/cmd/dang-playground/main.go +++ b/cmd/dang-playground/main.go @@ -61,6 +61,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "strings" "sync" "syscall/js" @@ -92,23 +93,88 @@ func main() { select {} } +// playgroundFilename is the synthetic filename playground snippets parse and +// evaluate under. Error locations carrying it (or no filename at all) resolve +// against the snippet's own source; renderers drop it from display, exactly +// like the docs build's "literate" (see renderErrorReport in +// docs/go/errorreport.go). +const playgroundFilename = "playground" + // result builds the JS object returned to the page. // // { ok: bool, value: string, output: string, error: string, stage: string } // // stage is "" on success, or "parse" | "type" | "eval" | "auth" identifying // which phase failed. "auth" covers GitHub introspection failures (e.g. an -// expired or unauthorized token). +// expired or unauthorized token). Captured output is stripped of ANSI +// escapes (warnings color themselves for a terminal), matching the build's +// baked output (docs/go/literate.go stripANSI). func result(value, output, errMsg, stage string) map[string]any { return map[string]any{ "ok": errMsg == "", "value": value, - "output": output, + "output": stripANSI(output), "error": errMsg, "stage": stage, } } +var ansiSGR = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiSGR.ReplaceAllString(s, "") +} + +// failure builds the JS object for a failed evaluation: the plain-text +// fields of result plus "report", the structured error report playground.js +// renders as an annotated source snippet (renderErrorReport — which must +// stay in lockstep with the build-side renderer in docs/go/errorreport.go). +// The reporter describes the unit the error came from, so its locations +// resolve without touching a filesystem. +func failure(output string, err error, stage string, rep dang.ErrorReporter) map[string]any { + res := result("", output, bareMessage(err), stage) + res["report"] = reportJS(rep.Report(err)) + return res +} + +// reportJS converts an ErrorReport to plain maps and slices, the only shapes +// js.ValueOf can carry across the wasm boundary. +func reportJS(rep *dang.ErrorReport) map[string]any { + sections := make([]any, 0, len(rep.Sections)) + for _, sec := range rep.Sections { + s := map[string]any{ + "role": sec.Role, + "message": sec.Message, + } + if len(sec.Fields) > 0 { + fields := make([]any, 0, len(sec.Fields)) + for _, f := range sec.Fields { + fields = append(fields, map[string]any{"name": f.Name, "value": f.Value}) + } + s["fields"] = fields + } + if sec.Location != nil { + s["location"] = map[string]any{ + "line": sec.Location.Line, + "column": sec.Location.Column, + "length": sec.Location.Length, + } + } + if sec.Snippet != nil { + lines := make([]any, 0, len(sec.Snippet.Lines)) + for _, l := range sec.Snippet.Lines { + lines = append(lines, l) + } + s["snippet"] = map[string]any{ + "startLine": sec.Snippet.StartLine, + "lines": lines, + } + } + sections = append(sections, s) + } + return map[string]any{"sections": sections} +} + // The bundled "Demo" import: a small GraphQL schema with canned resolvers run // entirely in-process (tests/gqlserver), so `import Demo` resolves with no // network — the offline counterpart to the live `import GitHub`. Built once and @@ -183,9 +249,9 @@ func evalSource(source, token string) map[string]any { } // Parse. - parsed, err := dang.ParseWithRecovery("playground", []byte(source)) + parsed, err := dang.ParseWithRecovery(playgroundFilename, []byte(source)) if err != nil { - return result("", "", err.Error(), "parse") + return failure("", err, "parse", dang.ErrorReporter{Filename: playgroundFilename, Source: source}) } file, ok := parsed.(*dang.FileBlock) if !ok { @@ -218,19 +284,22 @@ func evalSource(source, token string) map[string]any { // Type-check. fresh := hm.NewSimpleFresher() if _, err := dang.InferFormsWithPhases(baseCtx, forms, typeScope, fresh); err != nil { - return result("", "", err.Error(), "type") + return failure("", err, "type", dang.ErrorReporter{Filename: playgroundFilename, Source: source}) } // Evaluate, capturing anything written to stdout/stderr (e.g. log()). var out bytes.Buffer ctx := ioctx.StdoutToContext(baseCtx, &out) ctx = ioctx.StderrToContext(ctx, &out) + // Runtime faults that aren't raises only carry a location when an + // EvalContext supplies one, the same wiring RunFile does. + ctx = dang.WithEvalContext(ctx, dang.NewEvalContext(playgroundFilename, source)) var last dang.Value for _, node := range forms { val, err := dang.EvalNode(ctx, valueScope, node) if err != nil { - return result("", strings.TrimRight(out.String(), "\n"), err.Error(), "eval") + return failure(strings.TrimRight(out.String(), "\n"), err, "eval", dang.ErrorReporter{Filename: playgroundFilename, Source: source}) } last = val } @@ -248,6 +317,38 @@ func evalSource(source, token string) map[string]any { type replSession struct { typeScope dang.TypeScope valueScope dang.ValueScope + + // blocks counts the session's evaluated entries; each parses under the + // synthetic filename blockFilename(n), and sources records every entry's + // text by that name, so an error whose location points into an earlier + // entry still quotes the right source. The docs build numbers its + // literate blocks with the same scheme (docs/go/literate.go) — a page + // replay visits the same blocks in the same order — so any location a + // message spells out matches what the build baked. + blocks int + sources map[string]string +} + +// blockFilename names the nth entry of a session; lockstep with +// docs/go/literate.go's blockFilename. +func blockFilename(n int) string { + return fmt.Sprintf("snippet-%d", n) +} + +// nextBlock assigns the next entry filename and records its source. +func (s *replSession) nextBlock(source string) string { + s.blocks++ + name := blockFilename(s.blocks) + if s.sources == nil { + s.sources = map[string]string{} + } + s.sources[name] = source + return name +} + +// reporter builds the error reporter for an entry evaluated under name. +func (s *replSession) reporter(name, source string) dang.ErrorReporter { + return dang.ErrorReporter{Filename: name, Source: source, Sources: s.sources} } // replSessions holds the live sessions keyed by the handle the frontend assigns @@ -269,14 +370,14 @@ func session(id int) *replSession { return s } -// evalForms parses, type-checks, and evaluates source against the given scopes, -// writing any stdout/stderr through ctx. It returns the stringified value of -// the last form and whether that form is a declaration, or a non-nil error and -// the failing stage ("parse" | "type" | "eval"). The scopes are mutated in -// place, so passing the same scopes to successive calls accumulates session -// state. -func evalForms(ctx context.Context, source string, typeScope dang.TypeScope, valueScope dang.ValueScope, fresh hm.Fresher) (string, bool, error, string) { - parsed, err := dang.ParseWithRecovery("playground", []byte(source)) +// evalForms parses, type-checks, and evaluates source against the given +// scopes as the session entry named name, writing any stdout/stderr through +// ctx. It returns the stringified value of the last form and whether that +// form is a declaration, or a non-nil error and the failing stage ("parse" | +// "type" | "eval"). The scopes are mutated in place, so passing the same +// scopes to successive calls accumulates session state. +func evalForms(ctx context.Context, name, source string, typeScope dang.TypeScope, valueScope dang.ValueScope, fresh hm.Fresher) (string, bool, error, string) { + parsed, err := dang.ParseWithRecovery(name, []byte(source), dang.GlobalStore("filePath", name)) if err != nil { return "", false, err, "parse" } @@ -290,6 +391,10 @@ func evalForms(ctx context.Context, source string, typeScope dang.TypeScope, val return "", false, err, "type" } + // Runtime faults that aren't raises only carry a location when an + // EvalContext supplies one, the same wiring RunFile does. + ctx = dang.WithEvalContext(ctx, dang.NewEvalContext(name, source)) + var lastNode dang.Node var last dang.Value for _, node := range forms { @@ -337,9 +442,10 @@ func dangReplEval(_ js.Value, args []js.Value) any { ctx := ioctx.StdoutToContext(withDemo(context.Background()), &out) ctx = ioctx.StderrToContext(ctx, &out) - value, _, err, stage := evalForms(ctx, source, sess.typeScope, sess.valueScope, fresh) + name := sess.nextBlock(source) + value, _, err, stage := evalForms(ctx, name, source, sess.typeScope, sess.valueScope, fresh) if err != nil { - return result("", strings.TrimRight(out.String(), "\n"), err.Error(), stage) + return failure(strings.TrimRight(out.String(), "\n"), err, stage, sess.reporter(name, source)) } return result(value, strings.TrimRight(out.String(), "\n"), "", "") } @@ -362,9 +468,10 @@ func dangLiterateEval(_ js.Value, args []js.Value) any { ctx := ioctx.StdoutToContext(withDemo(context.Background()), &out) ctx = ioctx.StderrToContext(ctx, &out) - value, lastIsDecl, err, stage := evalForms(ctx, source, sess.typeScope, sess.valueScope, fresh) + name := sess.nextBlock(source) + value, lastIsDecl, err, stage := evalForms(ctx, name, source, sess.typeScope, sess.valueScope, fresh) if err != nil { - return result("", strings.TrimRight(out.String(), "\n"), err.Error(), stage) + return failure(strings.TrimRight(out.String(), "\n"), err, stage, sess.reporter(name, source)) } if lastIsDecl { value = "" @@ -400,9 +507,12 @@ func dangLiterateFailEval(_ js.Value, args []js.Value) any { ctx := ioctx.StdoutToContext(withDemo(context.Background()), &out) ctx = ioctx.StderrToContext(ctx, &out) - value, lastIsDecl, err, stage := evalForms(ctx, source, typeScope, valueScope, fresh) + // The block still takes its place in the session's numbering — the docs + // build counts every block of the page the same way. + name := sess.nextBlock(source) + value, lastIsDecl, err, stage := evalForms(ctx, name, source, typeScope, valueScope, fresh) if err != nil { - return result("", strings.TrimRight(out.String(), "\n"), failureMessage(err), stage) + return failure(strings.TrimRight(out.String(), "\n"), err, stage, sess.reporter(name, source)) } if lastIsDecl { value = "" @@ -410,13 +520,12 @@ func dangLiterateFailEval(_ js.Value, args []js.Value) any { return result(value, strings.TrimRight(out.String(), "\n"), "", "") } -// failureMessage extracts the bare message from an expected failure. -// SourceError.Error() renders for a terminal — ANSI colors plus a quoted -// source span — but the failing block's editor already shows the source, and -// escape codes have no business in the page. The same unwrap lives in -// docs/go/literate.go's failureMessage; the two must stay in lockstep so a -// replay shows exactly the line the build baked. -func failureMessage(err error) string { +// bareMessage extracts the plain-text message from a failure for the +// result's "error" field. SourceError.Error() renders for a terminal — ANSI +// colors plus a quoted source span — but the page renders the structured +// "report" field instead (renderErrorReport in playground.js), and this +// string is only its fallback; escape codes have no business in either. +func bareMessage(err error) string { var srcErr *dang.SourceError if errors.As(err, &srcErr) { return srcErr.Inner.Error() diff --git a/docs/go/errorreport.go b/docs/go/errorreport.go new file mode 100644 index 00000000..9954879c --- /dev/null +++ b/docs/go/errorreport.go @@ -0,0 +1,137 @@ +package dangdocs + +import ( + "fmt" + "html" + "strings" + + "github.com/vito/booklit" + "github.com/vito/dang/v2/pkg/dang" +) + +// renderErrorReport renders a structured error report as the annotated HTML +// analogue of the terminal output: per section, a labeled header line, a +// "--> line:col" location arrow, and the quoted source window with a +// line-number gutter and a ^^^ underline — the same text shape +// formatSourceAnnotation (pkg/dang/errors.go) prints, minus ANSI and minus +// the filename (snippet filenames are synthetic — "literate" here, +// "playground" in the wasm replay — and the source sits right above the +// report anyway). Quoted source lines and field values are +// syntax-highlighted with the same tok-* spans as everything else. +// +// docs/js/playground.js's renderErrorReport builds the same DOM client-side +// so a replay shows exactly what the build baked; the two must stay in +// lockstep. +func renderErrorReport(rep *dang.ErrorReport, stageLabel string) booklit.Content { + var b strings.Builder + for _, sec := range rep.Sections { + label := stageLabel + ":" + switch sec.Role { + case dang.ReportCause: + label = "caused by:" + case dang.ReportSibling: + label = "also failed:" + } + + b.WriteString(`
`) + b.WriteString(`
` + + html.EscapeString(label) + ` ` + html.EscapeString(sec.Message) + `
`) + + for _, f := range sec.Fields { + b.WriteString(`
` + html.EscapeString(f.Name) + `: ` + + highlightResultHTML(f.Value) + `
`) + } + + if sec.Location != nil { + b.WriteString(`
`)
+			b.WriteString(errorSnippetHTML(sec.Location, sec.Snippet))
+			b.WriteString(`
`) + } + + b.WriteString(`
`) + } + return booklit.Styled{Style: "raw-html", Content: booklit.String(b.String())} +} + +// errorSnippetHTML renders the location arrow and quoted source window, +// line for line the text formatSourceAnnotation prints (sans filename). +// With no resolvable snippet it degrades to the bare arrow, like annotate +// in pkg/dang/uncaught.go. +func errorSnippetHTML(loc *dang.SourceLocation, snip *dang.ErrorSnippet) string { + var b strings.Builder + b.WriteString(`` + + html.EscapeString(fmt.Sprintf(" --> %d:%d", loc.Line, loc.Column)) + ``) + if snip == nil { + return b.String() + } + + gutter := func(lineNum string, hl bool) string { + cls := "dang-error-gutter" + if hl { + cls += " is-hl" + } + return `` + fmt.Sprintf(" %3s | ", lineNum) + `` + } + pipe := `` + " " + ` |` + `` + + b.WriteString("\n" + pipe + "\n") + highlighted := highlightSnippetLines(snip.Lines) + for i, lineHTML := range highlighted { + lineNum := snip.StartLine + i + if lineNum == loc.Line { + b.WriteString(gutter(fmt.Sprintf("%d", lineNum), true) + lineHTML + "\n") + // Underline indent mirrors formatSourceAnnotation: 1 leading + // space + 3 gutter + " | " + column-1. + padding := strings.Repeat(" ", 1+3+3+loc.Column-1) + carets := strings.Repeat("^", max(1, loc.Length)) + b.WriteString(padding + `` + carets + `` + "\n") + } else { + b.WriteString(gutter(fmt.Sprintf("%d", lineNum), false) + + `` + lineHTML + `` + "\n") + } + } + b.WriteString(pipe) + return b.String() +} + +// highlightSnippetLines syntax-highlights a snippet's lines as one Dang +// fragment (so multi-line tokens keep their context), returning per-line +// HTML. Without a grammar (or cgo) the lines come back as escaped plain +// text, matching highlightResult's degradation. +func highlightSnippetLines(lines []string) []string { + joined := strings.Join(lines, "\n") + classes := classifyCode("dang", joined) + classAt := func(i int) string { + if classes == nil { + return "" + } + return classes[i] + } + + out := make([]string, 0, len(lines)) + var b strings.Builder + offset := 0 + for li, line := range lines { + b.Reset() + for i := offset; i < offset+len(line); { + cls := classAt(i) + j := i + 1 + for j < offset+len(line) && classAt(j) == cls { + j++ + } + text := html.EscapeString(joined[i:j]) + if cls != "" { + b.WriteString(`` + text + ``) + } else { + b.WriteString(text) + } + i = j + } + out = append(out, b.String()) + offset += len(line) + if li < len(lines)-1 { + offset++ // the joining newline + } + } + return out +} diff --git a/docs/go/errorreport_test.go b/docs/go/errorreport_test.go new file mode 100644 index 00000000..d6b972a2 --- /dev/null +++ b/docs/go/errorreport_test.go @@ -0,0 +1,97 @@ +package dangdocs + +import ( + "strings" + "testing" + + "github.com/vito/dang/v2/pkg/dang" +) + +// renderErrorReport bakes the annotated-snippet HTML that playground.js's +// errorReportHtml rebuilds on replay; this pins the DOM contract the two +// share: section/title/field/snippet classes, the filename-less arrow, the +// terminal's gutter geometry, and the caret underline. +func TestRenderErrorReport(t *testing.T) { + rep := &dang.ErrorReport{ + Sections: []dang.ErrorReportSection{ + { + Role: dang.ReportPrimary, + Message: "uncaught DeployError: deploy failed", + Fields: []dang.ErrorReportField{{Name: "stage", Value: `"push"`}}, + Location: &dang.SourceLocation{ + Filename: "literate", Line: 2, Column: 3, Length: 5, + }, + Snippet: &dang.ErrorSnippet{ + StartLine: 1, + Lines: []string{"first line", " raise it", "third line"}, + }, + }, + { + Role: dang.ReportCause, + Message: "error: connection refused", + // No location at all: an explicit `cause` field. Renders as + // just the labeled header. + }, + { + Role: dang.ReportSibling, + Message: "error: second", + Location: &dang.SourceLocation{Line: 3, Column: 25, Length: 1}, + // Location but no snippet: degrades to the bare arrow. + }, + }, + } + + html := renderErrorReport(rep, "Runtime error").String() + + for _, want := range []string{ + `Runtime error: uncaught DeployError: deploy failed`, + `caused by: error: connection refused`, + `also failed: error: second`, + // Fields keep the terminal's two-space indent. + `
stage: `, + // The arrow drops the synthetic filename. + ` --> 2:3`, + ` --> 3:25`, + // Gutter geometry mirrors formatSourceAnnotation: " %3d | ". + ` 1 | `, + ` 2 | `, + // Underline: 1+3+3 = 7 chars of lead, then column-1, then ^ x length. + "\n" + strings.Repeat(" ", 7+2) + `^^^^^`, + } { + if !strings.Contains(html, want) { + t.Errorf("rendered report missing %q:\n%s", want, html) + } + } + + // Context lines are dimmed; the error line is not. + if !strings.Contains(html, ``) { + t.Errorf("rendered report has no dimmed context lines:\n%s", html) + } + if strings.Contains(html, `is-hl"> 2 | `) { + t.Errorf("the error line must not be dimmed:\n%s", html) + } + + // The cause section has no location, so exactly two snippets render. + if got := strings.Count(html, `
`); got != 2 {
+		t.Errorf("got %d snippets, want 2 (cause has no location):\n%s", got, html)
+	}
+}
+
+// Snippet lines are highlighted as one fragment: a template string spanning
+// lines must not reset per line. Guarded by cgo builds only in effect —
+// without a grammar highlightSnippetLines degrades to escaped plain text,
+// and the per-line split alone is still exercised.
+func TestHighlightSnippetLinesSplit(t *testing.T) {
+	lines := highlightSnippetLines([]string{"let a = 1", "let b = 2"})
+	if len(lines) != 2 {
+		t.Fatalf("got %d lines, want 2: %q", len(lines), lines)
+	}
+	for i, l := range lines {
+		if strings.Contains(l, "\n") {
+			t.Errorf("line %d contains a newline: %q", i, l)
+		}
+	}
+	if !strings.Contains(lines[0], "let a = 1") && !strings.Contains(lines[0], "let") {
+		t.Errorf("line 0 lost its text: %q", lines[0])
+	}
+}
diff --git a/docs/go/literate.go b/docs/go/literate.go
index 7d1b914c..8f4e9003 100644
--- a/docs/go/literate.go
+++ b/docs/go/literate.go
@@ -3,8 +3,8 @@ package dangdocs
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
+	"regexp"
 	"strings"
 
 	"github.com/vito/booklit"
@@ -20,6 +20,32 @@ import (
 type literateSession struct {
 	typeScope  dang.TypeScope
 	valueScope dang.ValueScope
+
+	// blocks counts the session's evaluated blocks; each parses under the
+	// synthetic filename blockFilename(n), and sources records every block's
+	// text by that name. Error locations carry the defining block's filename,
+	// so a failure in one block can quote a function raised blocks earlier.
+	blocks  int
+	sources map[string]string
+}
+
+// blockFilename names the nth block of a session. The wasm replay
+// (cmd/dang-playground) numbers its session entries with the same scheme —
+// a page replay visits the same blocks in the same order — so any location
+// a message spells out matches what the build baked.
+func blockFilename(n int) string {
+	return fmt.Sprintf("snippet-%d", n)
+}
+
+// nextBlock assigns the next block filename and records its source.
+func (s *literateSession) nextBlock(source string) string {
+	s.blocks++
+	name := blockFilename(s.blocks)
+	if s.sources == nil {
+		s.sources = map[string]string{}
+	}
+	s.sources[name] = source
+	return name
 }
 
 // literateFencesPartial is the section partial under which \literate-fences
@@ -139,7 +165,7 @@ func (p Plugin) DangLiterateFailure(code booklit.Content) (booklit.Content, erro
 	return p.literateFailureBlock(code, `\dang-literate-failure block`)
 }
 
-// stageLabels mirrors playground.js's STAGE_LABEL: the baked error line must
+// stageLabels mirrors playground.js's STAGE_LABEL: the baked error header must
 // read exactly like the one renderReplOutput shows after a client-side
 // replay, label and all.
 var stageLabels = map[string]string{
@@ -148,6 +174,17 @@ var stageLabels = map[string]string{
 	"eval":  "Runtime error",
 }
 
+// stripANSI removes ANSI SGR escape sequences from build-time captured
+// output: escape codes have no business in HTML, and warnings printed
+// during evaluation (WarnAtSource) color themselves for a terminal. The
+// wasm module strips its captured output the same way (cmd/dang-playground)
+// so a replay shows exactly what the build baked.
+var ansiSGR = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+func stripANSI(s string) string {
+	return ansiSGR.ReplaceAllString(s, "")
+}
+
 // literateBlock evaluates and renders one literate snippet; label names the
 // originating syntax in build errors.
 func (p Plugin) literateBlock(code booklit.Content, label string) (booklit.Content, error) {
@@ -186,13 +223,18 @@ func (p Plugin) literateFailureBlock(code booklit.Content, label string) (bookli
 	source := strings.TrimRight(code.String(), "\n")
 	sess := literateSessionFor(p.section)
 
-	stdout, stage, failure := literateFailEval(source, sess)
+	stdout, stage, failure, blockName := literateFailEval(source, sess)
 	if failure == nil {
 		return nil, fmt.Errorf("%s in %s: expected the snippet to fail, but it succeeded — use a plain ```dang fence", label, p.section.FilePath())
 	}
 
+	report := dang.ErrorReporter{
+		Filename: blockName,
+		Source:   source,
+		Sources:  sess.sources,
+	}.Report(failure)
 	partials := booklit.Partials{
-		"Error": booklit.String(stageLabels[stage] + ": " + failureMessage(failure)),
+		"Error": renderErrorReport(report, stageLabels[stage]),
 	}
 	if stdout != "" {
 		partials["Stdout"] = booklit.String(stdout)
@@ -206,20 +248,6 @@ func (p Plugin) literateFailureBlock(code booklit.Content, label string) (bookli
 	}, nil
 }
 
-// failureMessage extracts the bare message from a snippet's failure.
-// SourceError.Error() renders for a terminal — ANSI colors plus a quoted
-// source span — but the snippet already sits right above the baked error,
-// and escape codes have no business in HTML. The same unwrap lives in
-// dangLiterateFailEval (cmd/dang-playground) so a client-side replay shows
-// the identical line.
-func failureMessage(err error) string {
-	var srcErr *dang.SourceError
-	if errors.As(err, &srcErr) {
-		return srcErr.Inner.Error()
-	}
-	return err.Error()
-}
-
 // literateFailEval runs source the way literateEval does, but against
 // throwaway forks of the session's scopes: a cloned type scope (declarations
 // land in the discarded child layer) and a sealed child value scope (even
@@ -227,16 +255,21 @@ func failureMessage(err error) string {
 // failing block's partial state is unknowable, so it contributes nothing to
 // the page's chain — the same isolation dangLiterateFailEval
 // (cmd/dang-playground) applies when the page is replayed client-side. It
-// returns the captured output plus the failing stage ("parse" | "type" |
-// "eval") and error; a nil error means the snippet unexpectedly succeeded.
-func literateFailEval(source string, sess *literateSession) (string, string, error) {
-	parsed, err := dang.ParseWithRecovery("literate", []byte(source))
+// returns the captured output, the failing stage ("parse" | "type" |
+// "eval") and error, and the block's assigned filename; a nil error means
+// the snippet unexpectedly succeeded.
+func literateFailEval(source string, sess *literateSession) (string, string, error, string) {
+	// The block still takes its place in the session's numbering — the wasm
+	// replay counts every block of the chain the same way.
+	name := sess.nextBlock(source)
+
+	parsed, err := dang.ParseWithRecovery(name, []byte(source), dang.GlobalStore("filePath", name))
 	if err != nil {
-		return "", "parse", err
+		return "", "parse", err, name
 	}
 	file, ok := parsed.(*dang.FileBlock)
 	if !ok {
-		return "", "parse", fmt.Errorf("unexpected parse result")
+		return "", "parse", fmt.Errorf("unexpected parse result"), name
 	}
 	forms := file.Forms
 
@@ -245,19 +278,22 @@ func literateFailEval(source string, sess *literateSession) (string, string, err
 
 	fresh := hm.NewSimpleFresher()
 	if _, err := dang.InferFormsWithPhases(context.Background(), forms, typeScope, fresh); err != nil {
-		return "", "type", err
+		return "", "type", err, name
 	}
 
 	var out bytes.Buffer
 	ctx := ioctx.StdoutToContext(context.Background(), &out)
 	ctx = ioctx.StderrToContext(ctx, &out)
+	// Runtime faults that aren't raises only carry a location when an
+	// EvalContext supplies one, the same wiring RunFile does.
+	ctx = dang.WithEvalContext(ctx, dang.NewEvalContext(name, source))
 
 	for _, node := range forms {
 		if _, err := dang.EvalNode(ctx, valueScope, node); err != nil {
-			return strings.TrimRight(out.String(), "\n"), "eval", err
+			return strings.TrimRight(stripANSI(out.String()), "\n"), "eval", err, name
 		}
 	}
-	return strings.TrimRight(out.String(), "\n"), "", nil
+	return strings.TrimRight(stripANSI(out.String()), "\n"), "", nil, name
 }
 
 // literateEval parses, type-checks, and evaluates source against the
@@ -278,7 +314,9 @@ func literateEval(source string, sess *literateSession) (string, string, error)
 // bundled in-process schema. The base context backs both phases so the import's
 // schema-module identity is shared between them.
 func literateEvalCtx(base context.Context, source string, sess *literateSession) (string, string, error) {
-	parsed, err := dang.ParseWithRecovery("literate", []byte(source))
+	name := sess.nextBlock(source)
+
+	parsed, err := dang.ParseWithRecovery(name, []byte(source), dang.GlobalStore("filePath", name))
 	if err != nil {
 		return "", "", err
 	}
@@ -296,6 +334,7 @@ func literateEvalCtx(base context.Context, source string, sess *literateSession)
 	var out bytes.Buffer
 	ctx := ioctx.StdoutToContext(base, &out)
 	ctx = ioctx.StderrToContext(ctx, &out)
+	ctx = dang.WithEvalContext(ctx, dang.NewEvalContext(name, source))
 
 	var last dang.Node
 	var lastVal dang.Value
@@ -312,5 +351,5 @@ func literateEvalCtx(base context.Context, source string, sess *literateSession)
 	if lastVal != nil && (last == nil || len(last.DeclaredSymbols()) == 0) {
 		value = dang.Repr(lastVal)
 	}
-	return strings.TrimRight(out.String(), "\n"), value, nil
+	return strings.TrimRight(stripANSI(out.String()), "\n"), value, nil
 }
diff --git a/docs/go/literate_test.go b/docs/go/literate_test.go
index 6c1089d1..8b8632dc 100644
--- a/docs/go/literate_test.go
+++ b/docs/go/literate_test.go
@@ -149,7 +149,10 @@ func TestCodeBlockFailureRouting(t *testing.T) {
 		t.Fatalf("seeding session: %v", err)
 	}
 
-	// The failure block sees the session's state (x) and bakes its error.
+	// The failure block sees the session's state (x) and bakes its error as a
+	// structured report: a header labeled like playground.js's STAGE_LABEL
+	// (errorReportHtml must render the same DOM on replay), then the annotated
+	// source snippet with gutter and underline.
 	failed, err := render("dang-failure", "x.toUpper")
 	if err != nil {
 		t.Fatalf("dang-failure fence: %v", err)
@@ -158,8 +161,19 @@ func TestCodeBlockFailureRouting(t *testing.T) {
 	if errPartial == nil {
 		t.Fatal("dang-failure fence baked no Error partial")
 	}
-	if got := errPartial.String(); !strings.HasPrefix(got, "Type error: ") {
-		t.Errorf("baked error %q, want a %q prefix matching playground.js's STAGE_LABEL", got, "Type error: ")
+	baked := errPartial.String()
+	if want := `Type error:`; !strings.Contains(baked, want) {
+		t.Errorf("baked error %q missing header label %q", baked, want)
+	}
+	if want := `  --> 1:`; !strings.Contains(baked, want) {
+		t.Errorf("baked error %q missing location arrow %q", baked, want)
+	}
+	if !strings.Contains(baked, `dang-error-underline`) || !strings.Contains(baked, "^") {
+		t.Errorf("baked error %q missing the ^^^ underline", baked)
+	}
+	// The failing line is quoted (split across tok-* spans, so compare text).
+	if text := valueText(errPartial); !strings.Contains(text, "x.toUpper") {
+		t.Errorf("baked error text %q does not quote the failing source line", text)
 	}
 	if failed.Partials["Value"] != nil {
 		t.Errorf("dang-failure fence baked a Value: %v", failed.Partials["Value"])
@@ -197,6 +211,40 @@ func TestCodeBlockFailureRouting(t *testing.T) {
 	}
 }
 
+// A failure whose raise site lives in an earlier fence quotes that fence's
+// source: blocks parse under per-block filenames and the session records
+// each block's text, so cross-fence locations resolve to the right snippet
+// instead of misquoting the failing fence (or dangling with no snippet).
+func TestFailureQuotesEarlierFence(t *testing.T) {
+	root := &booklit.Section{Path: "cross-fence-test.md"}
+	lit := &booklit.Section{Parent: root}
+	Plugin{section: lit}.LiterateFences()
+
+	render := func(language, source string) (booklit.Styled, error) {
+		t.Helper()
+		content, err := Plugin{section: lit}.CodeBlock(language, booklit.Preformatted{booklit.String(source)})
+		if err != nil {
+			return booklit.Styled{}, err
+		}
+		return content.(booklit.Styled), nil
+	}
+
+	if _, err := render("dang", `boom: String! { raise "kapow" }`); err != nil {
+		t.Fatalf("defining fence: %v", err)
+	}
+	failed, err := render("dang-failure", "boom")
+	if err != nil {
+		t.Fatalf("dang-failure fence: %v", err)
+	}
+	text := valueText(failed.Partials["Error"])
+	if !strings.Contains(text, "kapow") {
+		t.Fatalf("baked error %q missing the raised message", text)
+	}
+	if !strings.Contains(text, `raise "kapow"`) {
+		t.Errorf("baked error %q does not quote the raise site from the earlier fence", text)
+	}
+}
+
 // Booklit's dev server re-loads the whole book on every page request,
 // concurrently across requests, and Dang scopes are not safe for concurrent
 // use — so each load must get its own sessions. Each goroutine here simulates
diff --git a/docs/go/render.go b/docs/go/render.go
index fb86b05a..b2bdd79c 100644
--- a/docs/go/render.go
+++ b/docs/go/render.go
@@ -164,6 +164,13 @@ func renderCode(section *booklit.Section, language, source string, links []linkS
 // inherit the result line's color (.dang-playground-result, green). Without a
 // grammar (or cgo) it degrades to escaped plain text.
 func highlightResult(value string) booklit.Content {
+	return booklit.Styled{Style: "raw-html", Content: booklit.String(highlightResultHTML(value))}
+}
+
+// highlightResultHTML is highlightResult's raw HTML form: escaped text in
+// bare tok-* spans. It also backs the quoted source lines and field values
+// in baked error reports (errorreport.go).
+func highlightResultHTML(value string) string {
 	classes := classifyCode("dang", value)
 	classAt := func(i int) string {
 		if classes == nil {
@@ -187,5 +194,5 @@ func highlightResult(value string) booklit.Content {
 		}
 		i = j
 	}
-	return booklit.Styled{Style: "raw-html", Content: booklit.String(raw.String())}
+	return raw.String()
 }
diff --git a/docs/html/page.tmpl b/docs/html/page.tmpl
index 3e51b457..face64b1 100644
--- a/docs/html/page.tmpl
+++ b/docs/html/page.tmpl
@@ -172,6 +172,23 @@ pre code{display:block}
 .dang-playground-stdout{color:var(--fg);margin-bottom:.3rem;white-space:pre-wrap}
 .dang-playground-result{color:var(--green);white-space:pre-wrap}
 .dang-playground-error{color:var(--pink);white-space:pre-wrap}
+/* Structured error reports: the annotated source snippet the CLI prints,
+   as HTML (docs/go/errorreport.go bakes it; playground.js re-renders the
+   same DOM on replay). The header keeps the pink error color; the quoted
+   source goes back to full syntax coloring, with context lines dimmed the
+   way the terminal dims them. */
+.dang-error-section+.dang-error-section{margin-top:.6rem}
+.dang-error-label{font-weight:600}
+.dang-error-field{color:var(--fg);opacity:.75;white-space:pre-wrap}
+.dang-error-snippet{
+  color:var(--fg);font-size:inherit;line-height:var(--code-line-height);
+  margin:.2rem 0 0;white-space:pre;overflow-x:auto;
+}
+.dang-error-arrow{color:var(--link);opacity:.8}
+.dang-error-gutter{color:var(--fg2);opacity:.7}
+.dang-error-gutter.is-hl{color:var(--link);opacity:1;font-weight:600}
+.dang-error-dim{opacity:.55}
+.dang-error-underline{color:var(--pink);font-weight:700}
 .dang-playground-fallback{
   background:var(--code-bg);color:var(--base05);font-family:'JetBrains Mono',monospace;
   font-size:.8rem;line-height:var(--code-line-height);padding:1rem 1.25rem;margin:0;white-space:pre;overflow-x:auto;
@@ -285,6 +302,12 @@ pre code{display:block}
 .dang-carousel .dang-playground textarea {
   font-size: 16px;
 }
+/* …except the quoted snippet inside an error report, which stays at the
+   output panel's size (an edited slide that errors renders one there). */
+.dang-carousel .dang-literate pre.dang-error-snippet,
+.dang-carousel .dang-playground pre.dang-error-snippet {
+  font-size: inherit;
+}
 /* The single bar: a horizontally-clipped track of feature tabs. carousel.js
    slides the track so the active tab sits at the left as the header. */
 .dang-carousel-tabs{
diff --git a/docs/js/playground.js b/docs/js/playground.js
index 97620dc7..b2f14844 100644
--- a/docs/js/playground.js
+++ b/docs/js/playground.js
@@ -161,7 +161,10 @@
       } catch (e) {
         if (window.console) console.warn("dang playground: injection query unavailable:", e);
       }
-      ts = { parser: parser, query: query, injQuery: injQuery, mod: mod };
+      // retryWrap mirrors highlight.go's signaturePrefix/Suffix: fragments
+      // that don't parse bare (declaration signatures, error-snippet windows)
+      // retry inside a synthetic interface body.
+      ts = { parser: parser, query: query, injQuery: injQuery, mod: mod, retryWrap: ["interface _ {\n", "\n}"] };
       return ts;
     })().catch(function (err) {
       // Highlighting is best-effort; fall back to plain text on failure.
@@ -256,32 +259,72 @@
     return null;
   }
 
+  // captureSpans parses src inside optional affixes and returns highlight
+  // spans translated back to src's coordinates, plus the number of characters
+  // covered by ERROR/MISSING nodes within src — the same measure
+  // docs/go/highlight.go's capture uses to decide the wrap retry.
+  function captureSpans(langObj, src, prefix, suffix) {
+    var tree = langObj.parser.parse(prefix + src + suffix);
+    var errChars = errorChars(tree.rootNode, prefix.length, prefix.length + src.length);
+    var caps = langObj.query.captures(tree.rootNode);
+    var spans = [];
+    for (var i = 0; i < caps.length; i++) {
+      var c = caps[i];
+      var s = c.node.startIndex - prefix.length, e = c.node.endIndex - prefix.length;
+      if (e <= 0 || s >= src.length) continue;
+      spans.push({
+        start: Math.max(s, 0),
+        end: Math.min(e, src.length),
+        name: c.name,
+      });
+    }
+    tree.delete();
+    return { spans: spans, errChars: errChars };
+  }
+
+  // errorChars sums the characters of ERROR/MISSING nodes intersecting
+  // [start, end); mirrors errorBytes in docs/go/highlight.go.
+  function errorChars(node, start, end) {
+    if (node.isError || node.isMissing) {
+      var s = node.startIndex, e = node.endIndex;
+      if (e < start || s > end) return 0;
+      // A zero-width MISSING node still poisons the parse it appears in.
+      return Math.max(Math.min(e, end) - Math.max(s, start), 1);
+    }
+    var total = 0;
+    for (var i = 0; i < node.childCount; i++) {
+      total += errorChars(node.child(i), start, end);
+    }
+    return total;
+  }
+
   // classify(langObj, src) -> a token class per character (null where unstyled),
   // using langObj's parser + highlight query. langObj.wrap, when set, is a
   // synthetic prefix that makes a bare fragment parse (e.g. Go's "package p\n");
-  // its captures are shifted back so they line up with src.
+  // its captures are shifted back so they line up with src. langObj.retryWrap
+  // ([prefix, suffix]) retries a source that didn't fully parse inside the
+  // synthetic wrapper and keeps whichever parse recovered more — mirroring
+  // classify in docs/go/highlight.go (the two must stay in lockstep), so
+  // fragments like error-snippet windows highlight the same here as baked.
   function classify(langObj, src) {
     var names = new Array(src.length).fill(null);
     if (!langObj || !langObj.parser) return names;
-    var wrap = langObj.wrap || "";
-    var tree = langObj.parser.parse(wrap + src);
-    var caps = langObj.query.captures(tree.rootNode);
+    var res = captureSpans(langObj, src, langObj.wrap || "", "");
+    if (res.errChars > 0 && langObj.retryWrap) {
+      var wrapped = captureSpans(langObj, src, langObj.retryWrap[0], langObj.retryWrap[1]);
+      if (wrapped.errChars < res.errChars) res = wrapped;
+    }
     // Wider captures first, narrower (and later) override.
-    caps.sort(function (a, b) {
-      var d = a.node.startIndex - b.node.startIndex;
+    res.spans.sort(function (a, b) {
+      var d = a.start - b.start;
       if (d) return d;
-      return (b.node.endIndex - b.node.startIndex) - (a.node.endIndex - a.node.startIndex);
+      return (b.end - b.start) - (a.end - a.start);
     });
-    for (var i = 0; i < caps.length; i++) {
-      var c = caps[i], cls = tokenClass(c.name);
+    for (var i = 0; i < res.spans.length; i++) {
+      var sp = res.spans[i], cls = tokenClass(sp.name);
       if (!cls) continue; // unmapped captures (e.g. @error) stay unstyled
-      var s = c.node.startIndex - wrap.length, e = c.node.endIndex - wrap.length;
-      if (e <= 0 || s >= src.length) continue;
-      if (s < 0) s = 0;
-      if (e > src.length) e = src.length;
-      for (var j = s; j < e; j++) names[j] = cls;
+      for (var j = sp.start; j < sp.end; j++) names[j] = cls;
     }
-    tree.delete();
     return names;
   }
 
@@ -335,6 +378,35 @@
     return out;
   }
 
+  // Highlight an error snippet's quoted source lines as one Dang fragment
+  // (so multi-line tokens keep their context), returning per-line HTML.
+  // Mirrors highlightSnippetLines in docs/go/errorreport.go (the two must
+  // stay in lockstep); without tree-sitter the lines come back escaped
+  // but unstyled.
+  function highlightLinesHtml(lines) {
+    var joined = lines.join("\n");
+    var names;
+    if (ts) {
+      names = classify(ts, joined);
+      applyInjections(joined, names);
+    } else {
+      names = new Array(joined.length).fill(null);
+    }
+    var out = [], offset = 0;
+    for (var li = 0; li < lines.length; li++) {
+      var line = lines[li], html = "", k = offset;
+      while (k < offset + line.length) {
+        var cls = names[k], start = k;
+        while (k < offset + line.length && names[k] === cls) k++;
+        var chunk = escapeHtml(joined.slice(start, k));
+        html += cls ? '' + chunk + "" : chunk;
+      }
+      out.push(html);
+      offset += line.length + 1; // the joining newline
+    }
+    return out;
+  }
+
   // ── editor autosizing ─────────────────────────────────────────────────────
   //
   // Every editor textarea autosizes to its content by measuring scrollHeight.
@@ -360,6 +432,80 @@
 
   var STAGE_LABEL = { parse: "Parse error", type: "Type error", eval: "Runtime error", auth: "GitHub error" };
 
+  // Append a failed result's error to the output container. A structured
+  // report (res.report, from the wasm module) renders as annotated source
+  // sections — the DOM must match what the docs build bakes via
+  // renderErrorReport in docs/go/errorreport.go (the two must stay in
+  // lockstep) — with a plain label + message line as the fallback (e.g. the
+  // synthesized "expected this block to fail" error, or GitHub auth
+  // failures).
+  function renderError(out, res) {
+    out.classList.add("is-error");
+    var err = document.createElement("div");
+    err.className = "dang-playground-error";
+    var label = STAGE_LABEL[res.stage] || "Error";
+    var sections = res.report && res.report.sections;
+    if (sections && sections.length) {
+      err.innerHTML = errorReportHtml(sections, label);
+    } else {
+      err.textContent = label + ": " + res.error;
+    }
+    out.appendChild(err);
+  }
+
+  function errorReportHtml(sections, stageLabel) {
+    var html = "";
+    for (var i = 0; i < sections.length; i++) {
+      var sec = sections[i];
+      var label = stageLabel + ":";
+      if (sec.role === "cause") label = "caused by:";
+      else if (sec.role === "sibling") label = "also failed:";
+      html += '
'; + html += '
' + + escapeHtml(label) + " " + escapeHtml(sec.message) + "
"; + var fields = sec.fields || []; + for (var f = 0; f < fields.length; f++) { + html += '
' + escapeHtml(fields[f].name) + ": " + + highlightHtml(fields[f].value) + "
"; + } + if (sec.location) { + html += '
' +
+          errorSnippetHtml(sec.location, sec.snippet) + "
"; + } + html += "
"; + } + return html; + } + + // The location arrow and quoted source window, line for line the text the + // terminal's formatSourceAnnotation prints (sans filename — snippets parse + // under a synthetic name and the source sits right above). With no + // resolvable snippet it degrades to the bare arrow. + function errorSnippetHtml(loc, snip) { + var html = '' + + escapeHtml(" --> " + loc.line + ":" + loc.column) + ""; + if (!snip) return html; + var pipe = ' |'; + html += "\n" + pipe + "\n"; + var lines = highlightLinesHtml(snip.lines); + for (var i = 0; i < lines.length; i++) { + var num = snip.startLine + i; + var gutter = " " + String(num).padStart(3) + " | "; + if (num === loc.line) { + html += '' + gutter + "" + lines[i] + "\n"; + // Underline indent mirrors formatSourceAnnotation: 1 leading space + + // 3 gutter + " | " + column-1. + html += " ".repeat(1 + 3 + 3 + loc.column - 1) + + '' + "^".repeat(Math.max(1, loc.length)) + "\n"; + } else { + html += '' + gutter + "" + + '' + lines[i] + "\n"; + } + } + html += pipe; + return html; + } + function renderOutput(out, res) { out.innerHTML = ""; out.classList.remove("is-error", "is-empty"); @@ -371,18 +517,15 @@ out.appendChild(pre); } - var line = document.createElement("div"); if (res.ok) { + var line = document.createElement("div"); line.className = "dang-playground-result"; // Highlight the result like the input (highlightHtml escapes it). line.innerHTML = "=> " + highlightHtml(res.value); + out.appendChild(line); } else { - out.classList.add("is-error"); - line.className = "dang-playground-error"; - var label = STAGE_LABEL[res.stage] || "Error"; - line.textContent = label + ": " + res.error; + renderError(out, res); } - out.appendChild(line); } // ── widget construction ─────────────────────────────────────────────────── @@ -628,12 +771,7 @@ out.appendChild(line); } } else { - out.classList.add("is-error"); - var err = document.createElement("div"); - err.className = "dang-playground-error"; - var label = STAGE_LABEL[res.stage] || "Error"; - err.textContent = label + ": " + res.error; - out.appendChild(err); + renderError(out, res); } if (!out.firstChild) out.classList.add("is-empty"); } diff --git a/docs/lit/language/errors.md b/docs/lit/language/errors.md index 5b1be69c..b42038ac 100644 --- a/docs/lit/language/errors.md +++ b/docs/lit/language/errors.md @@ -393,20 +393,17 @@ gathered along the way — the error's type and public data fields, the raise site, the cause chain, and any sibling failures from a concurrent `{{ }}` — each with its own highlighted source location: -``` -Error: uncaught DeployError: deploy failed - --> ./ci/main.dang:12:17 - | - 12 | e: Error => raise DeployError(message: "deploy failed", stage: "push") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - stage: "push" -caused by: error: connection refused - --> ./ci/main.dang:8:17 - | - 8 | push: String! { raise "connection refused" } - ^^^^^^^^^^^^^^^^^^^^^^^^^^ - | +```dang-failure +type DeployError implements Error { + message: String! + stage: String! +} + +push: String! { raise "connection refused" } + +push rescue { + err: Error => raise DeployError(message: "deploy failed", stage: "push") +} ``` Jumps are not errors: `return`, `break`, and `continue` pass through a diff --git a/pkg/dang/errors.go b/pkg/dang/errors.go index 3e56ed8b..f8623bc3 100644 --- a/pkg/dang/errors.go +++ b/pkg/dang/errors.go @@ -280,7 +280,11 @@ func WarnAtSource(ctx context.Context, loc *SourceLocation, message string) { } evalCtx.warnedDeprecations[key] = true } - source = evalCtx.Source + // Same guard as CreateSourceError: a call site in another unit must + // not have this unit's source quoted under its line numbers. + if loc == nil || loc.Filename == "" || loc.Filename == evalCtx.Filename { + source = evalCtx.Source + } } if source == "" && loc != nil && loc.Filename != "" { @@ -333,7 +337,15 @@ func (ctx *EvalContext) CreateSourceError(err error, node SourceLocatable) error return err } - return NewSourceError(err, location, ctx.Source) + // Only attach this unit's source when the location is actually in it — a + // node from another unit (a REPL entry or literate block calling a + // function defined earlier) must not get the current unit's text quoted + // under its line numbers. + source := ctx.Source + if location.Filename != "" && location.Filename != ctx.Filename { + source = "" + } + return NewSourceError(err, location, source) } // ConvertInferError converts an InferError to a SourceError with source context diff --git a/pkg/dang/parse_errors.go b/pkg/dang/parse_errors.go index c0db7639..a54fe52c 100644 --- a/pkg/dang/parse_errors.go +++ b/pkg/dang/parse_errors.go @@ -47,7 +47,7 @@ func EnhanceParseError(pegErr error, filename string, source []byte) error { tsMu.Unlock() if tree == nil { - return pegErr + return pegSourceError(pegErr, filename, source) } defer tree.Close() @@ -58,7 +58,7 @@ func EnhanceParseError(pegErr error, filename string, source []byte) error { collectTSErrors(root, source, &errors) if len(errors) == 0 { - return pegErr + return pegSourceError(pegErr, filename, source) } // Use the first (earliest) error to produce the diagnostic. diff --git a/pkg/dang/parse_errors_common.go b/pkg/dang/parse_errors_common.go new file mode 100644 index 00000000..549f1b39 --- /dev/null +++ b/pkg/dang/parse_errors_common.go @@ -0,0 +1,31 @@ +package dang + +import "fmt" + +// pegSourceError converts a pigeon parse failure into a SourceError at the +// first error's position, keeping only the bare "no match found, expected +// …" message — the position prefix pigeon bakes into its Error() string +// ("filename:line:col (offset):") reads poorly in output that renders the +// location itself, and leaks synthetic unit filenames (the playground's +// per-entry names) into the message. Used where tree-sitter enhancement is +// unavailable (no cgo — notably the wasm playground) or found nothing to +// improve; the enhanced and fallback messages word the failure differently, +// but both carry a location and source, so every frontend still annotates. +func pegSourceError(pegErr error, filename string, source []byte) error { + first := pegErr + if el, ok := pegErr.(errList); ok && len(el) > 0 { + first = el[0] + } + pe, ok := first.(*parserError) + if !ok { + return pegErr + } + + loc := &SourceLocation{ + Filename: filename, + Line: pe.pos.line, + Column: pe.pos.col, + Length: 1, + } + return NewSourceError(fmt.Errorf("syntax error: %s", pe.Inner.Error()), loc, string(source)) +} diff --git a/pkg/dang/parse_errors_nocgo.go b/pkg/dang/parse_errors_nocgo.go index 0ef6d243..14ee57a5 100644 --- a/pkg/dang/parse_errors_nocgo.go +++ b/pkg/dang/parse_errors_nocgo.go @@ -2,20 +2,35 @@ package dang +import "os" + // ParseWithRecovery parses source using the PEG parser. Without CGo, -// tree-sitter error enhancement is not available. +// tree-sitter error enhancement is not available; parse failures degrade to +// a SourceError at the PEG error's position (pegSourceError). func ParseWithRecovery(filename string, source []byte, opts ...Option) (any, error) { - return Parse(filename, source, opts...) + result, err := Parse(filename, source, opts...) + if err != nil { + return nil, pegSourceError(err, filename, source) + } + return result, nil } // ParseFileWithRecovery is like ParseFile but without tree-sitter error // enhancement (CGo not available). func ParseFileWithRecovery(filename string, opts ...Option) (any, error) { - return ParseFile(filename, opts...) + result, err := ParseFile(filename, opts...) + if err != nil { + source, readErr := os.ReadFile(filename) + if readErr != nil { + return nil, err // can't enhance, return original + } + return nil, pegSourceError(err, filename, source) + } + return result, nil } -// EnhanceParseError returns the original error as-is since tree-sitter is not -// available without CGo. +// EnhanceParseError returns a position-annotated version of the PEG error; +// the tree-sitter description is not available without CGo. func EnhanceParseError(pegErr error, filename string, source []byte) error { - return pegErr + return pegSourceError(pegErr, filename, source) } diff --git a/pkg/dang/report.go b/pkg/dang/report.go new file mode 100644 index 00000000..08fc594b --- /dev/null +++ b/pkg/dang/report.go @@ -0,0 +1,235 @@ +package dang + +import ( + "errors" + "os" + "strings" +) + +// ErrorReport is a structured, renderer-neutral description of an error as +// the boundary printer presents it: one section per annotated site — the +// primary error first, then any cause-chain links and concurrent sibling +// failures. Non-terminal frontends (the docs build, the wasm playground) +// render it as HTML/DOM instead of ANSI; the terminal renderers in +// errors.go and uncaught.go remain the source of truth for wording, and +// this extraction mirrors them. +type ErrorReport struct { + Sections []ErrorReportSection +} + +// Section roles, named after the labels the terminal boundary printer uses. +const ( + ReportPrimary = "error" + ReportCause = "cause" // rendered as "caused by:" + ReportSibling = "sibling" // rendered as "also failed:" +) + +// ErrorReportSection is one annotated site of a report. Location may be +// non-nil while Snippet is nil when the source text couldn't be resolved +// (renderers degrade to a bare location arrow, like annotate in +// uncaught.go); both are nil for sites with no recorded location, such as +// causes taken from an explicit `cause` field. +type ErrorReportSection struct { + Role string + Message string + Fields []ErrorReportField + Location *SourceLocation + Snippet *ErrorSnippet +} + +// ErrorReportField is one public stored data field of an uncaught error +// value, with its Repr'd value. +type ErrorReportField struct { + Name string + Value string +} + +// ErrorSnippet quotes the source around a section's location: the same ±2 +// context-line window formatSourceAnnotation shows (the two must stay in +// lockstep), split into lines for renderers that draw their own gutter and +// underline. +type ErrorSnippet struct { + StartLine int // 1-based line number of Lines[0] + Lines []string +} + +// ErrorReporter extracts structured reports for a frontend that evaluates +// separately-parsed units against accumulated state — REPL entries, the +// docs build's literate blocks, the playground's chain replay. Filename and +// Source describe the unit that was just processed; Sources carries earlier +// units by their synthetic filenames, so a failure whose location points +// into an earlier unit (a raise inside a function defined blocks ago) still +// quotes the right source. Locations in files known to neither resolve +// from disk, mirroring uncaughtErrorReport.sourceFor. +type ErrorReporter struct { + Filename string + Source string + Sources map[string]string +} + +// NewErrorReport extracts a structured report from any error produced by +// parsing, inference, or evaluation of a single self-contained unit — +// ErrorReporter without the cross-unit sources. +func NewErrorReport(err error, filename, source string) *ErrorReport { + return ErrorReporter{Filename: filename, Source: source}.Report(err) +} + +// Report extracts the structured report for an error the reporter's unit +// just produced. +func (r ErrorReporter) Report(err error) *ErrorReport { + rep := &ErrorReport{} + + // Multiple type errors: one primary section each, matching the terminal's + // "N inference errors" listing. Groups nest — interface-implementation + // checks collect one inner group per type — so flatten recursively, or a + // nested group would surface only its first member. + var inferErrs *InferenceErrors + if errors.As(err, &inferErrs) && len(inferErrs.Errors) > 0 { + var flatten func(errs []error) + flatten = func(errs []error) { + for _, e := range errs { + var nested *InferenceErrors + if errors.As(e, &nested) && len(nested.Errors) > 0 { + flatten(nested.Errors) + continue + } + rep.Sections = append(rep.Sections, r.primarySection(e)) + } + } + flatten(inferErrs.Errors) + return rep + } + + rep.Sections = append(rep.Sections, r.primarySection(err)) + + // Cause chain and concurrent siblings, in the order the boundary + // printer shows them (uncaughtErrorReport.Error). + var raised *RaisedError + if errors.As(err, &raised) { + for _, link := range causeChain(raised) { + rep.Sections = append(rep.Sections, ErrorReportSection{ + Role: ReportCause, + Message: errorSummary(link.Value), + Fields: errorFields(link.Value), + Location: link.Location, + Snippet: r.snippetFor(link.Location, ""), + }) + } + } + var parallel *parallelFailure + if errors.As(err, ¶llel) { + for _, sibling := range parallel.Also { + loc, siteSource := errorLocation(sibling) + rep.Sections = append(rep.Sections, ErrorReportSection{ + Role: ReportSibling, + Message: siblingSummary(sibling), + Location: loc, + Snippet: r.snippetFor(loc, siteSource), + }) + } + } + + return rep +} + +// primarySection describes the error itself: the bare message the docs and +// playground have always shown, plus the location and snippet the terminal +// annotates it with. Raised errors get the boundary printer's "uncaught +// TypeName: message" summary and their public data fields. +func (r ErrorReporter) primarySection(err error) ErrorReportSection { + sec := ErrorReportSection{Role: ReportPrimary} + + var raised *RaisedError + var assertion *AssertionError + var sourceErr *SourceError + var inferErr *InferError + switch { + case errors.As(err, &raised): + sec.Message = "uncaught " + errorSummary(raised.Value) + sec.Fields = errorFields(raised.Value) + sec.Location = raised.Location + case errors.As(err, &assertion): + // AssertionError.Error() appends a "Location:" line; the location is + // carried structurally here instead. + sec.Message = assertion.Message + sec.Location = assertion.Location + case errors.As(err, &sourceErr): + sec.Message = sourceErr.Inner.Error() + sec.Location = sourceErr.Location + sec.Snippet = r.snippetFor(sec.Location, sourceErr.Source) + return sec + case errors.As(err, &inferErr): + // An InferError that never became a SourceError — ConvertInferError + // couldn't read its (synthetic) filename. The caller-provided source + // stands in below. + sec.Message = inferErr.Inner.Error() + sec.Location = inferErr.Location + default: + sec.Message = err.Error() + sec.Location, _ = boundaryLocation(err) + } + + sec.Snippet = r.snippetFor(sec.Location, "") + return sec +} + +// boundaryLocation recovers a location from errors that carry one outside +// the SourceError convention: control-flow sentinels escaping the program +// (mirroring translateBoundaryEvalError). +func boundaryLocation(err error) (*SourceLocation, bool) { + var returned *ReturnException + if errors.As(err, &returned) { + return returned.Location, true + } + var broken *BreakException + if errors.As(err, &broken) { + return broken.Location, true + } + var continued *ContinueException + if errors.As(err, &continued) { + return continued.Location, true + } + return nil, false +} + +// snippetFor resolves the quoted source window for a location. +// siteSource, when non-empty, is source text recorded alongside the +// location (e.g. by a sibling's SourceError) and wins; otherwise a +// location in the current unit — matching filename, or none recorded at +// all, as with locations from unnamed parses — uses the unit's source, an +// earlier unit resolves through Sources, and anything else falls back to +// the file on disk (a no-op in the browser). Returns nil when the source +// can't be resolved or the line is out of range, so renderers degrade the +// way annotate does. +func (r ErrorReporter) snippetFor(loc *SourceLocation, siteSource string) *ErrorSnippet { + if loc == nil { + return nil + } + + src := siteSource + if src == "" { + if loc.Filename == "" || loc.Filename == r.Filename { + src = r.Source + } else if earlier, ok := r.Sources[loc.Filename]; ok { + src = earlier + } else if contents, err := os.ReadFile(loc.Filename); err == nil { + src = string(contents) + } + } + if src == "" { + return nil + } + + lines := strings.Split(src, "\n") + if loc.Line < 1 || loc.Line > len(lines) { + return nil + } + + // The same ±2 window as formatSourceAnnotation. + start := max(1, loc.Line-2) + end := min(len(lines), loc.Line+2) + return &ErrorSnippet{ + StartLine: start, + Lines: lines[start-1 : end], + } +} diff --git a/pkg/dang/report_test.go b/pkg/dang/report_test.go new file mode 100644 index 00000000..17e85f0a --- /dev/null +++ b/pkg/dang/report_test.go @@ -0,0 +1,323 @@ +package dang + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/vito/dang/v2/pkg/hm" + "github.com/vito/dang/v2/pkg/ioctx" +) + +// snippetFilename is a synthetic filename like the docs build's "literate" +// and the playground's "playground": nothing on disk answers to it, so +// snippets must resolve from the source handed to NewErrorReport. +const snippetFilename = "snippet" + +// snippetError parses, infers, and evaluates source the way the docs +// literate build and the wasm playground do, returning the error (which the +// test requires) and the stage it came from. +func snippetError(t *testing.T, source string) error { + t.Helper() + + parsed, err := Parse(snippetFilename, []byte(source)) + if err != nil { + return err + } + file, ok := parsed.(*FileBlock) + if !ok { + t.Fatalf("unexpected parse result %T", parsed) + } + + typeScope, valueScope := BuildScopesFromImports("", nil) + fresh := hm.NewSimpleFresher() + if _, err := InferFormsWithPhases(context.Background(), file.Forms, typeScope, fresh); err != nil { + return err + } + + var out bytes.Buffer + ctx := ioctx.StdoutToContext(context.Background(), &out) + ctx = ioctx.StderrToContext(ctx, &out) + ctx = WithEvalContext(ctx, NewEvalContext(snippetFilename, source)) + + for _, node := range file.Forms { + if _, err := EvalNode(ctx, valueScope, node); err != nil { + return err + } + } + t.Fatalf("expected source to fail, but it succeeded:\n%s", source) + return nil +} + +// A type error's location must resolve against the provided source even +// though its synthetic filename can't be read from disk (the trap +// ConvertInferError falls into for the docs build). +func TestErrorReportTypeError(t *testing.T) { + source := "let one = 1\nlet x = undefinedName" + err := snippetError(t, source) + + rep := NewErrorReport(err, snippetFilename, source) + if len(rep.Sections) != 1 { + t.Fatalf("got %d sections, want 1: %+v", len(rep.Sections), rep.Sections) + } + sec := rep.Sections[0] + if sec.Role != ReportPrimary { + t.Errorf("role = %q, want %q", sec.Role, ReportPrimary) + } + if !strings.Contains(sec.Message, "undefinedName") { + t.Errorf("message %q does not name the missing symbol", sec.Message) + } + if strings.Contains(sec.Message, "failed to read file") { + t.Errorf("message %q leaks the unreadable synthetic filename", sec.Message) + } + if sec.Location == nil || sec.Location.Line != 2 { + t.Fatalf("location = %+v, want line 2", sec.Location) + } + if sec.Snippet == nil { + t.Fatal("no snippet despite the source being provided") + } + if got := sec.Snippet.Lines[sec.Location.Line-sec.Snippet.StartLine]; got != "let x = undefinedName" { + t.Errorf("snippet error line = %q", got) + } +} + +// An uncaught raise reports like the CLI boundary: "uncaught Type: message", +// public data fields, the raise site, and the recorded cause chain. +func TestErrorReportUncaughtWithCause(t *testing.T) { + source := strings.Join([]string{ + `type DeployError implements Error {`, + ` message: String!`, + ` stage: String!`, + `}`, + ``, + `push: String! { raise "connection refused" }`, + ``, + `push rescue {`, + ` err: Error => raise DeployError(message: "deploy failed", stage: "push")`, + `}`, + }, "\n") + err := snippetError(t, source) + + rep := NewErrorReport(err, snippetFilename, source) + if len(rep.Sections) != 2 { + t.Fatalf("got %d sections, want primary + cause: %+v", len(rep.Sections), rep.Sections) + } + + primary := rep.Sections[0] + if primary.Message != "uncaught DeployError: deploy failed" { + t.Errorf("primary message = %q", primary.Message) + } + if len(primary.Fields) != 1 || primary.Fields[0].Name != "stage" || primary.Fields[0].Value != `"push"` { + t.Errorf("primary fields = %+v, want stage: \"push\"", primary.Fields) + } + if primary.Location == nil || primary.Location.Line != 9 { + t.Errorf("primary location = %+v, want the raise on line 9", primary.Location) + } + if primary.Snippet == nil { + t.Error("primary section has no snippet") + } + + cause := rep.Sections[1] + if cause.Role != ReportCause { + t.Errorf("second section role = %q, want %q", cause.Role, ReportCause) + } + if cause.Message != "error: connection refused" { + t.Errorf("cause message = %q", cause.Message) + } + if cause.Location == nil || cause.Location.Line != 6 { + t.Errorf("cause location = %+v, want the inner raise on line 6", cause.Location) + } + if cause.Snippet == nil { + t.Error("cause section has no snippet") + } +} + +// Concurrent sibling failures from a `{{ }}` become "also failed:" sections. +func TestErrorReportParallelSiblings(t *testing.T) { + source := `{{ a: raise "first", b: raise "second" }}` + err := snippetError(t, source) + + rep := NewErrorReport(err, snippetFilename, source) + if len(rep.Sections) != 2 { + t.Fatalf("got %d sections, want primary + sibling: %+v", len(rep.Sections), rep.Sections) + } + if rep.Sections[0].Message != "uncaught error: first" { + t.Errorf("primary message = %q", rep.Sections[0].Message) + } + sibling := rep.Sections[1] + if sibling.Role != ReportSibling { + t.Errorf("second section role = %q, want %q", sibling.Role, ReportSibling) + } + if sibling.Message != "error: second" { + t.Errorf("sibling message = %q", sibling.Message) + } + if sibling.Location == nil || sibling.Snippet == nil { + t.Errorf("sibling not annotated: location = %+v, snippet = %+v", sibling.Location, sibling.Snippet) + } +} + +// The snippet window matches formatSourceAnnotation's: ±2 lines, clamped to +// the file, nil when the source can't be resolved or the line is out of it. +func TestErrorReportSnippetWindow(t *testing.T) { + source := "l1\nl2\nl3\nl4\nl5\nl6" + mk := func(line int) *SourceError { + return NewSourceError(errors.New("boom"), &SourceLocation{ + Filename: snippetFilename, Line: line, Column: 1, Length: 2, + }, "") + } + + middle := NewErrorReport(mk(3), snippetFilename, source).Sections[0].Snippet + if middle == nil || middle.StartLine != 1 || len(middle.Lines) != 5 { + t.Errorf("line 3 window = %+v, want lines 1-5", middle) + } + + top := NewErrorReport(mk(1), snippetFilename, source).Sections[0].Snippet + if top == nil || top.StartLine != 1 || len(top.Lines) != 3 { + t.Errorf("line 1 window = %+v, want lines 1-3", top) + } + + bottom := NewErrorReport(mk(6), snippetFilename, source).Sections[0].Snippet + if bottom == nil || bottom.StartLine != 4 || len(bottom.Lines) != 3 { + t.Errorf("line 6 window = %+v, want lines 4-6", bottom) + } + + if out := NewErrorReport(mk(99), snippetFilename, source).Sections[0].Snippet; out != nil { + t.Errorf("out-of-range line got a snippet: %+v", out) + } + + if noSrc := NewErrorReport(mk(3), snippetFilename, "").Sections[0].Snippet; noSrc != nil { + t.Errorf("unresolvable source got a snippet: %+v", noSrc) + } +} + +// A SourceError that recorded its own source (e.g. a parse error) uses it +// even when the report is built with different unit source. +func TestErrorReportSourceErrorOwnSource(t *testing.T) { + recorded := "recorded line" + srcErr := NewSourceError(errors.New("syntax error: nope"), &SourceLocation{ + Filename: "elsewhere.dang", Line: 1, Column: 1, Length: 4, + }, recorded) + + sec := NewErrorReport(srcErr, snippetFilename, "other text").Sections[0] + if sec.Snippet == nil || sec.Snippet.Lines[0] != recorded { + t.Errorf("snippet = %+v, want the error's own recorded source", sec.Snippet) + } +} + +// A location pointing into an earlier unit resolves through the reporter's +// Sources map — the docs literate chain and REPL sessions record every +// block's source by its synthetic filename. +func TestErrorReporterCrossUnitSources(t *testing.T) { + first := "line one\nraise site\nline three" + rep := ErrorReporter{ + Filename: "snippet-2", + Source: "call it", + Sources: map[string]string{"snippet-1": first, "snippet-2": "call it"}, + } + + err := NewSourceError(errors.New("boom"), &SourceLocation{ + Filename: "snippet-1", Line: 2, Column: 1, Length: 5, + }, "") + sec := rep.Report(err).Sections[0] + if sec.Snippet == nil { + t.Fatal("cross-unit location did not resolve through Sources") + } + if got := sec.Snippet.Lines[2-sec.Snippet.StartLine]; got != "raise site" { + t.Errorf("quoted %q from the wrong unit", got) + } +} + +// Multiple inference errors become one section apiece, in order. +func TestErrorReportMultipleInferenceErrors(t *testing.T) { + source := "let x = undefinedA\nlet y = undefinedB" + err := snippetError(t, source) + + var inferErrs *InferenceErrors + if !errors.As(err, &inferErrs) || len(inferErrs.Errors) < 2 { + t.Skipf("expected multiple inference errors, got %v", err) + } + + rep := NewErrorReport(err, snippetFilename, source) + if len(rep.Sections) != len(inferErrs.Errors) { + t.Fatalf("got %d sections for %d errors", len(rep.Sections), len(inferErrs.Errors)) + } + for i, sec := range rep.Sections { + if sec.Role != ReportPrimary { + t.Errorf("section %d role = %q, want %q", i, sec.Role, ReportPrimary) + } + if sec.Snippet == nil { + t.Errorf("section %d (%s) has no snippet", i, sec.Message) + } + } + if !strings.Contains(rep.Sections[0].Message, "undefinedA") || !strings.Contains(rep.Sections[1].Message, "undefinedB") { + t.Errorf("sections out of order or mislabeled: %q, %q", + rep.Sections[0].Message, rep.Sections[1].Message) + } +} + +// Interface-implementation checks nest an InferenceErrors group per type; +// the report must flatten them so every missing member gets a section, the +// way the terminal's "N inference errors" listing shows them all. +func TestErrorReportNestedInferenceErrors(t *testing.T) { + source := strings.Join([]string{ + `interface Contact {`, + ` email: String!`, + ` phone: String!`, + ` name: String!`, + `}`, + ``, + `type Person implements Contact {`, + ` name: String!`, + `}`, + }, "\n") + err := snippetError(t, source) + + rep := NewErrorReport(err, snippetFilename, source) + if len(rep.Sections) != 2 { + t.Fatalf("got %d sections, want one per missing member: %+v", len(rep.Sections), rep.Sections) + } + var got []string + for _, sec := range rep.Sections { + got = append(got, sec.Message) + } + joined := strings.Join(got, "\n") + if !strings.Contains(joined, "email") || !strings.Contains(joined, "phone") { + t.Errorf("sections missing a member: %q", got) + } +} + +// An assertion failure carries its location structurally, without the +// "Location:" suffix AssertionError.Error() appends for terminals. +func TestErrorReportAssertion(t *testing.T) { + source := "assert { 1 == 2 }" + err := snippetError(t, source) + + sec := NewErrorReport(err, snippetFilename, source).Sections[0] + if strings.Contains(sec.Message, "Location:") { + t.Errorf("message %q includes the terminal-only Location suffix", sec.Message) + } + if sec.Location == nil { + t.Fatal("assertion location lost") + } + if sec.Snippet == nil { + t.Error("assertion has no snippet") + } +} + +// Errors with no location still produce a section, so every failure renders. +func TestErrorReportPlainError(t *testing.T) { + rep := NewErrorReport(fmt.Errorf("just a message"), snippetFilename, "src") + if len(rep.Sections) != 1 { + t.Fatalf("got %d sections, want 1", len(rep.Sections)) + } + sec := rep.Sections[0] + if sec.Message != "just a message" { + t.Errorf("message = %q", sec.Message) + } + if sec.Location != nil || sec.Snippet != nil { + t.Errorf("locationless error got annotated: %+v", sec) + } +} diff --git a/pkg/dang/uncaught.go b/pkg/dang/uncaught.go index 67d0528c..38be7c30 100644 --- a/pkg/dang/uncaught.go +++ b/pkg/dang/uncaught.go @@ -125,19 +125,29 @@ func errorSummary(val Value) string { } // writeErrorFields prints the error's public stored data fields (everything -// except message, methods, and computed members), using lookupValue so -// pending initializers are never forced. This mirrors objectsEqual's -// non-forcing walk. +// except message, methods, and computed members). func writeErrorFields(out *strings.Builder, val Value) { + for _, f := range errorFields(val) { + fmt.Fprintf(out, " %s%s:%s %s\n", ansiDim, f.Name, ansiReset, f.Value) + } +} + +// errorFields collects the error's public stored data fields (everything +// except message, cause, methods, and computed members), using lookupValue +// so pending initializers are never forced. This mirrors objectsEqual's +// non-forcing walk. Shared by the terminal boundary printer and ErrorReport +// extraction (report.go). +func errorFields(val Value) []ErrorReportField { obj, ok := val.(*Object) if !ok { - return + return nil } typ, ok := obj.Mod.(*Type) if !ok { - return + return nil } + var fields []ErrorReportField for name, scheme := range typ.Bindings(PublicVisibility) { if name == "message" || name == "cause" { continue @@ -149,8 +159,9 @@ func writeErrorFields(out *strings.Builder, val Value) { if !found { continue } - fmt.Fprintf(out, " %s%s:%s %s\n", ansiDim, name, ansiReset, Repr(fieldVal)) + fields = append(fields, ErrorReportField{Name: name, Value: Repr(fieldVal)}) } + return fields } // causeChain walks the cause links reachable from an uncaught error, diff --git a/pkg/dang/union_provenance.go b/pkg/dang/union_provenance.go index b6314a51..96d886f6 100644 --- a/pkg/dang/union_provenance.go +++ b/pkg/dang/union_provenance.go @@ -71,8 +71,11 @@ func collectUnionNotes(notes *strings.Builder, t hm.Type) { if !ok || origin.Loc == nil { continue } - fmt.Fprintf(notes, "\n - %s from the %s at %s:%d:%d", - opt, origin.Desc, origin.Loc.Filename, origin.Loc.Line, origin.Loc.Column) + // line:col only — the note sits under an error that already names + // the file, and synthetic unit names (REPL entries, docs snippets) + // have no business in a message. + fmt.Fprintf(notes, "\n - %s from the %s at %d:%d", + opt, origin.Desc, origin.Loc.Line, origin.Loc.Column) } } } diff --git a/tests/testdata/break_value_call_type_mismatch.golden b/tests/testdata/break_value_call_type_mismatch.golden index 6a831d10..1e65ad74 100644 --- a/tests/testdata/break_value_call_type_mismatch.golden +++ b/tests/testdata/break_value_call_type_mismatch.golden @@ -1,5 +1,5 @@ Error: cannot use [Int!]! | String! as [Int!]! - - String! from the break value at errors/break_value_call_type_mismatch.dang:4:5 + - String! from the break value at 4:5 --> errors/break_value_call_type_mismatch.dang:2:19  |  1 | # break values contribute to the enclosing call expression type diff --git a/tests/testdata/conditional_widen_binding_mismatch.golden b/tests/testdata/conditional_widen_binding_mismatch.golden index a0258ce1..43bfe31d 100644 --- a/tests/testdata/conditional_widen_binding_mismatch.golden +++ b/tests/testdata/conditional_widen_binding_mismatch.golden @@ -1,6 +1,6 @@ Error: cannot use Int! | String! as Int! - - Int! from the then branch at errors/conditional_widen_binding_mismatch.dang:3:20 - - String! from the else branch at errors/conditional_widen_binding_mismatch.dang:5:8 + - Int! from the then branch at 3:20 + - String! from the else branch at 5:8 --> errors/conditional_widen_binding_mismatch.dang:8:16  |  6 | "one" diff --git a/tests/testdata/nested_break_value_type_mismatch.golden b/tests/testdata/nested_break_value_type_mismatch.golden index 425ea5cd..c80a7150 100644 --- a/tests/testdata/nested_break_value_type_mismatch.golden +++ b/tests/testdata/nested_break_value_type_mismatch.golden @@ -1,6 +1,6 @@ Error: cannot use String! | a | Int! as String! - - a from the break value at errors/nested_break_value_type_mismatch.dang:7:3 - - Int! from the break value at errors/nested_break_value_type_mismatch.dang:8:5 + - a from the break value at 7:3 + - Int! from the break value at 8:5 --> errors/nested_break_value_type_mismatch.dang:6:19  |  4 | } diff --git a/tests/testdata/nested_continue_value_type_mismatch.golden b/tests/testdata/nested_continue_value_type_mismatch.golden index 3479cd97..fdc8c64a 100644 --- a/tests/testdata/nested_continue_value_type_mismatch.golden +++ b/tests/testdata/nested_continue_value_type_mismatch.golden @@ -1,5 +1,5 @@ Error: cannot use [String! | Int!]! as [String!]! - - Int! from the continue value at errors/nested_continue_value_type_mismatch.dang:4:5 + - Int! from the continue value at 4:5 --> errors/nested_continue_value_type_mismatch.dang:2:22  |  1 | # Nested continue expressions in a continue value still target the block invocation diff --git a/tests/testdata/rescue_widen_operator_mismatch.golden b/tests/testdata/rescue_widen_operator_mismatch.golden index e14123e7..3ba106ec 100644 --- a/tests/testdata/rescue_widen_operator_mismatch.golden +++ b/tests/testdata/rescue_widen_operator_mismatch.golden @@ -1,6 +1,6 @@ Error: operator addition is not defined between types Int! | String! and Int! - - Int! from the rescue operand at errors/rescue_widen_operator_mismatch.dang:10:9 - - String! from the rescue clause at errors/rescue_widen_operator_mismatch.dang:11:3 + - Int! from the rescue operand at 10:9 + - String! from the rescue clause at 11:3 --> errors/rescue_widen_operator_mismatch.dang:13:16  |  11 | e: Error => "fallback" diff --git a/tests/testdata/rescue_widen_return_mismatch.golden b/tests/testdata/rescue_widen_return_mismatch.golden index 9d534e1f..467fed91 100644 --- a/tests/testdata/rescue_widen_return_mismatch.golden +++ b/tests/testdata/rescue_widen_return_mismatch.golden @@ -1,6 +1,6 @@ Error: return type mismatch: declared Int!, inferred Int! | String! - - Int! from the rescue operand at errors/rescue_widen_return_mismatch.dang:11:3 - - String! from the rescue clause at errors/rescue_widen_return_mismatch.dang:12:5 + - Int! from the rescue operand at 11:3 + - String! from the rescue clause at 12:5 --> errors/rescue_widen_return_mismatch.dang:10:24  |  8 | }