Skip to content

Commit a25b4e4

Browse files
committed
Better handle variants in traces (particularly syntax errors)
1 parent 17d793d commit a25b4e4

File tree

4 files changed

+74
-20
lines changed

4 files changed

+74
-20
lines changed

copydecks/en/copydeck.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@
5353

5454
"SyntaxError": {
5555
"variants": [
56+
{
57+
"if": {
58+
"match_message": ["expected ':'"]
59+
},
60+
"title": "Missing colon (:) at the end",
61+
"summary": "This line starts a block and needs a colon at {{loc}}: {{codeLine}}",
62+
"why": "In Python, lines that start a block must end with a colon.",
63+
"steps": [
64+
"Add a colon (:) at the end of that line."
65+
]
66+
},
5667
{
5768
"if": {
5869
"match_code": ["^(\\s*)(if|for|while|def|class|elif|else|try|except|with)\\b"],

src/adapters/pyodide.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export const pyodideAdapter: AdapterFn = (raw, code) => {
2121
if (cc) col = parseInt(cc[1], 10);
2222
}
2323

24+
if (!line) {
25+
const m1 = raw.match(/on\s+line\s+(\d+)\s+of\s+([^\s:]+)(?::\s*([\s\S]*))?/i);
26+
if (m1) {
27+
line = parseInt(m1[1], 10);
28+
file = m1[2];
29+
const afterColon = (m1[3] || "").split(/\r?\n/);
30+
const snippet = afterColon.find(s => s.trim() && !/^\s*\^+\s*$/.test(s));
31+
}
32+
}
33+
2434
let name: string | undefined;
2535
const q = (message || "").match(/["']([^"']+)["']/);
2636
if (q) name = q[1];
@@ -38,10 +48,20 @@ export const pyodideAdapter: AdapterFn = (raw, code) => {
3848
};
3949

4050
if (code && line) {
41-
const lines = code.split(/\r?\n/);
42-
t.codeLine = lines[line - 1]?.trim();
43-
t.codeBefore = lines.slice(Math.max(0, line - 3), line - 1);
44-
t.codeAfter = lines.slice(line, line + 2);
51+
const codeLines = code.split(/\r?\n/);
52+
t.codeLine = codeLines[line - 1]?.trim();
53+
t.codeBefore = codeLines.slice(Math.max(0, line - 3), line - 1);
54+
t.codeAfter = codeLines.slice(line, line + 2);
55+
} else {
56+
const m1 = raw.match(/on\s+line\s+\d+\s+of\s+[^\s:]+:(?:\s*([\s\S]*))?/i);
57+
if (m1 && m1[1]) {
58+
const snippet = m1[1].split(/\r?\n/).find(s => s.trim() && !/^\s*\^+\s*$/.test(s));
59+
if (snippet) t.codeLine = snippet.trim();
60+
}
4561
}
62+
63+
const looksLikeError = /Error\b/i.test(raw) || /Traceback/i.test(raw) || /pyodide/i.test(raw);
64+
if (!looksLikeError) return null;
65+
4666
return t;
4767
};

src/adapters/skulpt.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,26 @@ const lastLineTypeMessage = (raw: string) => {
88
};
99

1010
export const skulptAdapter: AdapterFn = (raw, code) => {
11-
if (!/skulpt/i.test(raw) && !/Traceback/i.test(raw)) {
12-
// still try; skulpt often includes "on line X of", etc.
13-
}
11+
// Skulpt traces vary - we accept both "Traceback..." and editor summaries
1412
const { type, message, tail, lines } = lastLineTypeMessage(raw);
1513
let file: string | undefined, line: number | undefined, col: number | undefined;
1614

15+
// standard Python-style frames (Skulpt often emits these)
1716
for (const L of lines) {
1817
const mm = L.match(/File\s+"([^"]+)",\s+line\s+(\d+)/i);
1918
if (mm) {
2019
file = mm[1];
2120
line = parseInt(mm[2], 10);
2221
}
2322
}
23+
2424
if (!line) {
25-
const loc = tail.match(/\b(?:on|at)\s+line\s+(\d+)\s+(?:of|in)\s+([^\s:]+)\b/i);
26-
if (loc) {
27-
line = parseInt(loc[1], 10);
28-
file = loc[2];
25+
const m1 = raw.match(/on\s+line\s+(\d+)\s+of\s+([^\s:]+)(?::\s*([\s\S]*))?/i);
26+
if (m1) {
27+
line = parseInt(m1[1], 10);
28+
file = m1[2];
29+
const afterColon = (m1[3] || "").split(/\r?\n/);
30+
const snippet = afterColon.find(s => s.trim() && !/^\s*\^+\s*$/.test(s));
2931
}
3032
}
3133

@@ -46,10 +48,20 @@ export const skulptAdapter: AdapterFn = (raw, code) => {
4648
};
4749

4850
if (code && line) {
49-
const lines = code.split(/\r?\n/);
50-
t.codeLine = lines[line - 1]?.trim();
51-
t.codeBefore = lines.slice(Math.max(0, line - 3), line - 1);
52-
t.codeAfter = lines.slice(line, line + 2);
51+
const codeLines = code.split(/\r?\n/);
52+
t.codeLine = codeLines[line - 1]?.trim();
53+
t.codeBefore = codeLines.slice(Math.max(0, line - 3), line - 1);
54+
t.codeAfter = codeLines.slice(line, line + 2);
55+
} else {
56+
const m1 = raw.match(/on\s+line\s+\d+\s+of\s+[^\s:]+:(?:\s*([\s\S]*))?/i);
57+
if (m1 && m1[1]) {
58+
const snippet = m1[1].split(/\r?\n/).find(s => s.trim() && !/^\s*\^+\s*$/.test(s));
59+
if (snippet) t.codeLine = snippet.trim();
60+
}
5361
}
62+
63+
const looksLikeError = /Error\b/i.test(raw) || /Traceback/i.test(raw) || /skulpt/i.test(raw);
64+
if (!looksLikeError) return null;
65+
5466
return t;
5567
};

src/engine.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,24 @@ export const loadCopydeck = (deck: CopyDeck) => (S.copy = deck);
1313
export const registerAdapter = (name: string, fn: (raw: string, code?: string) => Trace | null) =>
1414
(S.adapters[name] = fn);
1515

16+
const isTrace = (x: unknown): x is Trace =>
17+
!!x && typeof x === "object" && "raw" in (x as any) && "message" in (x as any);
18+
1619
const coerceTrace = (input: string | Error | Trace, code?: string): Trace => {
17-
if ((input as Trace).raw !== undefined) return input as Trace;
18-
const raw = typeof input === "string" ? input : String((input as Error).stack || (input as Error).message || input);
20+
if (isTrace(input)) return input as Trace;
21+
22+
const raw =
23+
typeof input === "string"
24+
? input
25+
: String((input as Error).stack || (input as Error).message || input);
26+
1927
// try adapters in registration order
2028
for (const key of Object.keys(S.adapters)) {
2129
const t = S.adapters[key](raw, code);
2230
if (t) return t;
2331
}
24-
// generic fallback
32+
33+
// fallback generic trace
2534
const lines = raw.trim().split(/\r?\n/).filter(Boolean);
2635
const tail = lines[lines.length - 1] || "";
2736
const m = tail.match(/^(\w+Error)\s*:\s*(.*)$/);
@@ -31,8 +40,9 @@ const coerceTrace = (input: string | Error | Trace, code?: string): Trace => {
3140
raw,
3241
runtime: "unknown"
3342
};
34-
if (code) {
35-
t.codeLine = code.split(/\r?\n/)[(t.line || 1) - 1]?.trim();
43+
44+
if (code && t.line) {
45+
t.codeLine = code.split(/\r?\n/)[t.line - 1]?.trim();
3646
}
3747
return t;
3848
};
@@ -128,6 +138,7 @@ export const explain = (opts: ExplainOptions): ExplainResult => {
128138

129139
const chosen = pickVariant(trace, code, audience);
130140
if (!chosen) {
141+
// this generic fallback copy should live in the copydeck instead...
131142
return {
132143
trace,
133144
variantId: "Other/variants/0",

0 commit comments

Comments
 (0)