Skip to content

Commit e31fe5a

Browse files
fix: bring the parser back in step with the JetBrains one
Ports the fix from stacktale-intellij#11, which closed the same three bugs there. The two parsers are deliberate twins and had diverged. - a block whose END delimiter has not arrived is discarded, not shown as a partial report (FORMAT.md requires this of any reader) - a marked frame with no usable line number no longer falls back to the first frame, which for a wrapped exception is the wrapped by: line in a different class — jumping there silently is worse than not jumping - the file pattern accepts Unicode names and .kts; \w is ASCII-only in JS without the u flag, so Ação.java matched nothing and the report came out with no culprit at all Verified against the authoritative golden fixtures in the main repo: both parsers now return the same culprit for all three, including null for the no-throwable report. Closes #5
1 parent 96e3180 commit e31fe5a

3 files changed

Lines changed: 106 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ First release. Parity with the JetBrains plugin's core:
99
- **Click to open the culprit** frame (`File.java:line`, the `← YOUR CODE` frame).
1010
- **Copy report for AI** — copies the full report block to the clipboard.
1111
- Auto-detects `errors-ai.log` in the workspace; `stacktale.file` overrides the path.
12+
- Clicking a report always responds: one with no source frame says so instead of doing
13+
nothing, and the view header shows which `errors-ai.log` was resolved. Thanks
14+
@11suixing11.
15+
- Parser conformance: a block caught mid-write is discarded until its closing delimiter
16+
arrives (as `FORMAT.md` requires), a marked frame with no line number no longer falls
17+
back to an unrelated file, and Unicode source names and `.kts` frames parse.

‎src/stParser.ts‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,18 @@ const START = "━━━ ERROR #";
2525
const END = "━━━ END #";
2626
// id + timestamp from "━━━ ERROR #a1b2 ━━━ 2026-07-10 20:16:40.412 thread=… ━━━"
2727
const HEADER = /^━━━ ERROR #(\S+) ━━━ (.+?) thread=/;
28-
// a frame carrying a source location: "…(PaymentService.java:44)"
29-
const FRAME = /\(([\w$]+\.(?:java|kt|groovy|scala)):(\d+)\)/;
28+
// A frame carrying a source location: "…(PaymentService.java:44)".
29+
//
30+
// The file part accepts anything that isn't whitespace, a colon or a paren, so Unicode
31+
// source names (`Ação.java`) match — `\w` is ASCII-only in JS without the `u` flag, and a
32+
// frame that doesn't match leaves the report with no culprit at all.
33+
//
34+
// `kts` is listed before `kt` on purpose: with `kt` first the engine matches `.kt`, then
35+
// needs `:` and finds `s`, and the whole frame fails to parse.
36+
//
37+
// The line number allows a leading `-`. The JVM writes -1 for a class compiled without
38+
// `-g:lines` and -2 for a native method, and the library renders it verbatim.
39+
const FRAME = /\(([^\s:()]+\.(?:java|kts|kt|groovy|scala)):(-?\d+)\)/;
3040

3141
export function parseReports(content: string): StReport[] {
3242
const reports: StReport[] = [];
@@ -42,6 +52,7 @@ export function parseReports(content: string): StReport[] {
4252
continue;
4353
}
4454
const block: string[] = [lines[i]];
55+
let complete = false;
4556
let j = i + 1;
4657
while (j < lines.length) {
4758
const bl = lines[j];
@@ -50,14 +61,20 @@ export function parseReports(content: string): StReport[] {
5061
}
5162
block.push(bl);
5263
if (bl.startsWith(END)) {
64+
complete = true;
5365
j++;
5466
break;
5567
}
5668
j++;
5769
}
58-
const report = parseBlock(block);
59-
if (report) {
60-
reports.push(report);
70+
// FORMAT.md: a block whose closing line is absent is incomplete and MUST be discarded
71+
// rather than shown as a partial entry. The writer appends the block separately from
72+
// the header, so a read can land mid-write and catch exactly this.
73+
if (complete) {
74+
const report = parseBlock(block);
75+
if (report) {
76+
reports.push(report);
77+
}
6178
}
6279
i = j;
6380
}
@@ -74,18 +91,31 @@ function parseBlock(block: string[]): StReport | undefined {
7491
const headline = block.length > 1 ? block[1].trim() : "";
7592

7693
let culprit: StFrame | undefined;
94+
let markedFrameSeen = false;
7795
const frames: StFrame[] = [];
7896
for (const bl of block) {
7997
const fm = FRAME.exec(bl);
80-
if (fm) {
81-
const frame: StFrame = { file: fm[1], line: parseInt(fm[2], 10), text: bl.trim() };
82-
frames.push(frame);
83-
if (!culprit && (bl.includes("← YOUR CODE") || bl.includes("← culprit"))) {
84-
culprit = frame;
85-
}
98+
if (!fm) {
99+
continue;
100+
}
101+
const marked = bl.includes("← YOUR CODE") || bl.includes("← culprit");
102+
if (marked) {
103+
markedFrameSeen = true;
104+
}
105+
const line = parseInt(fm[2], 10);
106+
if (line <= 0) {
107+
continue; // no source position — nothing to navigate to
108+
}
109+
const frame: StFrame = { file: fm[1], line, text: bl.trim() };
110+
frames.push(frame);
111+
if (!culprit && marked) {
112+
culprit = frame;
86113
}
87114
}
88-
if (!culprit && frames.length > 0) {
115+
// Fall back to the first frame ONLY when the block marked none. When a frame WAS marked
116+
// but had no usable position, jumping to some other frame is worse than not jumping: for
117+
// a wrapped exception that other frame is the `wrapped by:` line, in a different class.
118+
if (!culprit && !markedFrameSeen && frames.length > 0) {
89119
culprit = frames[0];
90120
}
91121
return { id, timestamp, headline, culprit, frames, block: block.join("\n") };

‎src/test/parser.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,61 @@ test("keeps reports that do not contain a source frame", () => {
6464
assert.equal(reports[0].frames.length, 0);
6565
assert.equal(reports[0].culprit, undefined);
6666
});
67+
68+
// The three bugs from #5, mirroring the fix landed in the JetBrains plugin (its PR #11).
69+
// The two parsers are deliberate twins; these cases must behave identically in both.
70+
71+
test("discards a truncated block and still parses the next complete one", () => {
72+
const truncated =
73+
"━━━ ERROR #dead ━━━ 2026-07-10 20:18:00.000 thread=main ━━━\n" +
74+
"RuntimeException: boom\n" +
75+
"at Svc.run(Svc.java:12) ← YOUR CODE\n";
76+
77+
assert.equal(parseReports(truncated).length, 0);
78+
79+
const followed =
80+
truncated +
81+
"━━━ ERROR #beef ━━━ 2026-07-10 20:19:00.000 thread=main ━━━\n" +
82+
"RuntimeException: complete\n" +
83+
"at GoodService.run(GoodService.java:7) ← YOUR CODE\n" +
84+
"━━━ END #beef ━━━\n";
85+
86+
const reports = parseReports(followed);
87+
assert.equal(reports.length, 1);
88+
assert.equal(reports[0].id, "beef");
89+
});
90+
91+
test("a marked frame with no line number does not fall back to another file", () => {
92+
// -1 is what the JVM reports for a class compiled without -g:lines
93+
const content = [
94+
"━━━ ERROR #nodebug ━━━ 2026-07-10 20:20:00.000 thread=main ━━━",
95+
"IllegalStateException: wrapped failure",
96+
"wrapped by: CheckoutException at CheckoutService.confirm(CheckoutService.java:88)",
97+
"at OrderService.confirm(OrderService.java:-1) ← YOUR CODE",
98+
"━━━ END #nodebug ━━━",
99+
].join("\n");
100+
101+
const r = parseReports(content)[0];
102+
assert.equal(r.culprit, undefined);
103+
assert.deepEqual(r.frames.map((f) => f.file), ["CheckoutService.java"]);
104+
});
105+
106+
test("parses unicode filenames and .kts frames", () => {
107+
const content = [
108+
"━━━ ERROR #unicode ━━━ 2026-07-10 20:22:00.000 thread=main ━━━",
109+
"RuntimeException: unicode filename",
110+
"at Ação.run(Ação.java:23) ← YOUR CODE",
111+
"━━━ END #unicode ━━━",
112+
"━━━ ERROR #script ━━━ 2026-07-10 20:23:00.000 thread=main ━━━",
113+
"RuntimeException: kotlin script",
114+
"at 構建.run(構建.kts:9) ← YOUR CODE",
115+
"━━━ END #script ━━━",
116+
].join("\n");
117+
118+
const reports = parseReports(content);
119+
assert.equal(reports.length, 2);
120+
assert.equal(reports[0].culprit?.file, "Ação.java");
121+
assert.equal(reports[0].culprit?.line, 23);
122+
assert.equal(reports[1].culprit?.file, "構建.kts");
123+
assert.equal(reports[1].culprit?.line, 9);
124+
});

0 commit comments

Comments
 (0)