Skip to content

Commit d739340

Browse files
committed
docs,test: document error selection; expand fatal/clamping tests
1 parent 7a014b0 commit d739340

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

docs/guide.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ When you build user-facing parsers, you typically want:
106106
- readable "where in the grammar did this fail?" traces
107107
- fewer confusing backtracks once you've committed to a branch
108108

109+
### Error selection (`any` vs `furthest`)
110+
111+
Backtracking combinators need a rule for which error to return when multiple
112+
alternatives fail.
113+
114+
- `any(p1, p2, ...)` tries alternatives in order and returns the first success.
115+
- If all alternatives fail, it returns the failure that got the furthest
116+
(`ctx.index` is greatest).
117+
- Fatal failures (from `cut(...)`) stop immediately; later alternatives are
118+
not tried.
119+
- `furthest(p1, p2, ...)` always tries all alternatives and returns the result
120+
(success or failure) that got the furthest.
121+
- This often improves error quality, but it may return a failure even if an
122+
earlier alternative succeeded.
123+
109124
### `context(label, parser)`
110125

111126
Wrap a parser so failures get an extra stack frame:
@@ -171,7 +186,8 @@ import {
171186

172187
if (!result.success) {
173188
console.error(formatErrorCompact(result));
174-
console.error(formatErrorReport(result)); // header + snippet + stack frames
189+
// Recommended: a single, non-redundant message (header + snippet + stack).
190+
console.error(formatErrorReport(result));
175191
console.error(formatErrorSnippet(result)); // line snippet with caret
176192
console.error(formatErrorStack(result));
177193
}

tests/error_ux.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { assertEquals } from "@std/assert";
2+
import {
3+
any,
4+
chainl1,
5+
chainr1,
6+
manyTill,
7+
optional,
8+
sepBy,
9+
seq,
10+
} from "../src/combinators.ts";
11+
import {
12+
failure,
13+
formatErrorReport,
14+
formatErrorSnippet,
15+
type Parser,
16+
} from "../src/Parser.ts";
17+
import { str } from "../src/parsers.ts";
18+
import { cut } from "../src/utility.ts";
19+
20+
Deno.test("any returns the failure that got furthest when all alternatives fail", () => {
21+
const p1 = seq(str("a"), str("b")); // fails at index 1 on "aX"
22+
const p2 = seq(str("a"), str("X"), str("Y")); // fails at index 2 on "aXz"
23+
24+
const res = any(p1, p2)({ text: "aXz", index: 0 });
25+
assertEquals(res.success, false);
26+
if (!res.success) {
27+
assertEquals(res.expected, "Y");
28+
assertEquals(res.ctx.index, 2);
29+
}
30+
});
31+
32+
Deno.test("any propagates fatal errors immediately (no backtracking)", () => {
33+
let secondTried = false;
34+
35+
const fatalBranch = seq(str("if"), cut(str(" "), "space after if"));
36+
const other: Parser<string> = (ctx) => {
37+
secondTried = true;
38+
return failure(ctx, "other");
39+
};
40+
41+
const res = any(fatalBranch, other)({ text: "ifthen", index: 0 });
42+
assertEquals(res.success, false);
43+
if (!res.success) {
44+
assertEquals(res.fatal, true);
45+
assertEquals(res.expected, "space after if");
46+
}
47+
assertEquals(secondTried, false);
48+
});
49+
50+
Deno.test("formatErrorSnippet clamps when failure index is out of bounds", () => {
51+
const text = "abc";
52+
const f = failure({ text, index: 999 }, "x");
53+
const snippet = formatErrorSnippet(f, { contextLines: 1, tabWidth: 2 });
54+
assertEquals(snippet.includes("expected x at line 1, column 4"), true);
55+
assertEquals(snippet.includes("^"), true);
56+
});
57+
58+
Deno.test("formatErrorReport is a single message (no repeated header)", () => {
59+
const text = "abc";
60+
const f = failure({ text, index: 1 }, "x");
61+
const report = formatErrorReport(f, { contextLines: 1, tabWidth: 2 });
62+
assertEquals(report.split("expected x at line 1, column 2").length - 1, 1);
63+
});
64+
65+
Deno.test("manyTill propagates fatal failures from the end parser", () => {
66+
const p = manyTill(str("a"), cut(str("END"), "end"));
67+
const res = p({ text: "aaaa", index: 0 });
68+
assertEquals(res.success, false);
69+
if (!res.success) {
70+
assertEquals(res.fatal, true);
71+
assertEquals(res.expected, "end");
72+
}
73+
});
74+
75+
Deno.test("sepBy propagates fatal failures from the separator", () => {
76+
const p = sepBy(str("a"), cut(str(","), "comma"));
77+
// First element matches, then separator fails fatally.
78+
const res = p({ text: "a;", index: 0 });
79+
assertEquals(res.success, false);
80+
if (!res.success) {
81+
assertEquals(res.fatal, true);
82+
assertEquals(res.expected, "comma");
83+
}
84+
});
85+
86+
Deno.test("chainl1 propagates fatal failures from the operator parser", () => {
87+
const term = str("a");
88+
const op = cut(str("+"), "plus");
89+
const p = chainl1(term, op, (l) => l);
90+
const res = p({ text: "a-", index: 0 });
91+
assertEquals(res.success, false);
92+
if (!res.success) {
93+
assertEquals(res.fatal, true);
94+
assertEquals(res.expected, "plus");
95+
}
96+
});
97+
98+
Deno.test("chainr1 propagates fatal failures from the operator parser", () => {
99+
const term = str("a");
100+
const op = cut(str("+"), "plus");
101+
const p = chainr1(term, op, (l) => l);
102+
const res = p({ text: "a-", index: 0 });
103+
assertEquals(res.success, false);
104+
if (!res.success) {
105+
assertEquals(res.fatal, true);
106+
assertEquals(res.expected, "plus");
107+
}
108+
});
109+
110+
Deno.test("optional propagates fatal errors", () => {
111+
const p = optional(cut(str("x"), "x"));
112+
const res = p({ text: "y", index: 0 });
113+
assertEquals(res.success, false);
114+
if (!res.success) assertEquals(res.fatal, true);
115+
});

0 commit comments

Comments
 (0)