From 036acad7a8b5efea0df9257a48b7e3b58960f1d3 Mon Sep 17 00:00:00 2001 From: janithcd Date: Thu, 30 Jul 2026 17:51:50 +0530 Subject: [PATCH] fix: harden parser edge cases Closes #7 --- .../stacktale/idea/StReportParser.java | 43 ++++++++--- .../stacktale/idea/StReportParserTest.java | 71 ++++++++++++++++--- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StReportParser.java b/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StReportParser.java index e2199ed..c591819 100644 --- a/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StReportParser.java +++ b/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StReportParser.java @@ -18,7 +18,8 @@ public final class StReportParser { // id + timestamp from "━━━ ERROR #a1b2 ━━━ 2026-07-10 20:16:40.412 thread=… ━━━" private static final Pattern HEADER = Pattern.compile("^━━━ ERROR #(\\S+) ━━━ (.+?) thread="); // a frame carrying a source location: "…(PaymentService.java:44)" - private static final Pattern FRAME = Pattern.compile("\\(([\\w$]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)"); + private static final Pattern FRAME = Pattern.compile( + "\\(([^\\s:()]+\\.(?:java|kts|kt|groovy|scala)):(-?\\d+)\\)"); private StReportParser() { } @@ -37,24 +38,27 @@ public static List parse(String content) { } List block = new ArrayList<>(); block.add(stripCr(lines[i])); + boolean complete = false; int j = i + 1; while (j < lines.length) { String bl = stripCr(lines[j]); if (bl.startsWith(START)) break; // next block began — this one was truncated block.add(bl); if (bl.startsWith(END)) { + complete = true; j++; break; } j++; } - StReport r = parseBlock(block); - if (r != null) reports.add(r); + if (complete) { + StReport report = parseBlock(block); + if (report != null) reports.add(report); + } i = j; } return reports; } - private static StReport parseBlock(List block) { Matcher h = HEADER.matcher(block.get(0)); if (!h.find()) return null; @@ -63,21 +67,38 @@ private static StReport parseBlock(List block) { String headline = block.size() > 1 ? block.get(1).trim() : ""; StFrame culprit = null; + boolean markedFrameFound = false; List frames = new ArrayList<>(); for (String bl : block) { Matcher fm = FRAME.matcher(bl); if (fm.find()) { - StFrame f = new StFrame(fm.group(1), Integer.parseInt(fm.group(2)), bl.trim()); - frames.add(f); - if (culprit == null && (bl.contains("← YOUR CODE") || bl.contains("← culprit"))) { - culprit = f; - } + boolean marked = bl.contains("← YOUR CODE") || bl.contains("← culprit"); + if (marked) markedFrameFound = true; + + int line = parseLineNumber(fm.group(2)); + if (line <= 0) continue; + + StFrame frame = new StFrame(fm.group(1), line, bl.trim()); + frames.add(frame); + if (culprit == null && marked) culprit = frame; } } - if (culprit == null && !frames.isEmpty()) culprit = frames.get(0); + if (culprit == null && !markedFrameFound && !frames.isEmpty()) { + culprit = frames.get(0); + } return new StReport(id, timestamp, headline, culprit, frames, String.join("\n", block)); } - + private static int parseLineNumber(String value) { + try { + long line = Long.parseLong(value); + if (line > Integer.MAX_VALUE) return Integer.MAX_VALUE; + if (line < Integer.MIN_VALUE) return Integer.MIN_VALUE; + return (int) line; + } catch (NumberFormatException ignored) { + // The regex guarantees digits, so overflow is the only expected failure. + return value.startsWith("-") ? Integer.MIN_VALUE : Integer.MAX_VALUE; + } + } private static String stripCr(String s) { return s.endsWith("\r") ? s.substring(0, s.length() - 1) : s; } diff --git a/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StReportParserTest.java b/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StReportParserTest.java index db14fe6..7f4dab4 100644 --- a/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StReportParserTest.java +++ b/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StReportParserTest.java @@ -22,7 +22,6 @@ class StReportParserTest { env: app=shop-api | java 21 | linux ━━━ END #a1b2c3d4 ━━━ """; - @Test void parsesReportIdHeadlineAndCulpritLocation() { List reports = StReportParser.parse(SAMPLE); @@ -37,7 +36,6 @@ void parsesReportIdHeadlineAndCulpritLocation() { assertThat(r.culprit().line()).isEqualTo(44); assertThat(r.block()).startsWith("━━━ ERROR #a1b2c3d4").contains("━━━ END #a1b2c3d4"); } - @Test void ignoresTheSelfDescribingHeaderAndParsesEveryReport() { String two = SAMPLE @@ -51,20 +49,73 @@ void ignoresTheSelfDescribingHeaderAndParsesEveryReport() { assertThat(reports).extracting(StReport::id).containsExactly("a1b2c3d4", "beef"); assertThat(reports.get(1).culprit().line()).isEqualTo(87); } - @Test - void aTruncatedTrailingBlockIsStillSurfaced() { - // a file killed mid-write: the last block has no END line + void discardsTruncatedBlocksAndStillParsesTheNextCompleteReport() { String truncated = "━━━ ERROR #dead ━━━ 2026-07-10 20:18:00.000 thread=main ━━━\n" + "RuntimeException: boom\n" + "at Svc.run(Svc.java:12) ← YOUR CODE\n"; - - List reports = StReportParser.parse(truncated); - + assertThat(StReportParser.parse(truncated)).isEmpty(); + String completeAfterTruncated = truncated + + "━━━ ERROR #beef ━━━ 2026-07-10 20:19:00.000 thread=main ━━━\n" + + "RuntimeException: complete report\n" + + "at GoodService.run(GoodService.java:7) ← YOUR CODE\n" + + "━━━ END #beef ━━━\n"; + assertThat(StReportParser.parse(completeAfterTruncated)) + .extracting(StReport::id) + .containsExactly("beef"); + } + @Test + void markedFrameWithNegativeLineDoesNotFallBackToAnotherFile() { + String content = """ + ━━━ ERROR #nodebug ━━━ 2026-07-10 20:20:00.000 thread=main ━━━ + IllegalStateException: wrapped failure + at CheckoutService.wrap(CheckoutService.java:88) + at OrderService.confirm(OrderService.java:-1) ← YOUR CODE + ━━━ END #nodebug ━━━ + """; + + StReport report = StReportParser.parse(content).get(0); + + assertThat(report.culprit()).isNull(); + assertThat(report.frames()) + .extracting(StFrame::fileName) + .containsExactly("CheckoutService.java"); + } + @Test + void clampsAbsurdLineNumbersInsteadOfThrowing() { + String content = """ + ━━━ ERROR #huge ━━━ 2026-07-10 20:21:00.000 thread=main ━━━ + RuntimeException: huge line number + at HugeService.run(HugeService.java:999999999999999999999999) ← YOUR CODE + ━━━ END #huge ━━━ + """; + + List reports = StReportParser.parse(content); assertThat(reports).hasSize(1); - assertThat(reports.get(0).culprit().fileName()).isEqualTo("Svc.java"); + assertThat(reports.get(0).culprit()).isNotNull(); + assertThat(reports.get(0).culprit().line()).isEqualTo(Integer.MAX_VALUE); + } + @Test + void parsesUnicodeAndKotlinScriptFileNames() { + String content = """ + ━━━ ERROR #unicode ━━━ 2026-07-10 20:22:00.000 thread=main ━━━ + RuntimeException: unicode filename + at Ação.run(Ação.java:23) ← YOUR CODE + ━━━ END #unicode ━━━ + ━━━ ERROR #script ━━━ 2026-07-10 20:23:00.000 thread=main ━━━ + RuntimeException: Kotlin script filename + at 構建.run(構建.kts:9) ← YOUR CODE + ━━━ END #script ━━━ + """; + + List reports = StReportParser.parse(content); + + assertThat(reports).hasSize(2); + assertThat(reports.get(0).culprit().fileName()).isEqualTo("Ação.java"); + assertThat(reports.get(0).culprit().line()).isEqualTo(23); + assertThat(reports.get(1).culprit().fileName()).isEqualTo("構建.kts"); + assertThat(reports.get(1).culprit().line()).isEqualTo(9); } - @Test void toleratesEmptyAndHeaderOnlyFiles() { assertThat(StReportParser.parse("")).isEmpty();