Integer.compare(a.span().start().column(), b.span().start().column()))
+ .sorted((a, b) -> Integer.compare(a.span().startColumn(), b.span().startColumn()))
.toList();
for (var label : sorted) {
- int startCol = label.span().start().line() == lineNum ? label.span().start().column() : 1;
- int endCol = label.span().end().line() == lineNum
- ? label.span().end().column()
+ int startCol = label.span().startLine() == lineNum ? label.span().startColumn() : 1;
+ int endCol = label.span().endLine() == lineNum
+ ? label.span().endColumn()
: lineContent.length() + 1;
while (currentCol < startCol) {
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/SourceSpan.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/SourceSpan.java
index d893902..228ac50 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/tree/SourceSpan.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/SourceSpan.java
@@ -1,36 +1,70 @@
package org.pragmatica.peg.tree;
/**
* A range in source text from start (inclusive) to end (exclusive).
+ *
+ * Stored as six primitive ints to avoid long-lived {@link SourceLocation}
+ * references (which previously promoted to old gen via CstNode → SourceSpan → SourceLocation
+ * chains, contributing to GC pause tail). {@link #start()}/{@link #end()} materialize
+ * fresh SourceLocation instances on demand; callers needing a single field should use
+ * the int accessors directly (e.g., {@link #startLine()}, {@link #endOffset()}) to avoid
+ * the materialization cost.
*/
-public record SourceSpan(SourceLocation start, SourceLocation end) {
+public record SourceSpan(int startLine,
+ int startColumn,
+ int startOffset,
+ int endLine,
+ int endColumn,
+ int endOffset) {
public static SourceSpan sourceSpan(SourceLocation start, SourceLocation end) {
- return new SourceSpan(start, end);
+ return new SourceSpan(start.line(), start.column(), start.offset(), end.line(), end.column(), end.offset());
}
public static SourceSpan sourceSpan(SourceLocation location) {
- return new SourceSpan(location, location);
+ return sourceSpan(location, location);
+ }
+
+ public SourceLocation start() {
+ return SourceLocation.sourceLocation(startLine, startColumn, startOffset);
+ }
+
+ public SourceLocation end() {
+ return SourceLocation.sourceLocation(endLine, endColumn, endOffset);
}
public int length() {
- return end.offset() - start.offset();
+ return endOffset - startOffset;
}
public String extract(String source) {
- return source.substring(start.offset(), end.offset());
+ return source.substring(startOffset, endOffset);
}
public SourceSpan merge(SourceSpan other) {
- var newStart = start.offset() <= other.start.offset()
- ? start
- : other.start;
- var newEnd = end.offset() >= other.end.offset()
- ? end
- : other.end;
- return new SourceSpan(newStart, newEnd);
+ int newStartLine, newStartColumn, newStartOffset;
+ int newEndLine, newEndColumn, newEndOffset;
+ if (startOffset <= other.startOffset) {
+ newStartLine = startLine;
+ newStartColumn = startColumn;
+ newStartOffset = startOffset;
+ }else {
+ newStartLine = other.startLine;
+ newStartColumn = other.startColumn;
+ newStartOffset = other.startOffset;
+ }
+ if (endOffset >= other.endOffset) {
+ newEndLine = endLine;
+ newEndColumn = endColumn;
+ newEndOffset = endOffset;
+ }else {
+ newEndLine = other.endLine;
+ newEndColumn = other.endColumn;
+ newEndOffset = other.endOffset;
+ }
+ return new SourceSpan(newStartLine, newStartColumn, newStartOffset, newEndLine, newEndColumn, newEndOffset);
}
@Override
public String toString() {
- return start + "-" + end;
+ return startLine + ":" + startColumn + "-" + endLine + ":" + endColumn;
}
}
diff --git a/peglib-core/src/test/java/org/pragmatica/peg/TriviaTest.java b/peglib-core/src/test/java/org/pragmatica/peg/TriviaTest.java
index c4df24a..c47dcae 100644
--- a/peglib-core/src/test/java/org/pragmatica/peg/TriviaTest.java
+++ b/peglib-core/src/test/java/org/pragmatica/peg/TriviaTest.java
@@ -158,8 +158,8 @@ void triviaSpan_hasCorrectPositions() {
assertThat(node.leadingTrivia()).hasSize(1);
var trivia = node.leadingTrivia().getFirst();
- assertThat(trivia.span().start().offset()).isEqualTo(0);
- assertThat(trivia.span().end().offset()).isEqualTo(3);
+ assertThat(trivia.span().startOffset()).isEqualTo(0);
+ assertThat(trivia.span().endOffset()).isEqualTo(3);
assertThat(trivia.text()).isEqualTo(" ");
}
diff --git a/peglib-core/src/test/java/org/pragmatica/peg/examples/ErrorRecoveryExample.java b/peglib-core/src/test/java/org/pragmatica/peg/examples/ErrorRecoveryExample.java
index 5290724..b9aeb2a 100644
--- a/peglib-core/src/test/java/org/pragmatica/peg/examples/ErrorRecoveryExample.java
+++ b/peglib-core/src/test/java/org/pragmatica/peg/examples/ErrorRecoveryExample.java
@@ -287,8 +287,8 @@ record LspDiagnostic(int line, int column, int severity, String message) {}
var lspDiagnostics = result.diagnostics().stream()
.map(d -> new LspDiagnostic(
- d.span().start().line() - 1, // LSP uses 0-based
- d.span().start().column() - 1,
+ d.span().startLine() - 1, // LSP uses 0-based
+ d.span().startColumn() - 1,
d.severity() == Diagnostic.Severity.ERROR ? 1 : 2,
d.message()
))
@@ -314,8 +314,8 @@ record ErrorRange(int start, int end) {}
var ranges = result.diagnostics().stream()
.map(d -> new ErrorRange(
- d.span().start().offset(),
- d.span().end().offset()
+ d.span().startOffset(),
+ d.span().endOffset()
))
.toList();
diff --git a/peglib-core/src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java b/peglib-core/src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java
index 4b8fb5f..71d8963 100644
--- a/peglib-core/src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java
+++ b/peglib-core/src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java
@@ -16,7 +16,7 @@
*/
class ChoiceDispatchAnalyzerTest {
- private static final SourceSpan SPAN = new SourceSpan(
+ private static final SourceSpan SPAN = SourceSpan.sourceSpan(
new SourceLocation(1, 1, 0),
new SourceLocation(1, 1, 0));
diff --git a/peglib-formatter/pom.xml b/peglib-formatter/pom.xml
index d61b091..9b41342 100644
--- a/peglib-formatter/pom.xml
+++ b/peglib-formatter/pom.xml
@@ -7,7 +7,7 @@
org.pragmatica-lite
peglib-parent
- 0.4.2
+ 0.4.3
../pom.xml
diff --git a/peglib-incremental/pom.xml b/peglib-incremental/pom.xml
index 3f4a225..0331120 100644
--- a/peglib-incremental/pom.xml
+++ b/peglib-incremental/pom.xml
@@ -7,7 +7,7 @@
org.pragmatica-lite
peglib-parent
- 0.4.2
+ 0.4.3
../pom.xml
diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java
new file mode 100644
index 0000000..7cf6d3d
--- /dev/null
+++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java
@@ -0,0 +1,530 @@
+package org.pragmatica.peg.incremental.bench;
+
+import org.pragmatica.peg.grammar.Grammar;
+import org.pragmatica.peg.grammar.GrammarParser;
+import org.pragmatica.peg.incremental.Edit;
+import org.pragmatica.peg.incremental.IncrementalParser;
+import org.pragmatica.peg.incremental.Session;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Realistic interactive editing-session benchmark for {@code peglib-incremental}.
+ *
+ *
What it measures. A single sequential editing session: 1,000
+ * mixed edits applied in order to the 1,900-LOC fixture
+ * ({@code FactoryClassGenerator.java.txt}). Each edit's wall-clock latency
+ * is recorded individually with {@code System.nanoTime()} and bucketed by
+ * edit class. We report median, p95, p99, max, and full-reparse fallback
+ * count per class, plus cold-vs-warm split (first 50 vs subsequent edits)
+ * and the share of edits that meet the 16 ms frame budget.
+ *
+ *
Two regimes run on the SAME edit sequence (same RNG seed):
+ *
+ * - Regime B (primary, realistic): cursor moved to the edit
+ * offset before each edit — what real editors do.
+ * - Regime A (control, pessimistic): cursor stays at offset 0
+ * for the entire session — exposes pivot-overshoot cost.
+ *
+ *
+ * How to run.
+ *
{@code
+ * mvn -pl peglib-incremental -Pbench -DskipTests package -q
+ * java -cp peglib-incremental/target/benchmarks.jar \
+ * org.pragmatica.peg.incremental.bench.IncrementalSessionBench
+ * }
+ *
+ * How to interpret the table. All times are in milliseconds.
+ * Edit classes:
+ *
+ * - {@code single-char} — one-character insert or delete (60% of edits)
+ * - {@code word} — short identifier insert or delete (25% of edits)
+ * - {@code line} — paste/delete a 20–80 char run (10%)
+ * - {@code block} — paste/delete an 80–200 char run (5%)
+ *
+ * The {@code Fallbacks} column counts edits that triggered a full reparse
+ * (boundary walk reached the root, back-reference rule on the path, or the
+ * reparsed subtree failed to validate). The {@code DELTA} section shows
+ * the ratio of Regime B / Regime A medians; values < 1 mean
+ * cursor-aware editing is faster than the cursor-pinned baseline.
+ *
+ * The bench is deterministic: the same RNG seed produces the same edit
+ * sequence on the same JDK build, so reruns are directly comparable.
+ *
+ * @since 0.4.4
+ */
+public final class IncrementalSessionBench {
+
+ private static final String GRAMMAR_RESOURCE = "/java25.peg";
+ private static final String FIXTURE_RESOURCE = "/perf-corpus/large/FactoryClassGenerator.java.txt";
+
+ private static final long RNG_SEED = 0xBEEFCAFEL;
+ private static final int EDIT_COUNT = 1000;
+ private static final int COLD_PREFIX = 50;
+ private static final double FRAME_BUDGET_MS = 16.0;
+
+ /** Edit shape, used for per-class bucketing in the report. */
+ private enum EditClass { SINGLE_CHAR, WORD, LINE, BLOCK;
+ String label() {
+ return switch (this) {
+ case SINGLE_CHAR -> "single-char";
+ case WORD -> "word";
+ case LINE -> "line";
+ case BLOCK -> "block";
+ };
+ }
+ }
+
+ /** A pre-classified edit, generated up-front so generation cost is excluded from timing. */
+ private record ClassifiedEdit(Edit edit, EditClass cls) {}
+
+ /** Per-edit measurement record. {@code -1} latency means the edit was skipped. */
+ private record Measurement(
+ EditClass cls,
+ long latencyNs,
+ boolean wasFallback,
+ boolean skipped,
+ int editOffset,
+ int editOldLen,
+ int editNewLen,
+ String pivotRule,
+ int pivotNodeCount) {}
+
+ public static void main(String[] args) throws Exception {
+ var grammarText = loadResource(GRAMMAR_RESOURCE);
+ var fixtureSource = loadResource(FIXTURE_RESOURCE);
+ var grammar = GrammarParser.parse(grammarText).fold(
+ cause -> { throw new IllegalStateException("grammar parse failed: " + cause.message()); },
+ g -> g);
+ var parser = IncrementalParser.create(grammar);
+
+ // Generate the edit plan once. The same plan drives both regimes so
+ // the comparison is apples-to-apples.
+ var plan = generateEditPlan(fixtureSource, EDIT_COUNT, RNG_SEED);
+
+ // Warm the JIT a bit so first-edit numbers reflect steady-state JIT,
+ // not interpreter cost. We do a small throwaway session.
+ warmJit(parser, fixtureSource);
+
+ var regimeB = runRegime(parser, fixtureSource, plan, /*moveCursor=*/true);
+ var regimeA = runRegime(parser, fixtureSource, plan, /*moveCursor=*/false);
+
+ printReport(fixtureSource, regimeB, regimeA);
+ }
+
+ // -------- Regime driver ----------------------------------------------------------------
+
+ private static Measurement[] runRegime(
+ IncrementalParser parser, String fixtureSource, List plan, boolean moveCursor) {
+ var session = parser.initialize(fixtureSource, 0);
+ int prevFallbacks = session.stats().fullReparseCount();
+ var out = new Measurement[plan.size()];
+ for (int i = 0; i < plan.size(); i++) {
+ var ce = plan.get(i);
+ var edit = ce.edit();
+ // Clamp the edit to the current buffer: the plan was generated against
+ // the initial text, so by the time we replay against the live session
+ // text the offsets and lengths may drift. Skip edits that cannot fit.
+ int textLen = session.text().length();
+ if (edit.offset() > textLen || edit.offset() + edit.oldLen() > textLen) {
+ out[i] = new Measurement(ce.cls(), -1L, false, true,
+ edit.offset(), edit.oldLen(), edit.newText().length(), "", 0);
+ continue;
+ }
+ try {
+ long t0 = System.nanoTime();
+ Session next;
+ if (moveCursor) {
+ next = session.moveCursor(edit.offset()).edit(edit);
+ } else {
+ next = session.edit(edit);
+ }
+ long t1 = System.nanoTime();
+ var stats = next.stats();
+ int fallbacks = stats.fullReparseCount();
+ boolean wasFallback = fallbacks > prevFallbacks;
+ prevFallbacks = fallbacks;
+ out[i] = new Measurement(ce.cls(), t1 - t0, wasFallback, false,
+ edit.offset(), edit.oldLen(), edit.newText().length(),
+ stats.lastReparsedRule(), stats.lastReparsedNodeCount());
+ session = next;
+ } catch (RuntimeException ex) {
+ out[i] = new Measurement(ce.cls(), -1L, false, true,
+ edit.offset(), edit.oldLen(), edit.newText().length(), "", 0);
+ }
+ }
+ return out;
+ }
+
+ private static void warmJit(IncrementalParser parser, String fixtureSource) {
+ var s = parser.initialize(fixtureSource, fixtureSource.length() / 2);
+ for (int i = 0; i < 20; i++) {
+ int off = (i * 37 + 100) % s.text().length();
+ try {
+ s = s.moveCursor(off).edit(new Edit(off, 0, "x"));
+ } catch (RuntimeException ignored) {
+ // warm-up best-effort
+ }
+ }
+ }
+
+ // -------- Edit plan generation ---------------------------------------------------------
+
+ /**
+ * Generate a deterministic edit plan against the initial fixture text.
+ * Edit positions cluster near the previous edit (70% within ±100 chars,
+ * 25% within ±2000, 5% uniform). Distribution mirrors
+ * {@code IncrementalParityTest.randomEdit} for shape-class proportions.
+ */
+ private static List generateEditPlan(String initialText, int n, long seed) {
+ var rng = new Random(seed);
+ var plan = new ArrayList(n);
+ // We don't replay the buffer here — the plan is offset-relative and the
+ // runner clamps at replay time. Use the initial length as the reference
+ // for clustering and clamping.
+ int textLen = initialText.length();
+ int prevOffset = textLen / 2;
+ for (int i = 0; i < n; i++) {
+ int off = clusteredOffset(rng, prevOffset, textLen);
+ var ce = drawEdit(rng, off, textLen);
+ plan.add(ce);
+ prevOffset = off;
+ }
+ return plan;
+ }
+
+ private static int clusteredOffset(Random rng, int prev, int textLen) {
+ int roll = rng.nextInt(100);
+ int radius;
+ if (roll < 70) {
+ radius = 100;
+ } else if (roll < 95) {
+ radius = 2000;
+ } else {
+ return rng.nextInt(textLen + 1);
+ }
+ int low = Math.max(0, prev - radius);
+ int high = Math.min(textLen, prev + radius);
+ if (high <= low) {
+ return low;
+ }
+ return low + rng.nextInt(high - low + 1);
+ }
+
+ private static ClassifiedEdit drawEdit(Random rng, int off, int textLen) {
+ int roll = rng.nextInt(100);
+ if (roll < 40) {
+ // 40% single-char insert
+ int safeOff = Math.min(off, textLen);
+ return new ClassifiedEdit(new Edit(safeOff, 0, String.valueOf(randomAlnum(rng))), EditClass.SINGLE_CHAR);
+ }
+ if (roll < 60) {
+ // 20% single-char delete
+ int safeOff = clampOffsetForDelete(off, textLen, 1);
+ return new ClassifiedEdit(new Edit(safeOff, 1, ""), EditClass.SINGLE_CHAR);
+ }
+ if (roll < 75) {
+ // 15% word insert
+ int safeOff = Math.min(off, textLen);
+ return new ClassifiedEdit(new Edit(safeOff, 0, randomWord(rng)), EditClass.WORD);
+ }
+ if (roll < 85) {
+ // 10% word delete
+ int len = 1 + rng.nextInt(6);
+ int safeOff = clampOffsetForDelete(off, textLen, len);
+ int safeLen = Math.min(len, Math.max(0, textLen - safeOff));
+ return new ClassifiedEdit(new Edit(safeOff, safeLen, ""), EditClass.WORD);
+ }
+ if (roll < 95) {
+ // 10% line paste / line delete
+ if (rng.nextBoolean()) {
+ int len = 20 + rng.nextInt(60);
+ int safeOff = clampOffsetForDelete(off, textLen, len);
+ int safeLen = Math.min(len, Math.max(0, textLen - safeOff));
+ return new ClassifiedEdit(new Edit(safeOff, safeLen, ""), EditClass.LINE);
+ }
+ int safeOff = Math.min(off, textLen);
+ return new ClassifiedEdit(new Edit(safeOff, 0, randomLine(rng)), EditClass.LINE);
+ }
+ // 5% block paste / delete
+ int len = 80 + rng.nextInt(120);
+ if (rng.nextBoolean()) {
+ int safeOff = clampOffsetForDelete(off, textLen, len);
+ int safeLen = Math.min(len, Math.max(0, textLen - safeOff));
+ return new ClassifiedEdit(new Edit(safeOff, safeLen, ""), EditClass.BLOCK);
+ }
+ var block = new StringBuilder();
+ int lines = 2 + rng.nextInt(4);
+ for (int i = 0; i < lines; i++) {
+ block.append(randomLine(rng)).append('\n');
+ }
+ int safeOff = Math.min(off, textLen);
+ return new ClassifiedEdit(new Edit(safeOff, 0, block.toString()), EditClass.BLOCK);
+ }
+
+ private static int clampOffsetForDelete(int off, int textLen, int oldLen) {
+ if (textLen <= 0) {
+ return 0;
+ }
+ return Math.min(off, Math.max(0, textLen - oldLen));
+ }
+
+ private static final String ALNUM = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_ ";
+
+ private static char randomAlnum(Random rng) {
+ return ALNUM.charAt(rng.nextInt(ALNUM.length()));
+ }
+
+ private static final String[] WORDS = {
+ "class", "interface", "enum", "void", "int", "String", "var", "public",
+ "private", "static", "final", "return", "new", "true", "false", "null",
+ "if", "else", "while", "for", "foo", "bar", "baz", "x", "y", "z",
+ "value", "name", "list", "map"
+ };
+
+ private static String randomWord(Random rng) {
+ return WORDS[rng.nextInt(WORDS.length)];
+ }
+
+ private static String randomLine(Random rng) {
+ return "var " + randomWord(rng) + " = " + randomWord(rng) + "; ";
+ }
+
+ // -------- Reporting --------------------------------------------------------------------
+
+ private static void printReport(String fixtureSource, Measurement[] regimeB, Measurement[] regimeA) {
+ System.out.println("=== IncrementalSessionBench ===");
+ int loc = countLines(fixtureSource);
+ System.out.printf("Fixture: large/FactoryClassGenerator.java.txt (%d LOC, %d chars)%n",
+ loc, fixtureSource.length());
+ System.out.printf("Edits: %d (RNG seed 0x%X)%n", EDIT_COUNT, RNG_SEED);
+ System.out.println();
+
+ System.out.println("REGIME B (cursor-moved-to-edit)");
+ printRegime(regimeB);
+ System.out.println();
+ System.out.println("REGIME A (cursor-stays-at-zero, pessimistic control)");
+ printRegime(regimeA);
+ System.out.println();
+ System.out.println("DELTA (B vs A on the same edit sequence)");
+ printDelta(regimeB, regimeA);
+ System.out.println();
+ System.out.println("TOP 10 OUTLIERS (Regime B, by latency desc)");
+ printOutliers(regimeB);
+ System.out.println();
+ System.out.println("TOP 10 OUTLIERS (Regime A, by latency desc)");
+ printOutliers(regimeA);
+ }
+
+ private static void printOutliers(Measurement[] ms) {
+ var nonSkipped = new ArrayList();
+ for (var m : ms) {
+ if (!m.skipped() && m.latencyNs() >= 0) {
+ nonSkipped.add(m);
+ }
+ }
+ nonSkipped.sort((a, b) -> Long.compare(b.latencyNs(), a.latencyNs()));
+ System.out.printf(" %-5s %-9s %-12s %-9s %-7s %-7s %-22s %-9s %s%n",
+ "rank", "latency", "class", "offset", "oldLen", "newLen", "pivotRule", "pivotNodes", "fallback");
+ int n = Math.min(10, nonSkipped.size());
+ for (int i = 0; i < n; i++) {
+ var m = nonSkipped.get(i);
+ String pivot = m.pivotRule() == null || m.pivotRule().isEmpty() ? "-" : m.pivotRule();
+ if (pivot.length() > 22) {
+ pivot = pivot.substring(0, 21) + "…";
+ }
+ System.out.printf(" %-5d %-9s %-12s %-9d %-7d %-7d %-22s %-9d %s%n",
+ i + 1,
+ fmtMs(m.latencyNs()),
+ m.cls().label(),
+ m.editOffset(),
+ m.editOldLen(),
+ m.editNewLen(),
+ pivot,
+ m.pivotNodeCount(),
+ m.wasFallback());
+ }
+ }
+
+ private static void printRegime(Measurement[] ms) {
+ System.out.printf(" %-15s %-7s %-8s %-8s %-8s %-8s %s%n",
+ "Class", "Count", "Median", "p95", "p99", "Max", "Fallbacks");
+ for (var cls : EditClass.values()) {
+ printClassRow(cls.label(), filterByClass(ms, cls));
+ }
+ printClassRow("ALL", filterAll(ms));
+
+ // Cold vs warm split (across all classes, all non-skipped edits)
+ long[] cold = sliceLatencies(ms, 0, COLD_PREFIX);
+ long[] warm = sliceLatencies(ms, COLD_PREFIX, ms.length);
+ long underBudget = countUnderBudget(ms);
+ long total = countNonSkipped(ms);
+ double underPct = total == 0 ? 0.0 : (100.0 * underBudget / total);
+
+ System.out.println();
+ System.out.printf(" Cold (first %d edits) median: %s%n", COLD_PREFIX, fmtMs(median(cold)));
+ System.out.printf(" Warm (edits %d-%d) median: %s%n", COLD_PREFIX + 1, ms.length, fmtMs(median(warm)));
+ System.out.printf(" %% under %.0f ms (frame budget): %.1f%%%n", FRAME_BUDGET_MS, underPct);
+ }
+
+ private static void printClassRow(String label, Measurement[] ms) {
+ long[] latencies = sliceLatencies(ms, 0, ms.length);
+ long fallbacks = countFallbacks(ms);
+ long count = latencies.length;
+ if (count == 0) {
+ System.out.printf(" %-15s %-7d %-8s %-8s %-8s %-8s %d%n",
+ label, 0, "-", "-", "-", "-", fallbacks);
+ return;
+ }
+ System.out.printf(" %-15s %-7d %-8s %-8s %-8s %-8s %d%n",
+ label,
+ count,
+ fmtMs(median(latencies)),
+ fmtMs(percentile(latencies, 95)),
+ fmtMs(percentile(latencies, 99)),
+ fmtMs(max(latencies)),
+ fallbacks);
+ }
+
+ private static void printDelta(Measurement[] regimeB, Measurement[] regimeA) {
+ System.out.printf(" %-15s %-12s %-12s %s%n", "Class", "B median", "A median", "B/A ratio");
+ for (var cls : EditClass.values()) {
+ printDeltaRow(cls.label(), filterByClass(regimeB, cls), filterByClass(regimeA, cls));
+ }
+ printDeltaRow("ALL", filterAll(regimeB), filterAll(regimeA));
+ }
+
+ private static void printDeltaRow(String label, Measurement[] b, Measurement[] a) {
+ long[] bl = sliceLatencies(b, 0, b.length);
+ long[] al = sliceLatencies(a, 0, a.length);
+ if (bl.length == 0 || al.length == 0) {
+ System.out.printf(" %-15s %-12s %-12s %s%n", label, "-", "-", "-");
+ return;
+ }
+ double bMs = ns2ms(median(bl));
+ double aMs = ns2ms(median(al));
+ String ratio = aMs <= 0 ? "-" : String.format("%.2fx", bMs / aMs);
+ System.out.printf(" %-15s %-12s %-12s %s%n",
+ label, fmtMs(median(bl)), fmtMs(median(al)), ratio);
+ }
+
+ // -------- Helpers ----------------------------------------------------------------------
+
+ private static Measurement[] filterByClass(Measurement[] ms, EditClass cls) {
+ var out = new ArrayList();
+ for (var m : ms) {
+ if (m.cls() == cls) {
+ out.add(m);
+ }
+ }
+ return out.toArray(new Measurement[0]);
+ }
+
+ private static Measurement[] filterAll(Measurement[] ms) {
+ return ms;
+ }
+
+ private static long[] sliceLatencies(Measurement[] ms, int from, int toExclusive) {
+ int hi = Math.min(toExclusive, ms.length);
+ var tmp = new long[Math.max(0, hi - from)];
+ int n = 0;
+ for (int i = from; i < hi; i++) {
+ if (!ms[i].skipped() && ms[i].latencyNs() >= 0) {
+ tmp[n++] = ms[i].latencyNs();
+ }
+ }
+ return Arrays.copyOf(tmp, n);
+ }
+
+ private static long countNonSkipped(Measurement[] ms) {
+ long c = 0;
+ for (var m : ms) {
+ if (!m.skipped()) {
+ c++;
+ }
+ }
+ return c;
+ }
+
+ private static long countFallbacks(Measurement[] ms) {
+ long c = 0;
+ for (var m : ms) {
+ if (!m.skipped() && m.wasFallback()) {
+ c++;
+ }
+ }
+ return c;
+ }
+
+ private static long countUnderBudget(Measurement[] ms) {
+ long budgetNs = (long) (FRAME_BUDGET_MS * 1_000_000.0);
+ long c = 0;
+ for (var m : ms) {
+ if (!m.skipped() && m.latencyNs() >= 0 && m.latencyNs() < budgetNs) {
+ c++;
+ }
+ }
+ return c;
+ }
+
+ private static long median(long[] sortedOrUnsorted) {
+ return percentile(sortedOrUnsorted, 50);
+ }
+
+ private static long percentile(long[] values, int pct) {
+ if (values.length == 0) {
+ return 0L;
+ }
+ var sorted = values.clone();
+ Arrays.sort(sorted);
+ int idx = (int) Math.ceil((pct / 100.0) * sorted.length) - 1;
+ if (idx < 0) {
+ idx = 0;
+ }
+ if (idx >= sorted.length) {
+ idx = sorted.length - 1;
+ }
+ return sorted[idx];
+ }
+
+ private static long max(long[] values) {
+ long m = 0;
+ for (long v : values) {
+ if (v > m) {
+ m = v;
+ }
+ }
+ return m;
+ }
+
+ private static double ns2ms(long ns) {
+ return ns / 1_000_000.0;
+ }
+
+ private static String fmtMs(long ns) {
+ return String.format("%.1f", ns2ms(ns));
+ }
+
+ private static int countLines(String s) {
+ int n = 1;
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == '\n') {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ private static String loadResource(String resourcePath) throws Exception {
+ try (var in = IncrementalSessionBench.class.getResourceAsStream(resourcePath)) {
+ if (in == null) {
+ throw new IllegalStateException("Resource not found on classpath: " + resourcePath);
+ }
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java
index 1b2df21..2b9422b 100644
--- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java
+++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java
@@ -35,8 +35,8 @@ public static long cstHash(CstNode node) {
long h = 1125899906842597L; // large prime seed
h = 31 * h + node.getClass().getName().hashCode();
h = 31 * h + safeHash(node.rule());
- h = 31 * h + node.span().start().offset();
- h = 31 * h + node.span().end().offset();
+ h = 31 * h + node.span().startOffset();
+ h = 31 * h + node.span().endOffset();
switch (node) {
case CstNode.Terminal t -> h = 31 * h + safeHash(t.text());
case CstNode.Token t -> h = 31 * h + safeHash(t.text());
diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java
index 5688315..34390db 100644
--- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java
+++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java
@@ -44,11 +44,22 @@ private NodeIndex(CstNode root, Map parents) {
* Build a fresh index over {@code root}. O(n) in the node count.
*/
public static NodeIndex build(CstNode root) {
- var parents = new IdentityHashMap();
+ int expectedSize = countDescendants(root);
+ var parents = new IdentityHashMap(expectedSize);
indexChildren(root, parents);
return new NodeIndex(root, parents);
}
+ private static int countDescendants(CstNode node) {
+ int count = 0;
+ if (node instanceof CstNode.NonTerminal nt) {
+ for (var child : nt.children()) {
+ count += 1 + countDescendants(child);
+ }
+ }
+ return count;
+ }
+
private static void indexChildren(CstNode node, Map parents) {
if (node instanceof CstNode.NonTerminal nt) {
for (var child : nt.children()) {
@@ -148,9 +159,7 @@ private static CstNode descendTo(CstNode node, int offset) {
*/
public static boolean contains(CstNode node, int offset) {
var span = node.span();
- return offset >= span.start()
- .offset() && offset <= span.end()
- .offset();
+ return offset >= span.startOffset() && offset <= span.endOffset();
}
/**
diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java
index dbd59c3..05afcb3 100644
--- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java
+++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java
@@ -69,7 +69,7 @@ public static CstNode spliceAndShift(Deque path,
for (var child : nt.children()) {
if (child == next) {
newChildren.add(replacement);
- } else if (child.span().start().offset() >= editEnd) {
+ } else if (child.span().startOffset() >= editEnd) {
newChildren.add(shiftAll(child, delta));
} else {
newChildren.add(child);
@@ -156,7 +156,7 @@ private static List shiftTrivia(List trivia, int delta, int edit
var out = new ArrayList(trivia.size());
boolean anyShift = false;
for (var t : trivia) {
- if (t.span().start().offset() >= editEnd) {
+ if (t.span().startOffset() >= editEnd) {
out.add(shiftTriviaOne(t, delta));
anyShift = true;
} else {
diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java
index da671ee..4d86bb1 100644
--- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java
+++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java
@@ -132,8 +132,8 @@ private static TriviaMatch findContainingTriviaInList(CstNode owner,
}
for (int i = 0; i < trivia.size(); i++) {
var t = trivia.get(i);
- int a = t.span().start().offset();
- int b = t.span().end().offset();
+ int a = t.span().startOffset();
+ int b = t.span().endOffset();
if (a <= editStart && editEnd <= b) {
return new TriviaMatch(owner, kind, i, t);
}
@@ -146,8 +146,8 @@ private static TriviaMatch findContainingTriviaInChildren(CstNode node, int edit
return null;
}
for (var child : nt.children()) {
- int a = child.span().start().offset();
- int b = child.span().end().offset();
+ int a = child.span().startOffset();
+ int b = child.span().endOffset();
// Only descend into children whose own span (plus trivia margins) could contain the edit.
// Leading trivia of a child sits before child.span().start(); trailing trivia (if any)
// sits after child.span().end(). Conservative: check the child if any trivia or the
@@ -171,17 +171,17 @@ private static TriviaMatch findContainingTriviaInChildren(CstNode node, int edit
*/
private static boolean couldContain(CstNode child, int editStart, int editEnd) {
for (var t : nullToEmpty(child.leadingTrivia())) {
- if (t.span().start().offset() <= editStart && editEnd <= t.span().end().offset()) {
+ if (t.span().startOffset() <= editStart && editEnd <= t.span().endOffset()) {
return true;
}
}
for (var t : nullToEmpty(child.trailingTrivia())) {
- if (t.span().start().offset() <= editStart && editEnd <= t.span().end().offset()) {
+ if (t.span().startOffset() <= editStart && editEnd <= t.span().endOffset()) {
return true;
}
}
// Child's own span containing the edit: descend in case of nested trivia attachments.
- return child.span().start().offset() <= editStart && editEnd <= child.span().end().offset();
+ return child.span().startOffset() <= editStart && editEnd <= child.span().endOffset();
}
private static List nullToEmpty(List trivia) {
@@ -237,14 +237,14 @@ private static boolean isPureDeletionSafe(Trivia trivia, Edit edit) {
}
private static boolean isLineCommentBoundaryUntouched(Trivia.LineComment lc, Edit edit) {
- int start = lc.span().start().offset();
+ int start = lc.span().startOffset();
// Don't allow deleting the leading "//" prefix (offsets [start, start+2]).
return edit.offset() >= start + 2;
}
private static boolean isBlockCommentBoundaryUntouched(Trivia.BlockComment bc, Edit edit) {
- int start = bc.span().start().offset();
- int end = bc.span().end().offset();
+ int start = bc.span().startOffset();
+ int end = bc.span().endOffset();
// Protect leading "/*" (offsets [start, start+2]) and trailing "*/" (offsets [end-2, end]).
return edit.offset() >= start + 2 && edit.offset() + edit.oldLen() <= end - 2;
}
@@ -341,7 +341,7 @@ private static CstNode rebuildNonTerminalChildren(CstNode.NonTerminal nt,
// then the unaffected case.
if (childContainsMatch(child, match)) {
newChildren.add(rewriteNode(child, match, newText, edit));
- } else if (child.span().start().offset() >= editEnd) {
+ } else if (child.span().startOffset() >= editEnd) {
newChildren.add(shiftAllOffsets(child, delta));
} else {
newChildren.add(child);
@@ -419,7 +419,7 @@ private static CstNode rebuildOwnerNonTerminal(CstNode.NonTerminal nt,
// Children of the owner: shift those at-or-after editEnd; leave others alone.
var newChildren = new ArrayList(nt.children().size());
for (var child : nt.children()) {
- if (child.span().start().offset() >= editEnd) {
+ if (child.span().startOffset() >= editEnd) {
newChildren.add(shiftAllOffsets(child, delta));
} else {
newChildren.add(child);
@@ -454,7 +454,7 @@ private static List rewriteTriviaList(List trivia, int index, St
}
private static Trivia rewriteSingleTrivia(Trivia trivia, String newText, Edit edit) {
- int triviaStart = trivia.span().start().offset();
+ int triviaStart = trivia.span().startOffset();
int relStart = edit.offset() - triviaStart;
int relEnd = relStart + edit.oldLen();
var oldText = trivia.text();
@@ -462,9 +462,9 @@ private static Trivia rewriteSingleTrivia(Trivia trivia, String newText, Edit ed
var newSpan = SourceSpan.sourceSpan(
trivia.span().start(),
new SourceLocation(
- trivia.span().end().line(),
- trivia.span().end().column(),
- trivia.span().end().offset() + edit.delta()));
+ trivia.span().endLine(),
+ trivia.span().endColumn(),
+ trivia.span().endOffset() + edit.delta()));
return switch (trivia) {
case Trivia.Whitespace _ -> new Trivia.Whitespace(newSpan, rebuilt);
case Trivia.LineComment _ -> new Trivia.LineComment(newSpan, rebuilt);
@@ -478,8 +478,8 @@ private static SourceSpan shiftSpanForEdit(SourceSpan span, int delta, int editE
if (delta == 0) {
return span;
}
- var start = span.start().offset() >= editEnd ? shiftLoc(span.start(), delta) : span.start();
- var end = span.end().offset() >= editEnd ? shiftLoc(span.end(), delta) : span.end();
+ var start = span.startOffset() >= editEnd ? shiftLoc(span.start(), delta) : span.start();
+ var end = span.endOffset() >= editEnd ? shiftLoc(span.end(), delta) : span.end();
return SourceSpan.sourceSpan(start, end);
}
@@ -490,7 +490,7 @@ private static List shiftTriviaListAfter(List trivia, int delta,
boolean any = false;
var out = new ArrayList(trivia.size());
for (var t : trivia) {
- if (t.span().start().offset() >= editEnd) {
+ if (t.span().startOffset() >= editEnd) {
out.add(shiftTrivia(t, delta));
any = true;
} else {
diff --git a/peglib-maven-plugin/pom.xml b/peglib-maven-plugin/pom.xml
index e5ddce0..0944381 100644
--- a/peglib-maven-plugin/pom.xml
+++ b/peglib-maven-plugin/pom.xml
@@ -7,7 +7,7 @@
org.pragmatica-lite
peglib-parent
- 0.4.2
+ 0.4.3
../pom.xml
diff --git a/peglib-playground/pom.xml b/peglib-playground/pom.xml
index 553752b..a30605d 100644
--- a/peglib-playground/pom.xml
+++ b/peglib-playground/pom.xml
@@ -7,7 +7,7 @@
org.pragmatica-lite
peglib-parent
- 0.4.2
+ 0.4.3
../pom.xml
diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java
index e13e80c..5867431 100644
--- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java
+++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java
@@ -130,7 +130,7 @@ private final class WalkState {
void visit(CstNode node) {
nodes++;
trivia += node.leadingTrivia().size() + node.trailingTrivia().size();
- int offset = node.span().start().offset();
+ int offset = node.span().startOffset();
switch (node) {
case CstNode.NonTerminal nt -> {
recordRuleEnter(nt.rule(), offset);
diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java
index 1de2cc7..5d500ff 100644
--- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java
+++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java
@@ -84,10 +84,10 @@ private static void writeNode(StringBuilder sb, CstNode node) {
writeString(sb, nodeKind(node));
sb.append(",\"rule\":");
writeString(sb, node.rule());
- sb.append(",\"start\":").append(node.span().start().offset());
- sb.append(",\"end\":").append(node.span().end().offset());
- sb.append(",\"line\":").append(node.span().start().line());
- sb.append(",\"column\":").append(node.span().start().column());
+ sb.append(",\"start\":").append(node.span().startOffset());
+ sb.append(",\"end\":").append(node.span().endOffset());
+ sb.append(",\"line\":").append(node.span().startLine());
+ sb.append(",\"column\":").append(node.span().startColumn());
if (!node.leadingTrivia().isEmpty()) {
sb.append(",\"leadingTrivia\":");
writeTriviaList(sb, node.leadingTrivia());
@@ -153,8 +153,8 @@ private static void writeTrivia(StringBuilder sb, Trivia trivia) {
sb.append('{');
sb.append("\"kind\":");
writeString(sb, ParseTracer.triviaKind(trivia));
- sb.append(",\"start\":").append(trivia.span().start().offset());
- sb.append(",\"end\":").append(trivia.span().end().offset());
+ sb.append(",\"start\":").append(trivia.span().startOffset());
+ sb.append(",\"end\":").append(trivia.span().endOffset());
sb.append(",\"text\":");
writeString(sb, trivia.text());
sb.append('}');
@@ -166,10 +166,10 @@ private static void writeDiagnostic(StringBuilder sb, Diagnostic diag) {
writeString(sb, diag.severity().display());
sb.append(",\"message\":");
writeString(sb, diag.message());
- sb.append(",\"line\":").append(diag.span().start().line());
- sb.append(",\"column\":").append(diag.span().start().column());
- sb.append(",\"start\":").append(diag.span().start().offset());
- sb.append(",\"end\":").append(diag.span().end().offset());
+ sb.append(",\"line\":").append(diag.span().startLine());
+ sb.append(",\"column\":").append(diag.span().startColumn());
+ sb.append(",\"start\":").append(diag.span().startOffset());
+ sb.append(",\"end\":").append(diag.span().endOffset());
if (diag.tag().isPresent()) {
sb.append(",\"tag\":");
writeString(sb, diag.tag().unwrap());
diff --git a/pom.xml b/pom.xml
index 43e9692..3eab7a7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.pragmatica-lite
peglib-parent
- 0.4.2
+ 0.4.3
pom
Peglib Parent