Skip to content

Commit e947905

Browse files
committed
feat: add error snippet formatter
1 parent 7b3ee24 commit e947905

3 files changed

Lines changed: 111 additions & 1 deletion

File tree

docs/guide.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,15 @@ avoid both false successes and confusing backtracking.
162162
### Formatting failures
163163

164164
```ts
165-
import { formatErrorCompact, formatErrorStack } from "@claudiu-ceia/combine";
165+
import {
166+
formatErrorCompact,
167+
formatErrorSnippet,
168+
formatErrorStack,
169+
} from "@claudiu-ceia/combine";
166170

167171
if (!result.success) {
168172
console.error(formatErrorCompact(result));
173+
console.error(formatErrorSnippet(result)); // line snippet with caret
169174
console.error(formatErrorStack(result));
170175
}
171176
```

src/Parser.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,81 @@ export const formatErrorCompact = (f: Failure): string => {
172172
const context = f.stack.length > 0 ? ` (${f.stack[0].label})` : "";
173173
return `expected ${f.expected}${context} at ${f.location.line}:${f.location.column}`;
174174
};
175+
176+
const expandTabs = (s: string, tabWidth: number): string => {
177+
if (tabWidth <= 0) return s;
178+
return s.replaceAll("\t", " ".repeat(tabWidth));
179+
};
180+
181+
const ansi = {
182+
reset: "\x1b[0m",
183+
dim: "\x1b[2m",
184+
bold: "\x1b[1m",
185+
red: "\x1b[31m",
186+
yellow: "\x1b[33m",
187+
};
188+
189+
export type FormatErrorSnippetOptions = Readonly<{
190+
/** Include N lines before and after the error line. Default: 1 */
191+
contextLines?: number;
192+
/** Expand tabs to this many spaces. Default: 2 */
193+
tabWidth?: number;
194+
/** Add ANSI color codes. Default: false */
195+
color?: boolean;
196+
}>;
197+
198+
/**
199+
* Format a failure with a small source snippet and caret indicator.
200+
*/
201+
export const formatErrorSnippet = (
202+
f: Failure,
203+
opts: FormatErrorSnippetOptions = {},
204+
): string => {
205+
const contextLines = opts.contextLines ?? 1;
206+
const tabWidth = opts.tabWidth ?? 2;
207+
const color = opts.color ?? false;
208+
209+
const lines = f.ctx.text.split("\n").map((l) =>
210+
l.endsWith("\r") ? l.slice(0, -1) : l
211+
);
212+
const lineIdx = Math.max(0, Math.min(lines.length - 1, f.location.line - 1));
213+
214+
const startLine = Math.max(0, lineIdx - contextLines);
215+
const endLine = Math.min(lines.length - 1, lineIdx + contextLines);
216+
const maxLineNo = endLine + 1;
217+
const lineNoWidth = String(maxLineNo).length;
218+
219+
const header =
220+
`expected ${f.expected} at line ${f.location.line}, column ${f.location.column}`;
221+
const out: string[] = [
222+
color ? `${ansi.red}${header}${ansi.reset}` : header,
223+
];
224+
225+
for (let i = startLine; i <= endLine; i++) {
226+
const rawLine = lines[i] ?? "";
227+
const printedLine = expandTabs(rawLine, tabWidth);
228+
const lineNo = String(i + 1).padStart(lineNoWidth, " ");
229+
230+
out.push(
231+
color
232+
? `${ansi.dim}${lineNo}${ansi.reset} | ${printedLine}`
233+
: `${lineNo} | ${printedLine}`,
234+
);
235+
236+
if (i === lineIdx) {
237+
// 1-based column -> prefix length in original line (clamped).
238+
const rawPrefixLen = Math.max(
239+
0,
240+
Math.min(rawLine.length, f.location.column - 1),
241+
);
242+
const rawPrefix = rawLine.slice(0, rawPrefixLen);
243+
const caretPos = expandTabs(rawPrefix, tabWidth).length;
244+
245+
const gutter = " ".repeat(lineNoWidth);
246+
const caretLine = `${gutter} | ${" ".repeat(caretPos)}^`;
247+
out.push(color ? `${ansi.yellow}${caretLine}${ansi.reset}` : caretLine);
248+
}
249+
}
250+
251+
return out.join("\n");
252+
};

tests/error_stack.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
failure,
88
fatalFailure,
99
formatErrorCompact,
10+
formatErrorSnippet,
1011
formatErrorStack,
1112
getLocation,
1213
isFatal,
@@ -103,6 +104,32 @@ Deno.test("formatErrorCompact produces single-line output", () => {
103104
assertEquals(compact.includes("1:"), true);
104105
});
105106

107+
Deno.test("formatErrorSnippet shows +/- 1 line context and a caret", () => {
108+
const text = ["aaa", "bbb", "ccc"].join("\n");
109+
const f = failure({ text, index: 5 }, "'x'"); // line 2, column 2
110+
111+
const snippet = formatErrorSnippet(f, { contextLines: 1, tabWidth: 2 });
112+
113+
assertEquals(snippet.includes("expected 'x' at line 2, column 2"), true);
114+
assertEquals(snippet.includes("1 | aaa"), true);
115+
assertEquals(snippet.includes("2 | bbb"), true);
116+
assertEquals(snippet.includes("3 | ccc"), true);
117+
assertEquals(snippet.includes(" | ^"), true);
118+
});
119+
120+
Deno.test("formatErrorSnippet handles CRLF and tab expansion", () => {
121+
const text = "a\r\n\tb\r\nc\r\n";
122+
const f = failure({ text, index: 4 }, "boom"); // line 2, column 2 (after '\t')
123+
124+
const snippet = formatErrorSnippet(f, { contextLines: 1, tabWidth: 2 });
125+
126+
// No stray '\r' in rendered lines.
127+
assertEquals(snippet.includes("\r"), false);
128+
// Tab rendered as 2 spaces, caret aligned after them.
129+
assertEquals(snippet.includes("2 | b"), true);
130+
assertEquals(snippet.includes(" | ^"), true);
131+
});
132+
106133
Deno.test("context combinator adds context on failure", () => {
107134
const parser = context("in greeting", str("hello"));
108135
const result = parser({ text: "world", index: 0 });

0 commit comments

Comments
 (0)