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(errorSnippetHTML(sec.Location, sec.Snippet)) + b.WriteString(``) + } + + b.WriteString(`
`); 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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/break_value_call_type_mismatch.dang:2:19[0m
[2m |[0m
[2m 1 | # break values contribute to the enclosing call expression type[0m
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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/conditional_widen_binding_mismatch.dang:8:16[0m
[2m |[0m
[2m 6 | "one"[0m
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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/nested_break_value_type_mismatch.dang:6:19[0m
[2m |[0m
[2m 4 | }[0m
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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/nested_continue_value_type_mismatch.dang:2:22[0m
[2m |[0m
[2m 1 | # Nested continue expressions in a continue value still target the block invocation[0m
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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/rescue_widen_operator_mismatch.dang:13:16[0m
[2m |[0m
[2m 11 | e: Error => "fallback"[0m
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 @@
[1m[31mError:[0m 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
[2m[34m--> errors/rescue_widen_return_mismatch.dang:10:24[0m
[2m |[0m
[2m 8 | }[0m