diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ae16e..b8932a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.3] - 2026-05-06 + +Performance — interactive editing focus. 19% faster median, 26% faster p95 on the IncrementalBenchmark editing-session suite. **One breaking change**: SourceSpan record components changed from two SourceLocations to six ints (see migration note). + +### Performance (interactive editing on 1,900-line Java fixture) + +| Metric | 0.4.2 | 0.4.3 | Change | +|---|---:|---:|---:| +| Median | 13.3 ms | 10.8 ms | -19% | +| p95 | 30.1 ms | 22.4 ms | -26% | +| p99 | 56.4 ms | 53.3 ms | -5% | +| % under 16ms (frame budget) | 83% | 91.5% | +8.5pp | + +### Changed (BREAKING) + +- **`SourceSpan` stores int triples instead of `SourceLocation` refs.** Component accessors changed from `start()`/`end()` (returning SourceLocation refs) to `startLine()`/`startColumn()`/`startOffset()`/`endLine()`/`endColumn()`/`endOffset()` (auto-generated record component accessors returning ints). The methods `start()` and `end()` are preserved as regular methods that lazily materialize SourceLocation. The factory `SourceSpan.sourceSpan(SourceLocation, SourceLocation)` is preserved. **Migration:** code that pattern-matches `case SourceSpan(SourceLocation s, SourceLocation e)` must be updated to either `case SourceSpan span` + accessor calls, or `case SourceSpan(int sl, int sc, int so, int el, int ec, int eo)`. Code calling `.span().start().line()` should migrate to `.span().startLine()` to skip the SourceLocation materialization. Drove the 26% p95 improvement. + +### Performance — internals + +- **`PegEngine` interpreter:** no API change. Continued from 0.4.1. +- **`NodeIndex.build` pre-sizes the IdentityHashMap** from a descendant-count pass before populating, eliminating resize churn that was 22.8% of bench allocations. (Commit `fcff78f`) +- **New `IncrementalSessionBench`** in `peglib-incremental/src/jmh/java`: realistic 1,000-edit interactive sessions with cursor-moved-to-edit (Regime B) AND cursor-pinned (Regime A) regimes. Reports per-class median/p95/p99/max + frame-budget hit rate + top-10 outlier diagnostics. Recommended JVM tuning for downstream consumers in `peglib-incremental/README.md`: `-Xms2g -Xmx2g -XX:MaxGCPauseMillis=20` reduces p99 by 33% (60ms → 40ms) — free for downstream. (Commits `716a113`, `77c7c70`) + +### Notes for downstream + +- **Regenerate generated parsers** with 0.4.3's `ParserGenerator` to pick up the gen-time SourceSpan emission update (also int-based). +- **Architectural rework planned for 0.5.0.** See `docs/incremental/ARCHITECTURE-0.5.0.md` (when published) for the planned overhaul targeting incremental-native data structures. + ## [0.4.2] - 2026-05-04 Standalone-parser fix. No API changes for the interpreter / incremental / formatter paths. diff --git a/README.md b/README.md index dccd4ce..74b0946 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Quick links: org.pragmatica-lite peglib - 0.4.2 + 0.4.3 ``` @@ -394,7 +394,7 @@ The `peglib-maven-plugin` module (separate artifact, sibling to `peglib`) wraps org.pragmatica-lite peglib-maven-plugin - 0.4.2 + 0.4.3 diff --git a/peglib-core/pom.xml b/peglib-core/pom.xml index 12e61ea..7c063b4 100644 --- a/peglib-core/pom.xml +++ b/peglib-core/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.2 + 0.4.3 ../pom.xml diff --git a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java index 773755a..de107f5 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java @@ -1660,13 +1660,40 @@ public static SourceLocation sourceLocation(int line, int column, int offset) { @Override public String toString() { return line + ":" + column; } } - 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 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 endOffset - startOffset; } + public String extract(String source) { return source.substring(startOffset, endOffset); } + public SourceSpan merge(SourceSpan other) { + int nsl, nsc, nso, nel, nec, neo; + if (startOffset <= other.startOffset) { + nsl = startLine; nsc = startColumn; nso = startOffset; + } else { + nsl = other.startLine; nsc = other.startColumn; nso = other.startOffset; + } + if (endOffset >= other.endOffset) { + nel = endLine; nec = endColumn; neo = endOffset; + } else { + nel = other.endLine; nec = other.endColumn; neo = other.endOffset; + } + return new SourceSpan(nsl, nsc, nso, nel, nec, neo); + } + @Override public String toString() { + return startLine + ":" + startColumn + "-" + endLine + ":" + endColumn; } - public int length() { return end.offset() - start.offset(); } - public String extract(String source) { return source.substring(start.offset(), end.offset()); } - @Override public String toString() { return start + "-" + end; } } public sealed interface Trivia { @@ -1822,11 +1849,11 @@ public String format(String source, String filename) { sb.append(loc.line()).append(":").append(loc.column()).append("\\n"); // Find all lines we need to display - int minLine = span.start().line(); - int maxLine = span.end().line(); + int minLine = span.startLine(); + int maxLine = span.endLine(); for (var label : labels) { - minLine = Math.min(minLine, label.span().start().line()); - maxLine = Math.max(maxLine, label.span().end().line()); + minLine = Math.min(minLine, label.span().startLine()); + maxLine = Math.max(maxLine, label.span().endLine()); } // Calculate gutter width @@ -1867,13 +1894,13 @@ public String format(String source, String filename) { private List getLabelsOnLine(int lineNum) { var result = new ArrayList(); - if (span.start().line() <= lineNum && span.end().line() >= lineNum) { + if (span.startLine() <= lineNum && span.endLine() >= lineNum) { if (labels.isEmpty()) { result.add(DiagnosticLabel.primary(span, "")); } } for (var label : labels) { - if (label.span().start().line() <= lineNum && label.span().end().line() >= lineNum) { + if (label.span().startLine() <= lineNum && label.span().endLine() >= lineNum) { result.add(label); } } @@ -1885,13 +1912,13 @@ private String formatUnderlines(int lineNum, String lineContent, List 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