diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4ffb45..72bf3b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,46 @@ 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.5.1] - 2026-05-09
+
+_Unreleased — patch cycle following 0.5.0._
+
+### Added
+
+- **Trivia investigation arc — Steps 1-3** (2026-05-09): catalog of context-dependencies in current attribution, 17-test adversarial corpus targeting 7 divergence categories, post-pass prototype validating context-independent attribution. Round-trip preservation confirmed on 21/21 corpus + 9/9 adversarial inputs; 0 text loss across 46,756 nodes; `parseRuleAt` structural parity achievable under post-pass (load-bearing claim for incremental engine Lever B). Findings: [`docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md`](docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md). Verdict in HANDOVER §11.
+- **Trivia rework Step 4 commits 1-4** (production rework, foundational): opt-in `triviaPostPass` flag on `ParserConfig` (default `false` — zero behavior change). When enabled: post-parse hook applies `TriviaPostPass.assignTrivia(input, cst, grammar)` overriding buffer-driven attribution with context-independent re-derivation from `(input, span)` coordinates. `parseRuleAt` honors splice offset (4-arg overload `assignTrivia(input, cst, grammar, leadingScanFrom)`) — body subtree is structurally identical to corresponding subtree of full reparse, removing one of two HANDOVER §6.4 blockers for incremental engine Lever B. Generator-emitted parsers embed an inline post-pass equivalent (preserves standalone-parser invariant).
+- **`org.pragmatica.peg.tree.TriviaPostPass`** (new public API): pure function `(input, cst, grammar) → CstNode` that re-derives leading/trailing trivia from coordinates without parse-history dependency. 4-arg overload supports splice scenarios.
+
+### Changed
+
+- `ParsingContext` pending-trivia methods (`appendPendingLeadingTrivia`, `takePendingLeadingTrivia`, `savePendingLeadingTrivia`, `restorePendingLeadingTrivia`) early-out when `config.triviaPostPass()=true` — buffer becomes dead weight under the flag and is skipped (38 call sites in `PegEngine` no-op without source changes).
+- **Trivia rework Step 4 commit 5** (`4ed1cf5`): `ParserGenerator` emits no-op buffer methods under flag-ON. Mirror of commit 3 for the generator side — pure CPU optimization; correctness already from commit 4. Generated parsers under flag-ON no longer do dead-work buffer maintenance.
+
+### Fixed
+
+- **Trivia post-pass orphan-trivia placement matches engine's Bug C' compensation** (`612fbea`). Earlier commit 6 attempt revealed text-loss on Java 25 corpus (`void m()` → `voidm()`). Root cause: post-pass attached orphan trivia to wrapper.trailingTrivia, but `CstReconstruct.emit` only emits trailing for last-child of a wrapper — non-last-wrapper-trailing was invisible during reconstruction. Fix mirrors engine's `attachTrailingToTail` — drains orphan trivia into deepest-rightmost-leaf descendant. Plus a generator-semantics adapter: `rebuildNonTerminal` probe-scans for caller-supplied leading and skips it to prevent license-header double-emission on fixtures whose wrappers' spans include their own leading trivia. RoundTripTest 22/22 under flag-ON across all 22 corpus fixtures including `large/FactoryClassGenerator.java.txt`.
+- **`triviaPostPass` default flipped to `true`** (`3b372af`). After the fix, the default-flip succeeded with 0 test failures. Two sentinel tests in `TriviaPostPassFlagTest` inverted (DefaultOffNoOp → DefaultOnNoOp). Phase1/Phase2 parity tests insulated (explicitly pass `triviaPostPass=false`). The 8 fixture-pinning tests for the previous (buffer-driven) attribution remain green via explicit-OFF construction. **End-state for 0.5.1: post-pass attribution is the default; legacy buffer-driven attribution is opt-out via `new ParserConfig(..., triviaPostPass=false, ...)`.**
+- **Post-pass O(n²) bench regression fixed** (`6675479`). Initial A/B bench after default flip surfaced 22× / 239× wallclock regression on reference / selfhost fixtures. Investigation pinpointed `computeSpan(input, from, to)` re-scanning `[0, from)` from offset 0 on every trivia chunk to count newlines: O(K · N) where K trivia chunks scale with N input chars → O(N²). Empirical exponent reached 1.88 at N=32000. Fix: precompute a line-start table once per `assignTrivia` call (O(N)) + binary search per `computeSpan` (O(log N)). Both runtime `TriviaPostPass.java` and the embedded version emitted by `ParserGenerator.java` updated symmetrically. Post-fix bench: reference 26.87 ms (1.36× legacy 19.78 ms); selfhost 943.6 ms (1.01× legacy 934.7 ms — within noise). 15.6× speedup on reference, 210× on selfhost vs pre-fix.
+
+- **Trivia rework cleanup arc — Cleanups A through F.3** (2026-05-09, commits `763e6e0` through `0b29c78`):
+ - **Cleanup A** (`763e6e0`): strip dead buffer call sites in PegEngine + emitted parsers under flag-ON. 38 PegEngine sites + parallel emit sites short-circuited via `if (!triviaPostPass)` guards; consumers use `List.of()` directly.
+ - **Cleanup B** (`884c4c8`): TriviaPostPass thread orphan trailing through recursion (replaces O(depth) spine rebuild) + probe-scan moved out of hot path. Selfhost flipped from parity to **-5% under legacy**.
+ - **Cleanup D** (`c682eef`): TriviaPostPass skip rebuild when no trivia change needed. Reference -2.6%; selfhost -11.1% (now strictly faster than legacy).
+ - **Cleanup F.1** (`ff596be`): StringSpan runtime foundation. New `org.pragmatica.peg.tree.StringSpan` (CharSequence view with lazy String materialization). CstNode.Terminal/Token components changed from `String text` to `StringSpan textSpan`; `.text()` accessor preserved as String API via `toString()`. 30 new StringSpanTest cases.
+ - **Cleanup F.2** (`2fec82c`): emitted CstParseResult.text widened from String to CharSequence — structural prerequisite for F.3.
+ - **Cleanup F.3** (`0b29c78`): generator emits inline StringSpan + uses it for token boundary capture and match helper text, eliminating per-call substring allocation on the bench's hot path.
+
+ **Final bench numbers (post-F.3, vs legacy buffer-driven path):**
+
+ | Fixture | Buffer-driven | Post-pass (F.3) | Δ |
+ |---|---:|---:|---:|
+ | Reference (1900 LOC) | 19.08 ms / 70.65 MB | 24.88 ms / 77.13 MB | +30% wallclock / +9% alloc |
+ | Selfhost (37k LOC) | 825.6 ms / 1908 MB | **784.7 ms / 1881 MB** | **-5% wallclock / -1.4% alloc** |
+
+ Selfhost — the GC-bound fixture (52% G1 time per profile) — is now meaningfully faster than legacy buffer-driven attribution. Reference fixture remains +30% over legacy (per-NonTerminal scan cost dominates; addressable in future work). The trivia rework + StringSpan arc delivers a real win on the perf-critical workload (selfhost) while preserving correctness across all 22 RoundTripTest fixtures.
+
+### Removed
+
## [0.5.0] - 2026-05-06
_Work in progress — incremental-native architectural rework. See `docs/incremental/ARCHITECTURE-0.5.0.md`._
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 8ce68d6..8177bd0 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -391,7 +391,7 @@ The Move B failure tells us **allocation rate is no longer a productive target**
- **Profile-driven wallclock work** (NEW, highest-ROI suggestion) — `async-profiler` in CPU mode + flame graphs on the reference fixture. Identify the actual hot CPU work post-Tier-1, target that directly. The Move B failure proved alloc-rate metrics can be misleading; CPU samples are the trustworthy signal now. **EXECUTED THIS SESSION — see "Post-rollback profile-driven optimization arc" below for outcomes.**
- **Char-class bit-packing** — pre-emit ASCII bitmaps; bitwise test instead of range comparisons. **Reassess with care** — same risk class as Move B. Bench wallclock first to confirm range-comparison isn't already JIT-optimized via SIMD/code-motion. Potential ~5-15% on char-class-heavy paths IF measurably hot.
- **Lever B retry** (incremental engine) — gated on trivia attribution rework. SafePivotAnalyzer + NodeIndex.smallestEnclosing live as dormant infrastructure for the eventual retry. Independent of allocation patterns; unaffected by Move B finding.
-- **Trivia attribution rework** — context-independent attachment. Unblocks Lever B; comparable scope to Lever C. Independent of allocation; unaffected by Move B finding.
+- **Trivia attribution rework** — context-independent attachment. Unblocks Lever B; comparable scope to Lever C. Independent of allocation; unaffected by Move B finding. **STATUS UPDATED 2026-05-09:** investigated through 3 steps (catalog → adversarial corpus → post-pass prototype) on `release-0.5.1`. Prototype lands at `78c4003`. Verdict: **viable; ~6-10 days to production rework**. See "Trivia investigation arc (2026-05-09)" subsection below.
- **Lever C — IR unification** (spec §4) — multi-week. Eliminates "every fix paid twice" pattern between PegEngine and ParserGenerator emission templates. Reduces 7,440 LOC to ~1,700. Maintainability + complexity reduction primary value, not raw perf.
### Post-rollback profile-driven optimization arc (this session, 2026-05-08)
@@ -455,6 +455,166 @@ Failed patterns target allocations the JIT already optimizes:
- **Lever C IR unification** — multi-week, maintainability-first
- **Re-profile post-`38b6a8e`** — the profile has shifted again; new candidates may emerge or the profile may be flat (no clear next move). Run a third profile pass if pursuing further wallclock work.
+### Trivia investigation arc (2026-05-09, on release-0.5.1)
+
+After 0.5.0 ship + tag move + branch creation for 0.5.1 patch cycle, a 3-step investigation arc explored the long-standing trivia attribution issue.
+
+**Background.** Current attribution couples to parse history via the `pendingLeadingTrivia` buffer + 30+ save/restore sites. RoundTripTest 22/22 green on corpus, but partial-reparse (`parseRuleAt`) attributes trivia differently than full reparse — blocking Lever B (incremental engine smaller-pivot optimization). HANDOVER §6.4 cited this as one of two blockers for Lever B.
+
+**Step 1 — context-dependency catalog.** Investigation-only mapping of attribution code in PegEngine + ParserGenerator. Findings:
+
+- Current attribution is *almost* a function of `(input, rule, span)` — buffer machinery is navigational, not algorithmic
+- Bonus latent issue: generator uses size-only `restorePendingLeading(int)` snapshot (`ParserGenerator.java:6768-6776`) vs interpreter's full-list `List.copyOf` (Bug A fix). Asymmetric. Currently passes RoundTripTest by luck — relies on `%whitespace` re-skip after rollback as load-bearing invariant.
+- Bug C' "orphan trivia → deepest leaf" is bookkeeping-context only; could attach to wrapper.trailing instead with same byte-equality.
+- Cost estimate: 6-10 days for production rework, comparable to one release cycle.
+
+**Step 2 — adversarial test corpus** (`test: trivia adversarial corpus + findings` `d2cc6be`). 17 tests across 7 divergence-target classes + 1 fuzz test. Findings:
+
+- 0 definite bugs
+- 2 latent bugs (generator size-only restore "robust-by-luck"; Optional CutFailure pending non-restore — unobservable through CST)
+- 3 robust-by-design (Bug C' nested cases, Predicate symmetry, %whitespace purity)
+
+Suite at `peglib-core/src/test/java/org/pragmatica/peg/perf/TriviaAdversarialTest.java`. Findings: [`docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md`](incremental/TRIVIA-ADVERSARIAL-FINDINGS.md).
+
+**Step 3 — post-pass prototype** (`feat: TriviaPostPass — context-independent trivia attribution prototype` `78c4003`). New `org.pragmatica.peg.tree.TriviaPostPass` re-derives leading/trailing for every CST node from `(input, span)` without parse-history dependency. Validation: `TriviaPostPassTest` (16 tests, all passing). Findings:
+
+- **Round-trip preservation: 21/21 corpus + 9/9 adversarial inputs byte-equal**
+- **Total trivia text preserved: 0 text loss across 46,756 nodes inspected**
+- Structural divergence is slot-shifts only (6.4% of nodes; all balanced — leading shifts in both directions across NonTerminals; trailing shrinks only on NonTerminals; no Bug-C' shape in this corpus)
+- **`parseRuleAt` structural parity ACHIEVED under postPass** — context-loss disappears. Load-bearing claim for Lever B.
+
+**Verdict.** Production rework is viable. Recommended path for next session:
+
+1. **Decide leading-attachment policy at sibling boundaries** — engine "carries leading forward through pending"; postPass "attaches leading from previous-sibling-end coordinate". Both coherent; pick one and document.
+2. **Add hash-based parity gate against generated parser on the large 1900-LOC fixture** (Step 3 catalog excluded it for runtime).
+3. **Production rework, multi-commit parity-gated** (per Move B lessons): wire `triviaPostPass=true` flag into `PegParser`, replace 30+ buffer save/restore sites with empty-leading emission + post-pass call. ~6-10 days. Each commit green at TriviaPostPassTest + RoundTripTest + adversarial suite.
+
+After production rework: orthogonal Lever B blockers remain (fallback-rule bypass per §6.2 root cause #1, safe-pivot concept). Trivia rework alone removes ONE of two HANDOVER-cited blockers.
+
+### Step 4 production rework — commits 1-4 landed (2026-05-09)
+
+After Step 3 verdict, the production rework arc executed in the same session. Branch `release-0.5.1` advanced from `51144a6` to `dcd146f` (4 commits + 1 docs commit).
+
+| Commit | Description |
+|---|---|
+| `318f5cf` | feat: triviaPostPass flag wires TriviaPostPass as post-parse hook (Step 4 commit 1) |
+| `d7b3496` | feat: TriviaPostPass splice-offset overload for parseRuleAt subtree leading (Step 4 commit 2) |
+| `605327b` | perf: ParsingContext buffer ops no-op under triviaPostPass flag (Step 4 commit 3) |
+| `dcd146f` | feat: ParserGenerator emits embedded TriviaPostPass under flag-ON (Step 4 commit 4) |
+
+**Functional state:** the trivia rework is COMPLETE through commit 4. Under `triviaPostPass=true`:
+
+- **Interpreter** (`PegEngine`): buffer ops no-op via `ParsingContext` early-outs (38 call sites neutralized without source changes); post-pass produces correct attribution. Optimal — no wasted CPU.
+- **Generator** (`ParserGenerator`): generated parsers embed an inline `TriviaPostPass` (preserves standalone-parser invariant — generated parsers depend only on `pragmatica-lite:core`); `parseCst` calls the embedded post-pass after the body parse. Correct attribution; **suboptimal CPU** because buffer machinery still runs and is overwritten (commit 5 territory).
+- **`parseRuleAt`** honors splice offset (4-arg overload `assignTrivia(input, cst, grammar, leadingScanFrom)`); body subtree structurally identical to corresponding subtree of full reparse — Lever B unblocker validated in tests.
+
+**Test coverage:** 768 peglib-core tests + 1 pre-existing skip. `TriviaPostPassFlagTest` (12 tests across 4 nested classes), `TriviaPostPassSpliceOffsetTest` (7 tests), `GeneratedParserTriviaPostPassTest` (8 tests), `TriviaPostPassTest` (16 tests), `TriviaAdversarialTest` (16 enabled). All green under both flag-OFF (default) and flag-ON.
+
+**Default behavior (flag-OFF) unchanged** — every pre-existing test passes byte-for-byte identically. Adoption is opt-in via `PegParser.builder(grammar).triviaPostPass(true).build()` or constructing `ParserConfig` with the flag set.
+
+### Step 4 COMPLETE — all commits 1-7 shipped including default flip
+
+(historical note: commit 6 was attempted, reverted, and successfully retried after commit 7 fixed the underlying bug — see commits 6 → 7 → 6-redo sequence below)
+
+### Step 4 commit 5 landed; commit 6 attempted and reverted (BUG DISCOVERED — later fixed in commit 7)
+
+**Commit 5 (`4ed1cf5`)** — ParserGenerator emits no-op buffer methods under flag-ON. Pure CPU optimization parallel to commit 3 for the interpreter. Tests stay at 768 + 1 skip. Generated parser under flag-ON is now lean (no dead buffer work) AND correct (post-pass produces attribution).
+
+**Commit 6 attempted — REVERTED. Real bug surfaced in post-pass implementation.**
+
+Flipping `triviaPostPass` default to `true` triggered **20 RoundTripTest failures on the Java 25 corpus** — *not* slot-shifts but actual trivia text LOSS:
+
+- `void allCompoundAssignments` → `voidallCompoundAssignments` (lost whitespace between tokens)
+- `int r;` → `intr;`
+- `int b = 0;` → `intb = 0;`
+- Reconstructed lengths 1-2059 chars shorter than originals across 20 of 22 RoundTripTest fixtures (`Annotations.java`, `BlankLines.java`, `ChainAlignment.java`, `ClassLiterals.java`, `Comments.java`, `CompoundAssignments.java`, `Enums.java`, `ExhaustiveSwitchPatterns.java`, `Imports.java`, `KeywordPrefixedIdentifiers.java`, `Lambdas.java`, `LineWrapping.java`, `MultilineArguments.java`, `MultilineParameters.java`, `Records.java`, `SwitchExpressions.java`, `TernaryOperators.java`, `TextBlocks.java`, `large/FactoryClassGenerator.java.txt`, `flow-format-examples/BlankLineRules.java`)
+
+This contradicts the post-pass spec doc claim: *"Total trivia text is preserved (round-trip reconstruction is byte-equal); only the leading/trailing slot in which a given trivia chunk lives can differ."* The bug is REAL TEXT LOSS on production-realistic Java 25 inputs, not just attribution permutation.
+
+**Test coverage gap that masked the bug:**
+- Step 3 prototype's `TriviaPostPassTest` round-trip claim ("21/21 corpus") used the **smaller fixture set under perf-corpus**, EXCLUDING the `/large/` directory which has the realistic 1900-LOC `FactoryClassGenerator.java.txt`.
+- `TriviaAdversarialTest` (16 tests) uses small focused grammars — JSON-like, not full Java 25.
+- `GeneratedParserTriviaPostPassTest` (8 tests) uses 5 small grammars (parity, leading, trailing, comment, unchanged).
+- The full `RoundTripTest` corpus (22 Java 25 fixtures) was never run under flag-ON until commit 6 attempt.
+
+**Bug location:** The generator path is materially affected. `GeneratedJava25Parser.ensureLoaded()` calls `PegParser.generateCstParser(...)` with no explicit config → picks up `ParserConfig.DEFAULT` → flag-flip flips the generator's emission. Whether the interpreter-only path has the same bug is unverified (could be tested by setting flag-ON on Phase1ParityTest variants).
+
+**Next-session work needed (BEFORE default flip can be reattempted):**
+
+1. **Reproduce the bug in isolation** — create a focused test case that fails under flag-ON. Minimal Java 25 sub-fixture; goal is a 5-line input that loses trivia text under flag-ON.
+2. **Diagnose the embedded post-pass implementation** in the generated parser. `ParserGenerator.java`'s `embedded TriviaPostPass` (added in commit 4, ~617 lines) emits an inline post-pass. The bug likely lives there — possibly:
+ - Whitespace re-scan logic in the embedded post-pass mismatches the parser's own `skipWhitespace` semantics
+ - The embedded post-pass's coordinate-walking misses whitespace between tokens (specifically `(prev_node_end, this_node_start)` where the gap is a token-boundary rather than a sibling-boundary)
+ - Cache-hit interaction (Bug B fix is in the parser; the embedded post-pass might re-derive trivia for nodes whose body came from cache and lose context)
+3. **Fix the embedded post-pass** OR extend `TriviaPostPass` (runtime) similarly. Re-run RoundTripTest under flag-ON; should be 22/22.
+4. **Run full coverage under flag-ON** before reattempting commit 6: every existing test class set to flag-ON, measure failures, fix incrementally. NOT a default-flip until the bug is gone.
+5. **Bench impact study** (deferred from original plan — still relevant after bug fix).
+
+**What's still valuable from Steps 1-5:**
+- The flag is functional for SMALL grammars + adversarial inputs (validated)
+- `parseRuleAt` splice-offset overload is correct and unblocks Lever B's trivia-context-loss problem in principle
+- Buffer no-op infrastructure in both interpreter and generator is clean and ready
+- Commits 1-5 are SHIPPABLE; the flag is opt-in only
+
+After bug fix + commit 6, Lever B can be retried: `parseRuleAt` + post-pass produces structurally identical subtrees, removing the trivia-context-loss blocker. The orthogonal fallback-rule-bypass blocker (§6.2 root cause #1) still requires separate work.
+
+### Step 4 commit 7 — bug fix shipped (`612fbea`)
+
+After the diagnosis identified two distinct bugs in the post-pass implementation, commit 7 fixed both:
+
+1. **Bug C' drain compensation** — TriviaPostPass.rebuildNonTerminal now mirrors engine's `attachTrailingToTail`: orphan trivia drains into deepest-rightmost-leaf descendant instead of being placed on the wrapper's trailingTrivia. `CstReconstruct.emit` correctly emits leaf trailings on every walk path; the wrapper-trailing was invisible for non-last-wrapper-children. ~70 lines in `TriviaPostPass.java` runtime + ~30 lines in the embedded version emitted by `ParserGenerator.java`.
+
+2. **Generator-semantics cursor adjustment** — separately discovered: the generated parser's `wrapWithRuleName` produces wrappers whose span INCLUDES their own leading trivia (`startOffset` captured BEFORE `skipWhitespace`). The post-pass's child-walk at first-child time was double-emitting the wrapper's leading bytes via the first child's leading scan. Fix: `rebuildNonTerminal` probe-scans `[spanStart, spanStart+leadingLen)` and, if it matches the caller-supplied leading exactly, advances the cursor past it. Symmetric to existing `rebuildRoot` adapter; only kicks in for generator-semantics CSTs.
+
+Validation: RoundTripTest 22/22 under flag-ON (was 2/22 pre-fix). All 768 peglib-core tests + 1 skip green at flag-OFF default.
+
+**Honest scope note (from agent report):** two bugs were fixed under one commit message. Could have split into 7a/7b, but both touch the same `rebuildNonTerminal` method and same root cause family (post-pass not mirroring engine span/trivia coupling). The cursor-adjustment hunk is cleanly separable in the diff if retrospective splitting is desired.
+
+### Step 4 commit 6 — default flip succeeded (`3b372af`)
+
+After commit 7 fixed the bugs, commit 6 was retried: `ParserConfig.DEFAULT.triviaPostPass` and the `parserConfig(...)` factory both flipped from `false` to `true`. Two sentinel tests in `TriviaPostPassFlagTest` inverted (DefaultOffNoOp → DefaultOnNoOp; assert-false → assert-true).
+
+**Result: 0 test failures, 0 errors, 0 surprises.** No Tier-A/B/C cascade. The 8 `Phase{1,2}*ParityTest` files (which explicitly pass `triviaPostPass=false`) provide the legacy-attribution regression net. Other tests in the project use the default and are bit-for-bit identical under post-pass attribution thanks to the fix.
+
+**End-state for 0.5.1:** post-pass attribution is the default; legacy buffer-driven attribution is opt-out via explicit `new ParserConfig(..., triviaPostPass=false, ...)`. The trivia issue that motivated this multi-session investigation is **closed**: attribution is now context-independent, parseRuleAt produces structurally identical subtrees to full-reparse (Lever B trivia-context-loss blocker is RESOLVED), and the buffer machinery still functions for backward-compat.
+
+**Lever B retry:** the trivia-context-loss blocker is gone. The orthogonal fallback-rule-bypass blocker (§6.2 root cause #1) still requires separate work — that's the next-session entry point if pursuing incremental engine optimization.
+
+### Bench impact study (2026-05-09) — DONE; one bug fixed in flight
+
+After Step 4 default flip, A/B bench (reference + selfhost, JMH 5/3, gc profile) revealed a critical regression:
+
+| Fixture | Buffer-driven | Post-pass (default) | Δ% |
+|---|---:|---:|---:|
+| Reference (1900 LOC) | 19.78 ms | **418 ms** | +2,013% |
+| Selfhost (37k LOC) | 934.7 ms | **198,679 ms** | +21,162% |
+
+Investigation: `computeSpan(input, from, to)` re-scanned `[0, from)` from offset 0 on every trivia chunk → O(K · N) → O(N²). Empirical exponent 1.88 at N=32000. RoundTripTest's correctness gate didn't catch this because tests run to completion without time limits.
+
+**Fix shipped** at `6675479` — line-start table approach. O(N) one-shot precompute + O(log N) binary search per chunk. Both runtime and emitted versions updated.
+
+Post-fix bench:
+
+| Fixture | Buffer-driven | Post-pass (post-fix) | Δ% |
+|---|---:|---:|---:|
+| Reference (1900 LOC) | 19.78 ms | 26.87 ms | +35.8% |
+| Selfhost (37k LOC) | 934.7 ms | 943.6 ms | +0.95% (within noise) |
+
+Reference is 36% slower than legacy; selfhost is at parity. The post-pass overhead is real on small inputs (one tree-walk) but amortizes for large inputs where buffer save/restore dominated. **Acceptable as default**; opt-out remains available via explicit `ParserConfig` constructor.
+
+Allocation rate is essentially flat (within ~16% of legacy on selfhost; line-start table + per-call int[2] tuples cost some bytes but no longer drive wallclock).
+
+**Lessons banked:**
+- Correctness tests should NOT be the only guard against perf regressions. RoundTripTest passed all 22 fixtures under flag-ON before the bench revealed the problem.
+- Bench A/B mandatory before any default-changing commit.
+- The diagnosis approach (read code → identify suspected hotspot → empirical timing harness → confirm with magnitude analysis) generalized well: the smoking gun was found in 30 minutes; the fix was 30 LOC.
+
+### Outstanding work (post-Step-4 + bench-fix)
+
+- **Lever B retry** — fallback-rule-bypass blocker work (orthogonal to trivia, separately scoped). The trivia-context-loss blocker is now fully RESOLVED.
+- **Lever C IR unification** — multi-week, maintainability-first.
+- **Tighten reference fixture overhead** — post-pass is 36% slower on small inputs. If important, address H1 (`attachTrailingToTail` spine rebuild) and/or H4 (probe-scan in rebuildNonTerminal). Probably not worth chasing; selfhost (the actual perf-critical workload) is at parity.
+
### Items superseded by this session's work
- §6.2 lever-1 puzzle: dissolved by Path D's stable-id algorithm.
@@ -466,4 +626,65 @@ Do NOT pursue further allocation reduction in the 0.4.x interpreter — old guid
---
-**Last updated:** 2026-05-08, end of profile-driven optimization arc (post-Move-B rollback). Branch `release-0.5.0` at `38b6a8e` — 2 commits past `v0.5.0-candidate` (`e849b63`). peglib-core 705 tests green (includes 6 smoke tests from the 2 shipped commits — CandidateTwoSmokeTest 3, CandidateFourSmokeTest 3); full reactor green via `mvn install`. Cumulative wins this session: -15.6% reference wallclock / -11.2% selfhost wallclock. Reference now under 20 ms. Move B post-mortem: [`docs/incremental/THROUGHPUT-ENGINE-MOVE-B.md`](incremental/THROUGHPUT-ENGINE-MOVE-B.md) §11. Next session: re-profile post-`38b6a8e` if pursuing further wallclock; otherwise pivot to trivia attribution rework / Lever B retry / Lever C IR unification.
+**Last updated:** 2026-05-09, end of Step 4 trivia rework + cleanup arc A→F.3 + StringSpan + Cleanup G + Lever B retry attempt + skip-postPass deferral.
+
+### Skip postPass for full parses — deferred (2026-05-09)
+
+After Cleanup G abandoned the +30% reference gap as structural, considered the cleaner architectural fix: under flag-ON, run the buffer machinery for full parses (`parseCst`) and run postPass only for `parseRuleAt`. That preserves both the buffer's free-attribution-during-parse property AND the postPass's structural-parity-for-splice property.
+
+**Cost-benefit assessment (deferred for that reason):**
+
+- **Implementation cost:** several days. Cleanup A's call-site short-circuit (38 sites in PegEngine + parallel emit sites) needs to be REVERSED selectively for full-parse path. Generator must emit two parse-paths (full vs partial). Adds a `parseMode` parameter / context flag throughout. Bench-gated to validate buffer path didn't regress since Cleanup A.
+- **Workloads that would benefit:**
+ - IDE plugin uses `parseRuleAt` (incremental), not `parseCst` — **no benefit** (postPass is mandatory for splice parity)
+ - Self-host workload — already at parity / faster than legacy — **no benefit**
+ - One-shot CLI / Maven plugin parsing — 25 ms vs 19 ms is **academic** at sub-frame budget
+- **Workloads that would NOT benefit:** the perf-critical paths.
+
+The +30% reference gap is therefore academic for current workloads. Documented as available-if-needed; not pursued.
+
+### Lever B retry — attempted, FAILED on bench (2026-05-09)
+
+After the trivia rework removed one of two HANDOVER §6.2 blockers (trivia-context-loss), retried Lever B by wiring the dormant `SafePivotAnalyzer.safePivotRules(Grammar)` into `tryIncrementalReparse`. Gate: only accept rules in safe-pivot set; walk outward otherwise.
+
+**Bench result (IncrementalSessionBench, 1000 edits, Regime B):**
+
+| Metric | Pre-Lever-B | Post-gate |
+|---|---:|---:|
+| Median | 5.0 ms | **21.9 ms (+338%)** |
+| p95 | 11.2 ms | 214.8 ms |
+| % under 16ms | 96.5% | **43.6%** |
+
+Reverted. HEAD unchanged at `46d8a05`.
+
+**Root cause of failure:** SafePivotAnalyzer's "unambiguous literal prefix" criterion is too conservative for Java 25 grammar. Most rules start with character classes (`Identifier <- [a-zA-Z_] ...`) or rule references (`Type <- ClassType / ...`), which the analyzer marks unsafe. Walk-up-find-safe-ancestor strategy lands at root for ~56% of edits → forces full reparse. Parity is preserved but perf is catastrophically worse.
+
+**What this implies:**
+- The existing "OLD walk-up + length check" passes IncrementalParityTest in current form, even without the safe-pivot gate. The §6.2 hypothetical bug ("smallestEnclosing descent strategy bypasses ancestors") doesn't actually fire in the current walk-up algorithm.
+- Lever B retry would need: (a) a smarter analyzer that includes rules with disjoint first-sets (not just literal prefixes), or (b) on-accept structural validation instead of static analysis gate.
+- Both are non-trivial (a few days each) and bench-gated.
+- Until then, the current incremental engine's length-check-only acceptance is the de facto safe state. Median 5.0 ms / p95 11.2 ms is already excellent.
+
+**Defer Lever B retry indefinitely.** The trivia work was the real blocker; the perf is already good enough that strengthening the gate hasn't been worth the analysis investment.
+
+### Cleanup G — reference-fixture tightening attempted, abandoned (2026-05-09)
+
+Two micro-optimizations attempted under bench gate (≥3% wallclock OR ≥5% alloc on reference); both REVERTED on evidence:
+
+- **G.1 — first-char prefilter on scanWhitespace.** Hypothesis: ~50% of scan calls return empty; precheck `input.charAt(prevEnd)` against whitespace-first-set to skip alloc. Reality: parser positions are always at token-content boundaries (skipWhitespace already advanced), so scan calls have either real whitespace or `from == to`. Existing `from >= to` short-circuit already catches the empty case; new prefilter rejected ~0 calls. Bench: alloc Δ +0.2%, wallclock noise. Reverted.
+
+- **G.2 — alloc tightening on non-empty scan path.** Three changes bundled: lineColAt returns long instead of int[2]; scanWhitespaceFast returns `List.of(single)` for size-1 chunks; combine() short-circuits empty. The combine change was already in place at `76498bf`. Other two micro-saves total ~32 KB out of 77 MB per-op (<0.05%). Bench: alloc Δ -0.4%, wallclock noise. Reverted.
+
+**Verdict: the +30% reference-fixture gap is structural, not micro-fixable.** Cost lives in:
+- Spine rebuild on divergence (List.copyOf of children, NonTerminal record construction)
+- Recursive scanWhitespace per node boundary
+- Per-chunk Trivia node classification
+
+Closing the gap requires structural redesigns (NOT "tightening"):
+1. Skip the post-pass entirely when CST already has correct trivia attribution (gate via engine-side flag)
+2. Persistent immutable lists with structural sharing for trivia lists
+3. Lazy SourceSpan materialization
+
+Each is its own spec. Selfhost (perf-critical) is already faster than legacy (-5%); the reference fixture's small inputs make the post-pass overhead a higher % of total but the absolute cost (~7 ms) is bounded.
+
+ Branch `release-0.5.1` at `0b29c78` — 20 commits past 0.5.1 base. peglib-core 805 tests + 1 pre-existing skipped, all green at the new default (post-pass + StringSpan). **Trivia rework + cleanup state: SHIPPED + bench-validated.** Cumulative selfhost wallclock now **-5% under legacy** (was at parity); reference fixture +30% over legacy (per-NonTerminal scan dominates; future work). 30 new StringSpanTest cases + StringSpan added as `org.pragmatica.peg.tree.StringSpan` (CharSequence view with lazy String materialization). Move B post-mortem: [`docs/incremental/THROUGHPUT-ENGINE-MOVE-B.md`](incremental/THROUGHPUT-ENGINE-MOVE-B.md) §11. Next session: Lever B retry (fallback-rule-bypass blocker remains; trivia-context-loss blocker is RESOLVED) OR Lever C IR unification OR address reference-fixture per-NonTerminal scan cost (cache scanWhitespace, skip NonTerminals with full-coverage children, batch gap-scans).
diff --git a/docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md b/docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md
new file mode 100644
index 0000000..8d44a1b
--- /dev/null
+++ b/docs/incremental/TRIVIA-ADVERSARIAL-FINDINGS.md
@@ -0,0 +1,95 @@
+# Trivia Adversarial Corpus — Step 2 Findings
+
+Step 2 of the trivia attribution context-dependency investigation.
+Tests at `peglib-core/src/test/java/org/pragmatica/peg/perf/TriviaAdversarialTest.java`.
+17 tests added — 16 enabled passing, 1 `@Disabled`. Production code untouched.
+
+## Bottom line
+
+| Severity | Count | Items |
+| ---------------- | ----- | ---------------------------------------------------- |
+| Definite bug | 0 | |
+| Latent bug | 2 | Bug 1 (gen size-only restore), Bug 2 (Optional cut) |
+| Robust-by-luck | 2 | Cache hit parity, Choice backtrack same-position |
+| Robust-by-design | 3 | Bug C' nested, Predicate symmetry, %whitespace pure |
+
+**Did Target #1 surface as a definite bug?** No. Two probes
+(`generator_choiceBacktrackAfterDrain_parityProbe`,
+`generator_choiceBacktrackDifferentStructure_parityProbe`) confirm
+interpreter and generator produce byte-identical trivia text. The
+size-only restore IS structurally weaker than `List.copyOf` snapshot
+(`ParserGenerator.java:6768-6776` vs `ParsingContext.java:526-528`), but
+the second alternative re-runs `skipWhitespace` at the rolled-back
+position and rebuilds pending equivalently. Bug 1 reclassified as
+**robust-by-luck**.
+
+## Per target
+
+### #1 Generator size-only restore (3 tests passing)
+- Interpreter and generator agree on backtracking-choice trivia.
+- Code-level asymmetry is real; black-box impact is masked.
+
+### #2 Bug C' nested boundary (4 tests passing)
+- Empty-ZoM tail with multi-sibling prefix — orphan WS attached.
+- Doubly-nested empty ZoM, comment+empty-ZoM, empty Optional tail.
+- All round-trip. Robust-by-design. Pinned as regression net.
+
+### #3 parseRuleAt context-loss (3 tests passing)
+- Documents narrowing of wrapper.leadingTrivia from fresh-context entry.
+- Item span text from parseRuleAt equals full-parse Item span text.
+- Behaviour as documented; tests pin current contract.
+
+### #4 Cache hit/miss parity (2 tests passing)
+- Two call sites, predicate-seeded cache hit — both produce correct
+ leading. Robust-by-luck (relies on Target #7).
+
+### #5 Predicate symmetry (2 tests passing)
+- And/Not save+restore via `savePendingLeadingTrivia` (full snapshot).
+- Robust-by-design.
+
+### #6 Optional CutFailure (1 test `@Disabled`)
+- `parseOptionalWithMode` line 1936 returns CutFailure without restoring
+ `entryPendingSnapshot`.
+- **Bug 2 latent.** No black-box surface — when cut fires the outer
+ parse fails and pending is unobservable through the returned CST.
+ Observability needs instrumented `ParsingContext` or upstream
+ recoverable cut-failure-from-Optional.
+- Fix is trivial: restore snapshot at line 1937. Risk-free even masked.
+
+### #7 %whitespace purity (1 test passing)
+- Sub-rule-using whitespace; cache hit reattach matches cache miss.
+- Robust-by-design for the tested fixture. Bug-B correctness *requires*
+ whitespace purity. Step 3 should formalise as grammar-validation rule.
+
+### Fuzz (1 test passing)
+- 100 random nested-pair inputs. parseRuleAt subtree span text equals
+ full-parse subtree span text for every Value/Pair node visited.
+- No new mismatches beyond Target #3's documented context-loss.
+
+## Reproducers
+
+**Bug 1 (latent in generator):**
+```
+Start <- Triple / Pair
+Pair <- 'x' 'y'
+Triple <- 'x' 'y' 'z'
+%whitespace <- [ ]+
+```
+Input `"x y"`. Generator's `restorePendingLeading(int)` (line 6772)
+truncates only — drained items not recovered. Masked because the second
+alt re-runs skipWhitespace.
+
+**Bug 2 (Optional CutFailure):** `PegEngine.java:1936`. Add
+`ctx.restorePendingLeadingTrivia(entryPendingSnapshot);` before
+`return result;` on line 1937.
+
+## Suggested next direction
+
+1. Fix Bug 2 — one-line change at `PegEngine.java:1937`.
+2. Step 3 design: treat the size-only-vs-list-snapshot asymmetry as a
+ semantic invariant relying on `skipWhitespace` re-skip after
+ rollback. Document this in `docs/TRIVIA-ATTRIBUTION.md` rather than
+ "fix" the generator.
+3. Search for a grammar where Bug 1 manifests black-box (a Choice whose
+ second alt skips the WS-bearing region entirely). If none exists,
+ that confirms size-only path is sufficient.
diff --git a/peglib-core/pom.xml b/peglib-core/pom.xml
index 61c47a9..2eb24f0 100644
--- a/peglib-core/pom.xml
+++ b/peglib-core/pom.xml
@@ -7,7 +7,7 @@
org.pragmatica-litepeglib-parent
- 0.5.0
+ 0.5.1../pom.xml
diff --git a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java
index 8e97afa..3532804 100644
--- a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java
+++ b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java
@@ -70,6 +70,7 @@ public class Java25ParseBenchmark {
"phase1_allStructural_skipPackrat",
"phase1_allStructural_mutableResult",
"phase1_allStructural_mutableResult_autoSkipPackrat",
+ "phase1_allStructural_mutableResult_autoSkipPackrat_postPass",
"interpreter"
})
public String variant;
@@ -173,6 +174,10 @@ private static ParserConfig configFor(String variant) {
case "phase1_allStructural_mutableResult_autoSkipPackrat_noFastPath" -> withStructural(true, true, true, true, Set.of(), true, false);
// Phase 1.9 (DFA spike): structural + autoSkipPackrat WITH tokenFastPath — A/B variant.
case "phase1_allStructural_mutableResult_autoSkipPackrat_fastPath" -> withStructural(true, true, true, true, Set.of(), true, true);
+ // Step 4 follow-up: post-pass trivia attribution A/B vs autoSkipPackrat baseline
+ // (which leaves triviaPostPass=false). Same generator-time perf flags as
+ // autoSkipPackrat; only triviaPostPass flips ON.
+ case "phase1_allStructural_mutableResult_autoSkipPackrat_postPass" -> withStructural(true, true, true, true, Set.of(), true, true, true);
default -> throw new IllegalArgumentException("Unknown variant: " + variant);
};
}
@@ -195,7 +200,8 @@ private static ParserConfig allOff() {
false, // selectivePackrat
Set.of(), // packratSkipRules
false, // mutableParseResult
- false); // tokenFastPath
+ false, // tokenFastPath
+ false); // triviaPostPass
}
/**
@@ -226,6 +232,18 @@ private static ParserConfig withStructural(boolean choiceDispatch,
Set packratSkipRules,
boolean mutableParseResult,
boolean tokenFastPath) {
+ return withStructural(choiceDispatch, markResetChildren, inlineLocations, selectivePackrat,
+ packratSkipRules, mutableParseResult, tokenFastPath, false);
+ }
+
+ private static ParserConfig withStructural(boolean choiceDispatch,
+ boolean markResetChildren,
+ boolean inlineLocations,
+ boolean selectivePackrat,
+ Set packratSkipRules,
+ boolean mutableParseResult,
+ boolean tokenFastPath,
+ boolean triviaPostPass) {
return new ParserConfig(
true, // packratEnabled
RecoveryStrategy.BASIC,
@@ -242,7 +260,8 @@ private static ParserConfig withStructural(boolean choiceDispatch,
selectivePackrat,
Set.copyOf(packratSkipRules),
mutableParseResult,
- tokenFastPath); // phase 1.9 (DFA spike)
+ tokenFastPath, // phase 1.9 (DFA spike)
+ triviaPostPass); // Step 4 — post-pass trivia attribution
}
static String loadResource(String resourcePath) throws Exception {
diff --git a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java
index 32a25f3..ace90fb 100644
--- a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java
+++ b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java
@@ -62,7 +62,8 @@ public static void main(String[] args) throws Exception {
false, // selectivePackrat
Set.of(), // packratSkipRules
false, // mutableParseResult
- true); // tokenFastPath
+ true, // tokenFastPath
+ false); // triviaPostPass
var sourceResult = PegParser.generateCstParser(
grammarText, PACKAGE_NAME, CLASS_NAME, ErrorReporting.BASIC, config);
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/PegParser.java b/peglib-core/src/main/java/org/pragmatica/peg/PegParser.java
index fda2ca7..876fe2a 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/PegParser.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/PegParser.java
@@ -243,6 +243,7 @@ public static final class Builder {
private boolean packratEnabled = true;
private RecoveryStrategy recoveryStrategy = RecoveryStrategy.BASIC;
private boolean captureTrivia = true;
+ private boolean triviaPostPass = false;
private Builder(String grammarText) {
this.grammarText = grammarText;
@@ -263,8 +264,37 @@ public Builder trivia(boolean capture) {
return this;
}
+ /**
+ * 0.5.1 (Step 4 commit 1) — enable the post-parse trivia attribution
+ * pass. When on, the parser re-derives leading/trailing trivia from
+ * {@code (input, span, grammar.whitespace())} after the engine
+ * returns its CST. Default: off.
+ */
+ public Builder triviaPostPass(boolean enabled) {
+ this.triviaPostPass = enabled;
+ return this;
+ }
+
public Result build() {
- var config = ParserConfig.parserConfig(packratEnabled, recoveryStrategy, captureTrivia);
+ var base = ParserConfig.parserConfig(packratEnabled, recoveryStrategy, captureTrivia);
+ var config = new ParserConfig(
+ base.packratEnabled(),
+ base.recoveryStrategy(),
+ base.captureTrivia(),
+ base.fastTrackFailure(),
+ base.literalFailureCache(),
+ base.charClassFailureCache(),
+ base.bulkAdvanceLiteral(),
+ base.skipWhitespaceFastPath(),
+ base.reuseEndLocation(),
+ base.choiceDispatch(),
+ base.markResetChildren(),
+ base.inlineLocations(),
+ base.selectivePackrat(),
+ base.packratSkipRules(),
+ base.mutableParseResult(),
+ base.tokenFastPath(),
+ triviaPostPass);
return fromGrammar(grammarText, config);
}
}
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 5a81ac8..89595e4 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
@@ -242,8 +242,8 @@ private static void validateConfig(Grammar grammar, ParserConfig config) {
}
for (var skip : config.packratSkipRules()) {
if (lrRules.contains(skip)) {
- throw new IllegalArgumentException(
- "rule '" + skip + "' is left-recursive; cannot be in packratSkipRules");
+ throw new IllegalArgumentException("rule '" + skip
+ + "' is left-recursive; cannot be in packratSkipRules");
}
}
}
@@ -279,6 +279,76 @@ private String expectedUnwrap(String varName) {
: varName + ".expected.unwrap()";
}
+ /**
+ * Cleanup A: returns the inline expression for {@code takePendingLeading()}.
+ * Under {@code triviaPostPass=true}, the helper is a no-op returning
+ * {@code List.of()}; emitting the literal here at gen-time eliminates the
+ * dead method dispatch at runtime.
+ */
+ private String takePendingExpr() {
+ return config.triviaPostPass()
+ ? "List.of()"
+ : "takePendingLeading()";
+ }
+
+ /**
+ * Cleanup A: returns the inline expression for {@code savePendingLeading()}.
+ * Under flag-ON, save returns {@code 0} (the sentinel); we just emit it.
+ */
+ private String savePendingExpr() {
+ return config.triviaPostPass()
+ ? "0"
+ : "savePendingLeading()";
+ }
+
+ /**
+ * Cleanup A: emit a {@code restorePendingLeading()} statement.
+ * Under flag-ON, emits nothing — the call would be a no-op anyway.
+ */
+ private void emitRestorePending(StringBuilder sb, String indent, String snapshotVar) {
+ if (config.triviaPostPass()) {
+ return;
+ }
+ sb.append(indent)
+ .append("restorePendingLeading(")
+ .append(snapshotVar)
+ .append(");\n");
+ }
+
+ /**
+ * Cleanup A: emit an {@code appendPending()} statement, evaluating
+ * {@code expr} eagerly (so any side-effect like {@code skipWhitespace()}
+ * still runs). Under flag-ON, emits {@code expr;} (a statement) so the
+ * skip still occurs but the buffer append is elided.
+ */
+ private void emitAppendPendingFromExpr(StringBuilder sb, String indent, String expr) {
+ if (config.triviaPostPass()) {
+ sb.append(indent)
+ .append(expr)
+ .append(";\n");
+ return;
+ }
+ sb.append(indent)
+ .append("appendPending(")
+ .append(expr)
+ .append(");\n");
+ }
+
+ /**
+ * Cleanup A: emit a {@code pendingLeadingTrivia.addAll()} statement
+ * for the rule-failure re-deposit path. Under flag-ON, the generated
+ * parser's pendingLeadingTrivia field is unused; skip the statement.
+ */
+ private void emitPendingAddAll(StringBuilder sb, String indent, String expr) {
+ if (config.triviaPostPass()) {
+ return;
+ }
+ sb.append(indent)
+ .append("pendingLeadingTrivia.addAll(")
+ .append(expr)
+ .append(");\n");
+ }
+
public static ParserGenerator parserGenerator(Grammar grammar, String packageName, String className) {
return new ParserGenerator(grammar, packageName, className, ErrorReporting.BASIC, ParserConfig.DEFAULT);
}
@@ -335,6 +405,9 @@ public String generateCst() {
generateCstParseMethods(sb);
generateCstRuleMethods(sb);
generateCstHelperMethods(sb);
+ if (config.triviaPostPass()) {
+ generateCstTriviaPostPass(sb);
+ }
generateClassEnd(sb);
return sb.toString();
}
@@ -1874,20 +1947,26 @@ public sealed interface CstNode {
List leadingTrivia();
List trailingTrivia();
- record Terminal(long id, SourceSpan span, RuleId rule, String text,
+ record Terminal(long id, SourceSpan span, RuleId rule, StringSpan textSpan,
List leadingTrivia, List trailingTrivia) implements CstNode {
+ /** Convenience constructor — wraps a String as a fully-materialized StringSpan. */
+ public Terminal(long id, SourceSpan span, RuleId rule, String text,
+ List leadingTrivia, List trailingTrivia) {
+ this(id, span, rule, StringSpan.ofString(text), leadingTrivia, trailingTrivia);
+ }
+ public String text() { return textSpan.toString(); }
@Override
public boolean equals(Object other) {
return other instanceof Terminal that
&& java.util.Objects.equals(span, that.span)
&& java.util.Objects.equals(rule, that.rule)
- && java.util.Objects.equals(text, that.text)
+ && java.util.Objects.equals(textSpan, that.textSpan)
&& java.util.Objects.equals(leadingTrivia, that.leadingTrivia)
&& java.util.Objects.equals(trailingTrivia, that.trailingTrivia);
}
@Override
public int hashCode() {
- return java.util.Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia);
+ return java.util.Objects.hash(Terminal.class, span, rule, textSpan, leadingTrivia, trailingTrivia);
}
}
@@ -1908,20 +1987,26 @@ public int hashCode() {
}
}
- record Token(long id, SourceSpan span, RuleId rule, String text,
+ record Token(long id, SourceSpan span, RuleId rule, StringSpan textSpan,
List leadingTrivia, List trailingTrivia) implements CstNode {
+ /** Convenience constructor — wraps a String as a fully-materialized StringSpan. */
+ public Token(long id, SourceSpan span, RuleId rule, String text,
+ List leadingTrivia, List trailingTrivia) {
+ this(id, span, rule, StringSpan.ofString(text), leadingTrivia, trailingTrivia);
+ }
+ public String text() { return textSpan.toString(); }
@Override
public boolean equals(Object other) {
return other instanceof Token that
&& java.util.Objects.equals(span, that.span)
&& java.util.Objects.equals(rule, that.rule)
- && java.util.Objects.equals(text, that.text)
+ && java.util.Objects.equals(textSpan, that.textSpan)
&& java.util.Objects.equals(leadingTrivia, that.leadingTrivia)
&& java.util.Objects.equals(trailingTrivia, that.trailingTrivia);
}
@Override
public int hashCode() {
- return java.util.Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia);
+ return java.util.Objects.hash(Token.class, span, rule, textSpan, leadingTrivia, trailingTrivia);
}
}
""");
@@ -1950,6 +2035,80 @@ public int hashCode() {
sb.append("""
}
+ /**
+ * View into a substring of the parser input. Defers materialization to a
+ * Java String until first access via toString(). Cleanup F.3: token text in
+ * generated parsers flows through StringSpan to avoid eager substring()
+ * allocation in match helpers, token boundary capture, and DFA fast-path.
+ *
+ *
Embedded inline so the standalone-parser invariant (depends only on
+ * pragmatica-lite:core) is preserved.
+ */
+ public static final class StringSpan implements CharSequence {
+ private final String source;
+ private final int start;
+ private final int end;
+ private String materialized;
+
+ public StringSpan(String source, int start, int end) {
+ this.source = source;
+ this.start = start;
+ this.end = end;
+ }
+
+ /** Wrap a fully-materialized String with no deferral. */
+ public static StringSpan ofString(String text) {
+ var span = new StringSpan(text, 0, text.length());
+ span.materialized = text;
+ return span;
+ }
+
+ public int start() { return start; }
+ public int end() { return end; }
+
+ @Override public int length() { return end - start; }
+ @Override public char charAt(int i) { return source.charAt(start + i); }
+ @Override public CharSequence subSequence(int s, int e) {
+ return new StringSpan(source, start + s, start + e);
+ }
+
+ @Override
+ public String toString() {
+ var m = materialized;
+ if (m == null) {
+ m = source.substring(start, end);
+ materialized = m;
+ }
+ return m;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof StringSpan that) {
+ int len = this.length();
+ if (len != that.length()) return false;
+ if (this.source == that.source && this.start == that.start && this.end == that.end) return true;
+ for (int i = 0; i < len; i++) {
+ if (this.source.charAt(this.start + i) != that.source.charAt(that.start + i)) return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ if (materialized != null) return materialized.hashCode();
+ int h = 0;
+ int len = end - start;
+ for (int i = 0; i < len; i++) {
+ h = 31 * h + source.charAt(start + i);
+ }
+ return h;
+ }
+ }
+
public sealed interface AstNode {
SourceSpan span();
String rule();
@@ -2448,6 +2607,15 @@ private String substring(int start, int end) {
return input.substring(start, end);
}
+ /**
+ * Cleanup F.3: build a StringSpan view over the input without materializing
+ * a Java String. Used by match helpers and token boundary capture so the
+ * substring is deferred until a consumer actually needs it.
+ */
+ private StringSpan substringSpan(int start, int end) {
+ return new StringSpan(input, start, end);
+ }
+
private long cacheKey(int ruleId, int position) {
return ((long) ruleId << 32) | position;
}
@@ -2700,6 +2868,9 @@ private void generateCstParseMethods(StringBuilder sb) {
var expectedExpr = config.mutableParseResult()
? "((furthestExpected != null && !furthestExpected.isEmpty()) ? furthestExpected : (result.expected != null ? result.expected : \"valid input\"))"
: "furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or(\"valid input\"))";
+ var postPassParseLine = config.triviaPostPass()
+ ? " rootNode = TriviaPostPass.assignTrivia(input, rootNode, 0);\n"
+ : "";
sb.append("""
// === Public Parse Methods ===
@@ -2718,7 +2889,7 @@ public Result parse(String input) {
}
// Attach trailing trivia to root node
var rootNode = attachTrailingTrivia(%s, trailingTrivia);
- return Result.success(rootNode);
+ %s return Result.success(rootNode);
}
/**
@@ -2741,8 +2912,19 @@ private AstNode toAst(CstNode cst) {
};
}
- """.formatted(sanitizedName, furthestFailureOrLoc, expectedExpr, furthestFailureOrLoc, resultNodeUnwrapExpr));
+ """.formatted(sanitizedName,
+ furthestFailureOrLoc,
+ expectedExpr,
+ furthestFailureOrLoc,
+ resultNodeUnwrapExpr,
+ postPassParseLine));
if (errorReporting == ErrorReporting.ADVANCED) {
+ var postPassDiagBranchLine = config.triviaPostPass()
+ ? " rootNode = TriviaPostPass.assignTrivia(input, rootNode, 0);\n"
+ : "";
+ var postPassDiagSuccessLine = config.triviaPostPass()
+ ? " rootNode = TriviaPostPass.assignTrivia(input, rootNode, 0);\n"
+ : "";
sb.append("""
/**
* Parse with advanced error recovery and Rust-style diagnostics.
@@ -2781,11 +2963,11 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) {
// Attach error node to result
var rootNode = attachTrailingTrivia(%s, trailingTrivia);
- return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input);
+ %s return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input);
}
var rootNode = attachTrailingTrivia(%s, trailingTrivia);
- if (diagnostics.isEmpty()) {
+ %s if (diagnostics.isEmpty()) {
return ParseResultWithDiagnostics.success(rootNode, input);
}
return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input);
@@ -2796,7 +2978,9 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) {
expectedExpr,
furthestFailureOrLoc,
resultNodeUnwrapExpr,
- resultNodeUnwrapExpr));
+ postPassDiagBranchLine,
+ resultNodeUnwrapExpr,
+ postPassDiagSuccessLine));
}
generateCstParseRuleAt(sb);
}
@@ -2887,7 +3071,7 @@ public Result parseRuleAt(
var expected = __EXPECTED_EXPR__;
return Result.failure(new ParseError(errorLoc, "expected " + expected));
}
- return Result.success(new PartialParse(__RESULT_NODE__, pos));
+ return Result.success(new PartialParse(__POST_PASS_WRAP__, pos));
}
/**
@@ -2911,9 +3095,13 @@ private void seekTo(int offset) {
}
""");
+ var postPassWrapExpr = config.triviaPostPass()
+ ? "TriviaPostPass.assignTrivia(input, " + resultNodeUnwrapExpr + ", offset)"
+ : resultNodeUnwrapExpr;
sb.append(ruleAtBlock.toString()
.replace("__FURTHEST_FAILURE_OR_LOC__", furthestFailureOrLoc)
.replace("__EXPECTED_EXPR__", expectedExpr)
+ .replace("__POST_PASS_WRAP__", postPassWrapExpr)
.replace("__RESULT_NODE__", resultNodeUnwrapExpr));
}
@@ -3000,7 +3188,9 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) {
sb.append(" var cached = cache.get(key);\n");
sb.append(" if (cached != null) {\n");
sb.append(" if (cached.isSuccess()) {\n");
- sb.append(" var hitCarriedLeading = takePendingLeading();\n");
+ sb.append(" var hitCarriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var hitLocalLeading = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var hitLeading = concatTrivia(hitCarriedLeading, hitLocalLeading);\n");
sb.append(" var hitEndLoc = ")
@@ -3020,7 +3210,9 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) {
sb.append(" \n");
}
sb.append(" // Skip leading whitespace and combine with carried pending-leading.\n");
- sb.append(" var carriedLeading = takePendingLeading();\n");
+ sb.append(" var carriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var localLeadingTrivia = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var leadingTrivia = concatTrivia(carriedLeading, localLeadingTrivia);\n");
var ruleIdConst = toConstantName(ruleName);
@@ -3077,7 +3269,11 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) {
// child (typically inter-element skipWhitespace before a zero-width tail
// element). Pos is NOT rewound — predicate combinators rely on pos being
// past consumed whitespace. Mirrors PegEngine.parseRule.
- sb.append(" var pendingAtExit = takePendingLeading();\n");
+ // Cleanup A: under triviaPostPass=true, takePendingLeading returns an
+ // empty list, so the trailing-attach block is statically dead — elide.
+ if (!config.triviaPostPass()) {
+ sb.append(" var pendingAtExit = takePendingLeading();\n");
+ }
// Bug C fix: cache the empty-leading wrapped node so cache hits don't
// preserve stale leading trivia and duplicate it on outer nodes. The
// returned node carries the actual leadingTrivia for this call site.
@@ -3088,10 +3284,12 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) {
sb.append(" var node = wrapWithRuleName(result, children, span, ")
.append(ruleIdConst)
.append(", leadingTrivia);\n");
- sb.append(" if (!pendingAtExit.isEmpty()) {\n");
- sb.append(" cacheNode = attachTrailingToTail(cacheNode, pendingAtExit);\n");
- sb.append(" node = attachTrailingToTail(node, pendingAtExit);\n");
- sb.append(" }\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" if (!pendingAtExit.isEmpty()) {\n");
+ sb.append(" cacheNode = attachTrailingToTail(cacheNode, pendingAtExit);\n");
+ sb.append(" node = attachTrailingToTail(node, pendingAtExit);\n");
+ sb.append(" }\n");
+ }
sb.append(" cacheableResult = CstParseResult.success(cacheNode, ")
.append(textOrEmpty("result"))
.append(", endLoc);\n");
@@ -3108,8 +3306,11 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) {
sb.append(" restoreLocation(startLoc);\n");
}
// Re-deposit the caller's pending-leading so enclosing backtracking
- // combinators can roll it back to their own snapshots.
- sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ // combinators can roll it back to their own snapshots. Cleanup A:
+ // under flag-ON, the buffer is unused; skip the addAll entirely.
+ if (!config.triviaPostPass()) {
+ sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ }
// Use custom error message if available
if (rule.hasErrorMessage()) {
sb.append(" finalResult = CstParseResult.failure(\"")
@@ -3181,7 +3382,9 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId
sb.append(" var cached = cache.get(key);\n");
sb.append(" if (cached != null) {\n");
sb.append(" if (cached.isSuccess()) {\n");
- sb.append(" var hitCarriedLeading = takePendingLeading();\n");
+ sb.append(" var hitCarriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var hitLocalLeading = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var hitLeading = concatTrivia(hitCarriedLeading, hitLocalLeading);\n");
sb.append(" var hitEndLoc = ")
@@ -3210,7 +3413,9 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId
sb.append(" return activeSeed;\n");
sb.append(" }\n");
// Capture leading whitespace & carried pending at the outer level.
- sb.append(" var carriedLeading = takePendingLeading();\n");
+ sb.append(" var carriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var localLeadingTrivia = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var leadingTrivia = concatTrivia(carriedLeading, localLeadingTrivia);\n");
sb.append(" int bodyStartPos = pos;\n");
@@ -3294,7 +3499,9 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId
}else {
sb.append(" restoreLocation(startLoc);\n");
}
- sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ }
if (rule.hasErrorMessage()) {
sb.append(" finalResult = CstParseResult.failure(\"")
.append(escape(rule.errorMessage()
@@ -3416,7 +3623,9 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
sb.append(" var cached = cache.get(key);\n");
sb.append(" if (cached != null) {\n");
sb.append(" if (cached.isSuccess()) {\n");
- sb.append(" var hitCarriedLeading = takePendingLeading();\n");
+ sb.append(" var hitCarriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var hitLocalLeading = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var hitLeading = concatTrivia(hitCarriedLeading, hitLocalLeading);\n");
sb.append(" var hitEndLoc = ")
@@ -3435,7 +3644,9 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
sb.append(" }\n");
sb.append(" \n");
}
- sb.append(" var carriedLeading = takePendingLeading();\n");
+ sb.append(" var carriedLeading = ")
+ .append(takePendingExpr())
+ .append(";\n");
sb.append(" var localLeadingTrivia = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n");
sb.append(" var leadingTrivia = concatTrivia(carriedLeading, localLeadingTrivia);\n");
sb.append(" var children = new ArrayList();\n");
@@ -3455,7 +3666,13 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
sb.append(" int choiceStart0Pos = pos;\n");
sb.append(" int choiceStart0Line = line;\n");
sb.append(" int choiceStart0Column = column;\n");
- sb.append(" int choicePending0 = savePendingLeading();\n");
+ // Cleanup A: under triviaPostPass=true, savePendingLeading() returns 0
+ // (sentinel) and restorePendingLeading is a no-op; we still emit the
+ // declaration since helpers receive choicePending0 as a parameter, and
+ // the JIT trivially elides the dead local.
+ sb.append(" int choicePending0 = ")
+ .append(savePendingExpr())
+ .append(";\n");
var childrenStateName = config.markResetChildren()
? "childrenMark0"
: "savedChildren0";
@@ -3496,7 +3713,9 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
.append(childrenStateName)
.append(");\n");
}
- sb.append(" restorePendingLeading(choicePending0);\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" restorePendingLeading(choicePending0);\n");
+ }
sb.append(" result = CstParseResult.failure(\"one of alternatives\");\n");
sb.append(" }\n");
sb.append(" \n");
@@ -3521,17 +3740,21 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
}else {
sb.append(" var span = SourceSpan.sourceSpan(startLoc, endLoc);\n");
}
- sb.append(" var pendingAtExit = takePendingLeading();\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" var pendingAtExit = takePendingLeading();\n");
+ }
sb.append(" var cacheNode = wrapWithRuleName(result, children, span, ")
.append(ruleIdConst)
.append(", List.of());\n");
sb.append(" var node = wrapWithRuleName(result, children, span, ")
.append(ruleIdConst)
.append(", leadingTrivia);\n");
- sb.append(" if (!pendingAtExit.isEmpty()) {\n");
- sb.append(" cacheNode = attachTrailingToTail(cacheNode, pendingAtExit);\n");
- sb.append(" node = attachTrailingToTail(node, pendingAtExit);\n");
- sb.append(" }\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" if (!pendingAtExit.isEmpty()) {\n");
+ sb.append(" cacheNode = attachTrailingToTail(cacheNode, pendingAtExit);\n");
+ sb.append(" node = attachTrailingToTail(node, pendingAtExit);\n");
+ sb.append(" }\n");
+ }
sb.append(" cacheableResult = CstParseResult.success(cacheNode, ")
.append(textOrEmpty("result"))
.append(", endLoc);\n");
@@ -3546,7 +3769,9 @@ private void emitCstChoiceRule(StringBuilder sb, Rule rule, int ruleId, Expressi
}else {
sb.append(" restoreLocation(startLoc);\n");
}
- sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" if (!carriedLeading.isEmpty()) pendingLeadingTrivia.addAll(carriedLeading);\n");
+ }
if (rule.hasErrorMessage()) {
sb.append(" finalResult = CstParseResult.failure(\"")
.append(escape(rule.errorMessage()
@@ -3616,7 +3841,9 @@ private void emitChoiceRuleDispatcherF(StringBuilder sb,
sb.append(" this.pos = choiceStart0Pos;\n");
sb.append(" this.line = choiceStart0Line;\n");
sb.append(" this.column = choiceStart0Column;\n");
- sb.append(" restorePendingLeading(choicePending0);\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" restorePendingLeading(choicePending0);\n");
+ }
sb.append(" result = null;\n");
emitHelperCallChain(sb, rule, defaults, childrenStateName, " ");
sb.append(" }\n");
@@ -3748,7 +3975,9 @@ private void emitCstChoiceAltHelper(StringBuilder sb, Rule rule, Expression alt,
sb.append(" this.pos = choiceStartPos;\n");
sb.append(" this.line = choiceStartLine;\n");
sb.append(" this.column = choiceStartColumn;\n");
- sb.append(" restorePendingLeading(choicePending);\n");
+ if (!config.triviaPostPass()) {
+ sb.append(" restorePendingLeading(choicePending);\n");
+ }
sb.append(" return ")
.append(altVar)
.append(";\n");
@@ -3947,7 +4176,9 @@ private void emitCstSequenceChunkedDispatcher(StringBuilder sb,
sb.append(pad)
.append("int ")
.append(seqPending)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
if (addToChildren) {
sb.append(pad)
.append("var ")
@@ -4092,7 +4323,9 @@ private void emitCstSequenceChunkHelper(Expression.Sequence seq,
.append("if (result.isSuccess()) {\n");
if (!inWhitespaceRule && !isPredicate(elem)) {
sb.append(pad)
- .append(" if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
+ .append(config.triviaPostPass()
+ ? " if (tokenBoundaryDepth == 0) skipWhitespace();\n"
+ : " if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
}
// Element-local result variable name. The leading `chunkIdx_` prefix is just
// a unique-name disambiguator across elements within the chunk.
@@ -4107,7 +4340,9 @@ private void emitCstSequenceChunkHelper(Expression.Sequence seq,
sb.append(pad)
.append(" restoreLocationRaw(seqStartPos, seqStartLine, seqStartColumn);\n");
sb.append(pad)
- .append(" restorePendingLeading(seqPending);\n");
+ .append(config.triviaPostPass()
+ ? ""
+ : " restorePendingLeading(seqPending);\n");
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -4125,7 +4360,9 @@ private void emitCstSequenceChunkHelper(Expression.Sequence seq,
sb.append(pad)
.append(" restoreLocationRaw(seqStartPos, seqStartLine, seqStartColumn);\n");
sb.append(pad)
- .append(" restorePendingLeading(seqPending);\n");
+ .append(config.triviaPostPass()
+ ? ""
+ : " restorePendingLeading(seqPending);\n");
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -4189,7 +4426,9 @@ private void emitCstSequenceInline(StringBuilder sb,
sb.append(pad)
.append("int ")
.append(seqPending)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
if (addToChildren) {
sb.append(pad)
.append("var ")
@@ -4208,7 +4447,9 @@ private void emitCstSequenceInline(StringBuilder sb,
.append(".isSuccess()) {\n");
if (!inWhitespaceRule && !isPredicate(elem)) {
sb.append(pad)
- .append(" if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
+ .append(config.triviaPostPass()
+ ? " if (tokenBoundaryDepth == 0) skipWhitespace();\n"
+ : " if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
}
var elemVar = "elem" + id + "_" + i;
generateCstExpressionCode(sb, elem, elemVar, indent + 1, addToChildren, counter, inWhitespaceRule);
@@ -4224,10 +4465,12 @@ private void emitCstSequenceInline(StringBuilder sb,
.append(", ")
.append(seqStartColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(")
- .append(seqPending)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(")
+ .append(seqPending)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -4254,10 +4497,12 @@ private void emitCstSequenceInline(StringBuilder sb,
.append(", ")
.append(seqStartColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(")
- .append(seqPending)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(")
+ .append(seqPending)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -4472,7 +4717,9 @@ private void emitCstChoiceInline(StringBuilder sb,
sb.append(pad)
.append("int choicePending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
// Don't skip whitespace here - let alternatives capture trivia themselves
emitChildrenSave(sb, pad, childrenState, addToChildren);
// Phase 1F: try extended FIRST-set classification first (covers Reference,
@@ -4523,10 +4770,12 @@ private void emitCstChoiceInline(StringBuilder sb,
.append(resultVar)
.append(" == null) {\n");
emitChildrenRestore(sb, pad + " ", childrenState, addToChildren);
- sb.append(pad)
- .append(" restorePendingLeading(choicePending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(choicePending")
+ .append(id)
+ .append(");\n");
+ }
sb.append(pad)
.append(" ")
.append(resultVar)
@@ -4906,12 +5155,13 @@ private void emitCstFastPathTokenBoundary(StringBuilder sb,
.append(" column = fpCol")
.append(id)
.append(";\n");
+ // Cleanup F.3: emit StringSpan instead of input.substring(...) to defer
+ // materialization. Note: we use the parser instance's substringSpan()
+ // helper since fpInput is just a local alias for `input`.
sb.append(pad)
.append(" var tbText")
.append(id)
- .append(" = ")
- .append(fpInput)
- .append(".substring(")
+ .append(" = substringSpan(")
.append(tbStartPos)
.append(", ")
.append(fpEnd)
@@ -4938,7 +5188,9 @@ private void emitCstFastPathTokenBoundary(StringBuilder sb,
.append(tokenRuleId)
.append(", tbText")
.append(id)
- .append(", takePendingLeading(), List.of());\n");
+ .append(", ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
if (addToChildren) {
sb.append(pad)
.append(" children.add(tbNode")
@@ -5284,10 +5536,14 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append(" int zomIterPending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
if (!inWhitespaceRule) {
sb.append(pad)
- .append(" if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
+ .append(config.triviaPostPass()
+ ? " if (tokenBoundaryDepth == 0) skipWhitespace();\n"
+ : " if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
}
generateCstExpressionCode(sb,
zom.expression(),
@@ -5325,10 +5581,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(beforeColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(zomIterPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(zomIterPending")
+ .append(id)
+ .append(");\n");
+ }
sb.append(pad)
.append(" break;\n");
sb.append(pad)
@@ -5436,7 +5694,9 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append("int oomEntryPending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
generateCstExpressionCode(sb,
oom.expression(),
oomFirst,
@@ -5462,16 +5722,18 @@ private void generateCstExpressionCode(StringBuilder sb,
.append("int ")
.append(oomStartColumn)
.append(" = column;\n");
- sb.append(pad)
- .append("if (!")
- .append(oomFirst)
- .append(".isSuccess()) {\n");
- sb.append(pad)
- .append(" restorePendingLeading(oomEntryPending")
- .append(id)
- .append(");\n");
- sb.append(pad)
- .append("}\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append("if (!")
+ .append(oomFirst)
+ .append(".isSuccess()) {\n");
+ sb.append(pad)
+ .append(" restorePendingLeading(oomEntryPending")
+ .append(id)
+ .append(");\n");
+ sb.append(pad)
+ .append("}\n");
+ }
sb.append(pad)
.append("if (")
.append(oomFirst)
@@ -5493,10 +5755,14 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append(" int oomIterPending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
if (!inWhitespaceRule) {
sb.append(pad)
- .append(" if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
+ .append(config.triviaPostPass()
+ ? " if (tokenBoundaryDepth == 0) skipWhitespace();\n"
+ : " if (tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
}
generateCstExpressionCode(sb,
oom.expression(),
@@ -5534,10 +5800,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(beforeColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(oomIterPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(oomIterPending")
+ .append(id)
+ .append(");\n");
+ }
sb.append(pad)
.append(" break;\n");
sb.append(pad)
@@ -5626,7 +5894,9 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append("int optPending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
// Save parent children before inner expression
if (addToChildren) {
sb.append(pad)
@@ -5661,10 +5931,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(optStartColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(optPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(optPending")
+ .append(id)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -5717,10 +5989,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(optStartColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(optPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(optPending")
+ .append(id)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append(" children.clear();\n");
@@ -5803,12 +6077,16 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append(" int repIterPending")
.append(id)
- .append(" = savePendingLeading();\n");
+ .append(" = ")
+ .append(savePendingExpr())
+ .append(";\n");
if (!inWhitespaceRule) {
sb.append(pad)
.append(" if (")
.append(repCount)
- .append(" > 0 && tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
+ .append(config.triviaPostPass()
+ ? " > 0 && tokenBoundaryDepth == 0) skipWhitespace();\n"
+ : " > 0 && tokenBoundaryDepth == 0) appendPending(skipWhitespace());\n");
}
generateCstExpressionCode(sb,
rep.expression(),
@@ -5846,10 +6124,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(beforeColumn)
.append(");\n");
- sb.append(pad)
- .append(" restorePendingLeading(repIterPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(repIterPending")
+ .append(id)
+ .append(");\n");
+ }
sb.append(pad)
.append(" break;\n");
sb.append(pad)
@@ -5982,10 +6262,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append("int ")
.append(andStartColumn)
.append(" = column;\n");
- sb.append(pad)
- .append("int andPending")
- .append(id)
- .append(" = savePendingLeading();\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append("int andPending")
+ .append(id)
+ .append(" = savePendingLeading();\n");
+ }
if (addToChildren) {
sb.append(pad)
.append("var ")
@@ -6002,10 +6284,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(andStartColumn)
.append(");\n");
// Predicates don't consume trivia state either.
- sb.append(pad)
- .append("restorePendingLeading(andPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append("restorePendingLeading(andPending")
+ .append(id)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append("children.clear();\n");
@@ -6042,10 +6326,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append("int ")
.append(notStartColumn)
.append(" = column;\n");
- sb.append(pad)
- .append("int notPending")
- .append(id)
- .append(" = savePendingLeading();\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append("int notPending")
+ .append(id)
+ .append(" = savePendingLeading();\n");
+ }
if (addToChildren) {
sb.append(pad)
.append("var ")
@@ -6061,10 +6347,12 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(", ")
.append(notStartColumn)
.append(");\n");
- sb.append(pad)
- .append("restorePendingLeading(notPending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append("restorePendingLeading(notPending")
+ .append(id)
+ .append(");\n");
+ }
if (addToChildren) {
sb.append(pad)
.append("children.clear();\n");
@@ -6140,7 +6428,7 @@ private void generateCstExpressionCode(StringBuilder sb,
sb.append(pad)
.append(" var tbText")
.append(id)
- .append(" = substring(")
+ .append(" = substringSpan(")
.append(tbStartPos)
.append(", pos);\n");
sb.append(pad)
@@ -6165,7 +6453,9 @@ private void generateCstExpressionCode(StringBuilder sb,
.append(tokenRuleId)
.append(", tbText")
.append(id)
- .append(", takePendingLeading(), List.of());\n");
+ .append(", ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
if (addToChildren) {
sb.append(pad)
.append(" children.add(tbNode")
@@ -6380,10 +6670,12 @@ private void emitCstChoiceAlt(StringBuilder sb,
.append("Column);\n");
// Roll back any pending-leading trivia captured inside the failed alt,
// so sibling alternatives start from the same buffer state as the Choice entry.
- sb.append(pad)
- .append(" restorePendingLeading(choicePending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(choicePending")
+ .append(id)
+ .append(");\n");
+ }
}
/**
@@ -6572,10 +6864,12 @@ private void emitCstChoiceDispatchF(StringBuilder sb,
.append("Line, ")
.append(choiceStart)
.append("Column);\n");
- sb.append(pad)
- .append(" restorePendingLeading(choicePending")
- .append(id)
- .append(");\n");
+ if (!config.triviaPostPass()) {
+ sb.append(pad)
+ .append(" restorePendingLeading(choicePending")
+ .append(id)
+ .append(");\n");
+ }
emitCstChoiceAltChainClosed(sb,
defaults,
id,
@@ -6750,31 +7044,60 @@ private Trivia classifyTrivia(SourceSpan span, String text) {
// === Pending-leading trivia helpers ===
// See docs/TRIVIA-ATTRIBUTION.md for the attribution rule.
- private void appendPending(List captured) {
- if (!captured.isEmpty()) {
- pendingLeadingTrivia.addAll(captured);
- }
- }
+ """);
+ // Step 4 commit 5: Under triviaPostPass=true, the post-pass produces correct
+ // attribution from the trivia stream. The buffer machinery is dead work — the
+ // call sites still call these helpers, but the helpers no-op (and the buffer
+ // field stays declared but unused — see PR description for rationale).
+ if (config.triviaPostPass()) {
+ sb.append("""
+ private void appendPending(List captured) {
+ // no-op: triviaPostPass=true; attribution handled by post-pass.
+ }
- private List takePendingLeading() {
- if (pendingLeadingTrivia.isEmpty()) {
- return List.of();
- }
- var snapshot = List.copyOf(pendingLeadingTrivia);
- pendingLeadingTrivia.clear();
- return snapshot;
- }
+ private List takePendingLeading() {
+ return List.of();
+ }
- private int savePendingLeading() {
- return pendingLeadingTrivia.size();
- }
+ private int savePendingLeading() {
+ return 0;
+ }
- private void restorePendingLeading(int snapshot) {
- if (pendingLeadingTrivia.size() > snapshot) {
- pendingLeadingTrivia.subList(snapshot, pendingLeadingTrivia.size()).clear();
- }
- }
+ private void restorePendingLeading(int snapshot) {
+ // no-op: triviaPostPass=true; attribution handled by post-pass.
+ }
+
+ """);
+ }else {
+ sb.append("""
+ private void appendPending(List captured) {
+ if (!captured.isEmpty()) {
+ pendingLeadingTrivia.addAll(captured);
+ }
+ }
+ private List takePendingLeading() {
+ if (pendingLeadingTrivia.isEmpty()) {
+ return List.of();
+ }
+ var snapshot = List.copyOf(pendingLeadingTrivia);
+ pendingLeadingTrivia.clear();
+ return snapshot;
+ }
+
+ private int savePendingLeading() {
+ return pendingLeadingTrivia.size();
+ }
+
+ private void restorePendingLeading(int snapshot) {
+ if (pendingLeadingTrivia.size() > snapshot) {
+ pendingLeadingTrivia.subList(snapshot, pendingLeadingTrivia.size()).clear();
+ }
+ }
+
+ """);
+ }
+ sb.append("""
private List concatTrivia(List first, List second) {
if (first.isEmpty()) return second;
if (second.isEmpty()) return first;
@@ -6908,12 +7231,17 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) {
private static final class CstParseResult {
final boolean success;
final CstNode node; // raw nullable
- final String text; // raw nullable
+ // Cleanup F.2: widen text from String to CharSequence so the
+ // generator can emit StringSpan into this field in F.3 without
+ // forcing String materialization. Consumers (textOrEmpty helper,
+ // CstNode.Terminal/Token construction via wrapWithRuleName) all
+ // accept CharSequence, so this is a non-breaking widening.
+ final CharSequence text; // raw nullable
final String expected; // raw nullable
final SourceLocation endLocation; // raw nullable
final boolean cutFailed;
- private CstParseResult(boolean success, CstNode node, String text, String expected, SourceLocation endLocation, boolean cutFailed) {
+ private CstParseResult(boolean success, CstNode node, CharSequence text, String expected, SourceLocation endLocation, boolean cutFailed) {
this.success = success;
this.node = node;
this.text = text;
@@ -6926,7 +7254,7 @@ private CstParseResult(boolean success, CstNode node, String text, String expect
boolean isFailure() { return !success; }
boolean isCutFailure() { return !success && cutFailed; }
- static CstParseResult success(CstNode node, String text, SourceLocation endLocation) {
+ static CstParseResult success(CstNode node, CharSequence text, SourceLocation endLocation) {
return new CstParseResult(true, node, text, null, endLocation, false);
}
@@ -6936,7 +7264,7 @@ static CstParseResult success(CstNode node, String text, SourceLocation endLocat
// their endLocation field discarded by callers, so skipping the
// location() call here removes the dominant SourceLocation allocator
// on the self-host fixture.
- static CstParseResult successNoLoc(CstNode node, String text) {
+ static CstParseResult successNoLoc(CstNode node, CharSequence text) {
return new CstParseResult(true, node, text, null, null, false);
}
@@ -6963,12 +7291,16 @@ CstParseResult asRegularFailure() {
private static final class CstParseResult {
final boolean success;
final Option node;
- final Option text;
+ // Cleanup F.2: widen text from Option to Option
+ // so F.3 can emit StringSpan into this field without forcing
+ // String materialization. Consumers (textOrEmpty helper) read
+ // via .or("") which produces CharSequence, accepted everywhere.
+ final Option text;
final Option expected;
final Option endLocation;
final boolean cutFailed;
- private CstParseResult(boolean success, Option node, Option text, Option expected, Option endLocation, boolean cutFailed) {
+ private CstParseResult(boolean success, Option node, Option text, Option expected, Option endLocation, boolean cutFailed) {
this.success = success;
this.node = node;
this.text = text;
@@ -6981,14 +7313,14 @@ private CstParseResult(boolean success, Option node, Option tex
boolean isFailure() { return !success; }
boolean isCutFailure() { return !success && cutFailed; }
- static CstParseResult success(CstNode node, String text, SourceLocation endLocation) {
- return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.some(endLocation), false);
+ static CstParseResult success(CstNode node, CharSequence text, SourceLocation endLocation) {
+ return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.some(endLocation), false);
}
// Phase 1.7 (D2): intermediate-result success that omits endLocation.
// See mutable variant above for rationale.
- static CstParseResult successNoLoc(CstNode node, String text) {
- return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.none(), false);
+ static CstParseResult successNoLoc(CstNode node, CharSequence text) {
+ return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.none(), false);
}
static CstParseResult failure(String expected) {
@@ -7011,6 +7343,718 @@ CstParseResult asRegularFailure() {
}
}
+ // === Step 4 commit 4: embedded TriviaPostPass (flag-ON only) ===
+ /**
+ * Emit a {@code private static final class TriviaPostPass} embedded in the
+ * generated parser. Provides {@code assignTrivia(input, cst, leadingScanFrom)}
+ * with the same semantics as
+ * {@link org.pragmatica.peg.tree.TriviaPostPass#assignTrivia(String, CstNode, org.pragmatica.peg.grammar.Grammar, int)}.
+ *
+ *
The embedded class is fully self-contained: it depends only on the
+ * generated parser's own embedded {@code CstNode}, {@code Trivia}, and
+ * {@code SourceSpan} types (which themselves depend only on
+ * {@code pragmatica-lite:core}). It never imports any peglib runtime class,
+ * preserving the standalone-parser invariant.
+ *
+ *
The whitespace matcher is generated from the grammar's
+ * {@code %whitespace} expression at generation time, walking the expression
+ * tree and emitting Java code per Expression subtype. This avoids needing
+ * the runtime {@code Grammar} object at parse time.
+ */
+ private void generateCstTriviaPostPass(StringBuilder sb) {
+ var hasWhitespace = grammar.whitespace()
+ .isPresent();
+ sb.append(" // === Embedded TriviaPostPass (Step 4 commit 4 — flag-ON) ===\n");
+ sb.append(" /**\n");
+ sb.append(" * Re-derive leading/trailing trivia attribution from\n");
+ sb.append(" * (input, span, %whitespace) after the engine returns its CST.\n");
+ sb.append(" * Mirrors org.pragmatica.peg.tree.TriviaPostPass at the runtime.\n");
+ sb.append(" */\n");
+ sb.append(" private static final class TriviaPostPass {\n");
+ sb.append(" private TriviaPostPass() {}\n\n");
+ sb.append(" static CstNode assignTrivia(String input, CstNode cst, int leadingScanFrom) {\n");
+ sb.append(" int rootStart = cst.span().startOffset();\n");
+ sb.append(" if (leadingScanFrom < 0 || leadingScanFrom > rootStart) {\n");
+ sb.append(" // Generator span semantics: rootStart may be 0 (span includes\n");
+ sb.append(" // leading whitespace). Clamp leadingScanFrom rather than reject.\n");
+ sb.append(" if (leadingScanFrom > 0 && rootStart == 0) {\n");
+ sb.append(" leadingScanFrom = 0;\n");
+ sb.append(" } else {\n");
+ sb.append(" throw new IllegalArgumentException(\n");
+ sb.append(" \"leadingScanFrom \" + leadingScanFrom + \" out of range [0, \" + rootStart + \"]\");\n");
+ sb.append(" }\n");
+ sb.append(" }\n");
+ sb.append(" // Build line-start table once per pass (O(N)) so that per-chunk\n");
+ sb.append(" // computeSpan is O(log N) instead of O(from). Eliminates an O(N²)\n");
+ sb.append(" // hotspot for whitespace-rich source.\n");
+ sb.append(" int[] lineStarts = buildLineStarts(input);\n");
+ sb.append(" return rebuildRoot(input, cst, lineStarts, leadingScanFrom);\n");
+ sb.append(" }\n\n");
+ sb.append(" private static int[] buildLineStarts(String input) {\n");
+ sb.append(" int len = input.length();\n");
+ sb.append(" int[] starts = new int[Math.max(8, len / 16 + 4)];\n");
+ sb.append(" int n = 0;\n");
+ sb.append(" starts[n++] = 0;\n");
+ sb.append(" for (int i = 0; i < len; i++) {\n");
+ sb.append(" if (input.charAt(i) == '\\n') {\n");
+ sb.append(" if (n == starts.length) {\n");
+ sb.append(" starts = java.util.Arrays.copyOf(starts, starts.length * 2);\n");
+ sb.append(" }\n");
+ sb.append(" starts[n++] = i + 1;\n");
+ sb.append(" }\n");
+ sb.append(" }\n");
+ sb.append(" return java.util.Arrays.copyOf(starts, n);\n");
+ sb.append(" }\n\n");
+ sb.append(" private static int[] lineColAt(int[] lineStarts, int offset) {\n");
+ sb.append(" int idx = java.util.Arrays.binarySearch(lineStarts, offset);\n");
+ sb.append(" if (idx < 0) idx = -idx - 2;\n");
+ sb.append(" int line = idx + 1;\n");
+ sb.append(" int col = offset - lineStarts[idx] + 1;\n");
+ sb.append(" return new int[]{line, col};\n");
+ sb.append(" }\n\n");
+ sb.append(" static List scanWhitespace(String input, int from, int to, int[] lineStarts) {\n");
+ sb.append(" if (from >= to) return List.of();\n");
+ if (!hasWhitespace) {
+ sb.append(" return List.of();\n");
+ }else {
+ sb.append(" var trivia = new ArrayList();\n");
+ sb.append(" int pos = from;\n");
+ sb.append(" while (pos < to) {\n");
+ sb.append(" int matched = matchWsInner(input, pos, to);\n");
+ sb.append(" if (matched < 0 || matched == pos) break;\n");
+ sb.append(" var text = input.substring(pos, matched);\n");
+ sb.append(" var span = computeSpan(lineStarts, pos, matched);\n");
+ sb.append(" trivia.add(classify(span, text));\n");
+ sb.append(" pos = matched;\n");
+ sb.append(" }\n");
+ sb.append(" return List.copyOf(trivia);\n");
+ }
+ sb.append(" }\n\n");
+ // === Tree rebuild ===
+ // Span-semantics adapter: the generated parser's rule wrappers may use
+ // a span whose startOffset INCLUDES leading whitespace (rule entry
+ // captures startOffset BEFORE skipWhitespace, then the leading trivia
+ // is attached as the wrapper's leadingTrivia). The runtime engine, by
+ // contrast, captures startOffset AFTER skipWhitespace. Detect this by
+ // measuring whether the root's existing leadingTrivia text overlaps
+ // [rootStart, rootStart + leadingLen). If so, use rootStart as the
+ // "leading-end" boundary AND leadingScanFrom + (rootStart - leadingScanFrom)
+ // = rootStart for the leading scan. Otherwise (runtime semantics),
+ // scan up to rootStart as the runtime post-pass does.
+ // H2 (Cleanup B): the wrapper-span semantics decision (does the rule
+ // wrapper's span INCLUDE its leading whitespace?) is a property of the
+ // GENERATED parser globally, not of an individual node. Detect it once
+ // at the root and propagate the boolean through the recursion so each
+ // NonTerminal can advance the child-cursor past leadingLen without
+ // re-probing scanWhitespace per node.
+ sb.append(" private static CstNode rebuildRoot(String input, CstNode root, int[] lineStarts, int leadingScanFrom) {\n");
+ sb.append(" int rootStart = root.span().startOffset();\n");
+ sb.append(" int rootEnd = root.span().endOffset();\n");
+ sb.append(" int leadingTextLen = totalTriviaLength(root.leadingTrivia());\n");
+ sb.append(" // Generator semantics: span includes leading WS. The actual content\n");
+ sb.append(" // begins at (rootStart + leadingTextLen), and the leading WS lives\n");
+ sb.append(" // in [rootStart, rootStart + leadingTextLen).\n");
+ sb.append(" // Runtime semantics: span excludes leading WS. leadingTextLen is in\n");
+ sb.append(" // the gap [leadingScanFrom, rootStart). We distinguish by checking\n");
+ sb.append(" // whether scanning the pre-rootStart gap yields the leading.\n");
+ sb.append(" int leadingScanEnd;\n");
+ sb.append(" boolean wrapperSpanIncludesLeading = false;\n");
+ sb.append(" if (leadingTextLen > 0 && rootStart + leadingTextLen <= input.length()) {\n");
+ sb.append(" // Hypothesis: generator semantics — leading occupies [rootStart, rootStart + leadingTextLen).\n");
+ sb.append(" // Verify by re-scanning [rootStart, rootStart + leadingTextLen) and checking it consumes exactly that range.\n");
+ sb.append(" int probeEnd = rootStart + leadingTextLen;\n");
+ sb.append(" var probe = scanWhitespace(input, rootStart, probeEnd, lineStarts);\n");
+ sb.append(" if (totalTriviaLength(probe) == leadingTextLen) {\n");
+ sb.append(" leadingScanEnd = probeEnd;\n");
+ sb.append(" wrapperSpanIncludesLeading = true;\n");
+ sb.append(" } else {\n");
+ sb.append(" leadingScanEnd = rootStart;\n");
+ sb.append(" }\n");
+ sb.append(" } else {\n");
+ sb.append(" leadingScanEnd = rootStart;\n");
+ sb.append(" }\n");
+ sb.append(" var leading = scanWhitespace(input, leadingScanFrom, leadingScanEnd, lineStarts);\n");
+ sb.append(" var trailingExternal = scanWhitespace(input, rootEnd, input.length(), lineStarts);\n");
+ sb.append(" return rebuildSelf(input, root, lineStarts, leading, trailingExternal, java.util.List.of(), wrapperSpanIncludesLeading);\n");
+ sb.append(" }\n\n");
+ sb.append(" private static int totalTriviaLength(List trivia) {\n");
+ sb.append(" int total = 0;\n");
+ sb.append(" for (var t : trivia) total += t.text().length();\n");
+ sb.append(" return total;\n");
+ sb.append(" }\n\n");
+ // rebuildSelf: extraTrailing sticks on this node's own trailing slot
+ // (root post-EOF only). drainExtra propagates DOWN into the
+ // deepest-rightmost-leaf — H1 eliminates the post-construction
+ // attachTrailingToTail spine rebuild.
+ sb.append(" private static CstNode rebuildSelf(String input, CstNode node, int[] lineStarts,\n");
+ sb.append(" List leading, List extraTrailing,\n");
+ sb.append(" List drainExtra, boolean wrapperSpanIncludesLeading) {\n");
+ sb.append(" return switch (node) {\n");
+ sb.append(" case CstNode.NonTerminal nt -> rebuildNonTerminal(input, nt, lineStarts, leading, extraTrailing, drainExtra, wrapperSpanIncludesLeading);\n");
+ sb.append(" case CstNode.Terminal t -> rebuildTerminal(t, leading, extraTrailing, drainExtra);\n");
+ sb.append(" case CstNode.Token tk -> rebuildToken(tk, leading, extraTrailing, drainExtra);\n");
+ if (errorReporting == ErrorReporting.ADVANCED) {
+ sb.append(" case CstNode.Error e -> rebuildError(e, leading, extraTrailing, drainExtra);\n");
+ }
+ sb.append(" };\n");
+ sb.append(" }\n\n");
+ // Cleanup D: leaf rebuild helpers — skip allocation when the rebuild
+ // would yield content-equal nodes (all incoming trivia empty AND
+ // existing trivia already empty).
+ sb.append(" private static boolean canSkipLeafRebuild(List leading, List extraTrailing,\n");
+ sb.append(" List drainExtra,\n");
+ sb.append(" List existingLeading, List existingTrailing) {\n");
+ sb.append(" return leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty()\n");
+ sb.append(" && existingLeading.isEmpty() && existingTrailing.isEmpty();\n");
+ sb.append(" }\n\n");
+ sb.append(" private static CstNode.Terminal rebuildTerminal(CstNode.Terminal t, List leading,\n");
+ sb.append(" List extraTrailing, List drainExtra) {\n");
+ sb.append(" if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, t.leadingTrivia(), t.trailingTrivia())) {\n");
+ sb.append(" return t;\n");
+ sb.append(" }\n");
+ sb.append(" return new CstNode.Terminal(t.id(), t.span(), t.rule(), t.text(), leading, combine(drainExtra, extraTrailing));\n");
+ sb.append(" }\n\n");
+ sb.append(" private static CstNode.Token rebuildToken(CstNode.Token tk, List leading,\n");
+ sb.append(" List extraTrailing, List drainExtra) {\n");
+ sb.append(" if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, tk.leadingTrivia(), tk.trailingTrivia())) {\n");
+ sb.append(" return tk;\n");
+ sb.append(" }\n");
+ sb.append(" return new CstNode.Token(tk.id(), tk.span(), tk.rule(), tk.text(), leading, combine(drainExtra, extraTrailing));\n");
+ sb.append(" }\n\n");
+ if (errorReporting == ErrorReporting.ADVANCED) {
+ sb.append(" private static CstNode.Error rebuildError(CstNode.Error e, List leading,\n");
+ sb.append(" List extraTrailing, List drainExtra) {\n");
+ sb.append(" if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, e.leadingTrivia(), e.trailingTrivia())) {\n");
+ sb.append(" return e;\n");
+ sb.append(" }\n");
+ sb.append(" return new CstNode.Error(e.id(), e.span(), e.skippedText(), e.expected(), leading, combine(drainExtra, extraTrailing));\n");
+ sb.append(" }\n\n");
+ }
+ sb.append(" private static CstNode rebuildChild(String input, CstNode child, int[] lineStarts, int prevEnd,\n");
+ sb.append(" List drainExtra, boolean wrapperSpanIncludesLeading) {\n");
+ sb.append(" int childStart = child.span().startOffset();\n");
+ sb.append(" var leading = scanWhitespace(input, prevEnd, childStart, lineStarts);\n");
+ sb.append(" return switch (child) {\n");
+ sb.append(" case CstNode.NonTerminal nt -> rebuildNonTerminal(input, nt, lineStarts, leading, java.util.List.of(), drainExtra, wrapperSpanIncludesLeading);\n");
+ sb.append(" case CstNode.Terminal t -> rebuildTerminal(t, leading, java.util.List.of(), drainExtra);\n");
+ sb.append(" case CstNode.Token tk -> rebuildToken(tk, leading, java.util.List.of(), drainExtra);\n");
+ if (errorReporting == ErrorReporting.ADVANCED) {
+ sb.append(" case CstNode.Error e -> rebuildError(e, leading, java.util.List.of(), drainExtra);\n");
+ }
+ sb.append(" };\n");
+ sb.append(" }\n\n");
+ sb.append(" private static CstNode.NonTerminal rebuildNonTerminal(String input, CstNode.NonTerminal nt, int[] lineStarts,\n");
+ sb.append(" List leading, List extraTrailing,\n");
+ sb.append(" List drainExtra, boolean wrapperSpanIncludesLeading) {\n");
+ sb.append(" int spanStart = nt.span().startOffset();\n");
+ sb.append(" int spanEnd = nt.span().endOffset();\n");
+ sb.append(" // H2: span-semantics decided once at the root. When wrapper span\n");
+ sb.append(" // includes its own leading WS, advance cursor past leadingLen so the\n");
+ sb.append(" // first child's leading scan does not re-emit the same trivia. No\n");
+ sb.append(" // per-node probe scan.\n");
+ sb.append(" int cursor = spanStart;\n");
+ sb.append(" if (wrapperSpanIncludesLeading) {\n");
+ sb.append(" int leadingLen = totalTriviaLength(leading);\n");
+ sb.append(" if (leadingLen > 0 && spanStart + leadingLen <= input.length()) {\n");
+ sb.append(" cursor = spanStart + leadingLen;\n");
+ sb.append(" }\n");
+ sb.append(" }\n");
+ sb.append(" var children = nt.children();\n");
+ sb.append(" int childCount = children.size();\n");
+ sb.append(" if (childCount == 0) {\n");
+ sb.append(" // No children: gap [cursor, spanEnd) is the wrapper's internal\n");
+ sb.append(" // trailing. Combine with caller-supplied drainExtra (no deeper\n");
+ sb.append(" // descendant to absorb it) and extraTrailing.\n");
+ sb.append(" var internalTrailing = scanWhitespace(input, cursor, spanEnd, lineStarts);\n");
+ sb.append(" // Cleanup D: skip allocation if rebuild would yield identical content.\n");
+ sb.append(" if (leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty()\n");
+ sb.append(" && internalTrailing.isEmpty()\n");
+ sb.append(" && nt.leadingTrivia().isEmpty() && nt.trailingTrivia().isEmpty()) {\n");
+ sb.append(" return nt;\n");
+ sb.append(" }\n");
+ sb.append(" return new CstNode.NonTerminal(nt.id(), nt.span(), nt.rule(), children, leading,\n");
+ sb.append(" combine(combine(drainExtra, internalTrailing), extraTrailing));\n");
+ sb.append(" }\n");
+ sb.append(" // H1: thread orphan trailing INTO the last child's rebuildChild as\n");
+ sb.append(" // drainExtra instead of rebuilding the spine afterwards. Mirrors\n");
+ sb.append(" // PegEngine.attachTrailingToTail (Bug C' compensation): the orphan\n");
+ sb.append(" // trivia ends up in the deepest-rightmost-leaf's trailing slot,\n");
+ sb.append(" // matching CstReconstruct.emit's last-child-only emission.\n");
+ sb.append(" int lastChildEnd = children.get(childCount - 1).span().endOffset();\n");
+ sb.append(" var internalTrailing = scanWhitespace(input, lastChildEnd, spanEnd, lineStarts);\n");
+ sb.append(" var lastChildDrain = combine(drainExtra, internalTrailing);\n");
+ sb.append(" // Cleanup D: defer ArrayList allocation until first child diverges; skip\n");
+ sb.append(" // wrapper-rebuild entirely when no incoming trivia and no internal change.\n");
+ sb.append(" boolean canSkip = leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty()\n");
+ sb.append(" && internalTrailing.isEmpty()\n");
+ sb.append(" && nt.leadingTrivia().isEmpty() && nt.trailingTrivia().isEmpty();\n");
+ sb.append(" ArrayList newChildren = null;\n");
+ sb.append(" for (int i = 0; i < childCount; i++) {\n");
+ sb.append(" var c = children.get(i);\n");
+ sb.append(" var drainForThis = (i == childCount - 1) ? lastChildDrain : java.util.List.of();\n");
+ sb.append(" var rebuilt = rebuildChild(input, c, lineStarts, cursor, drainForThis, wrapperSpanIncludesLeading);\n");
+ sb.append(" if (newChildren == null) {\n");
+ sb.append(" if (rebuilt != c) {\n");
+ sb.append(" newChildren = new ArrayList(childCount);\n");
+ sb.append(" for (int j = 0; j < i; j++) newChildren.add(children.get(j));\n");
+ sb.append(" newChildren.add(rebuilt);\n");
+ sb.append(" }\n");
+ sb.append(" } else {\n");
+ sb.append(" newChildren.add(rebuilt);\n");
+ sb.append(" }\n");
+ sb.append(" cursor = c.span().endOffset();\n");
+ sb.append(" }\n");
+ sb.append(" if (newChildren == null && canSkip) {\n");
+ sb.append(" return nt;\n");
+ sb.append(" }\n");
+ sb.append(" var finalChildren = newChildren == null ? children : List.copyOf(newChildren);\n");
+ sb.append(" return new CstNode.NonTerminal(nt.id(), nt.span(), nt.rule(), finalChildren, leading, extraTrailing);\n");
+ sb.append(" }\n\n");
+ sb.append(" private static List combine(List a, List b) {\n");
+ sb.append(" if (a.isEmpty()) return b;\n");
+ sb.append(" if (b.isEmpty()) return a;\n");
+ sb.append(" var combined = new ArrayList(a.size() + b.size());\n");
+ sb.append(" combined.addAll(a);\n");
+ sb.append(" combined.addAll(b);\n");
+ sb.append(" return List.copyOf(combined);\n");
+ sb.append(" }\n\n");
+ sb.append(" private static Trivia classify(SourceSpan span, String text) {\n");
+ sb.append(" if (text.startsWith(\"//\")) {\n");
+ sb.append(" return new Trivia.LineComment(span, text);\n");
+ sb.append(" } else if (text.startsWith(\"/*\")) {\n");
+ sb.append(" return new Trivia.BlockComment(span, text);\n");
+ sb.append(" } else {\n");
+ sb.append(" return new Trivia.Whitespace(span, text);\n");
+ sb.append(" }\n");
+ sb.append(" }\n\n");
+ // Static copy of matchesPattern for char-class matching inside the post-pass.
+ // Mirrors the instance-method MATCHES_PATTERN_METHOD emitted elsewhere; kept
+ // as a separate static copy because the embedded class cannot reach instance
+ // methods of the enclosing parser.
+ sb.append(" private static boolean matchesPattern(char c, String pattern, boolean caseInsensitive) {\n");
+ sb.append(" char testChar = caseInsensitive ? Character.toLowerCase(c) : c;\n");
+ sb.append(" int i = 0;\n");
+ sb.append(" while (i < pattern.length()) {\n");
+ sb.append(" char start = pattern.charAt(i);\n");
+ sb.append(" if (start == '\\\\' && i + 1 < pattern.length()) {\n");
+ sb.append(" char escaped = pattern.charAt(i + 1);\n");
+ sb.append(" int consumed = 2;\n");
+ sb.append(" char expected = switch (escaped) {\n");
+ sb.append(" case 'n' -> '\\n';\n");
+ sb.append(" case 'r' -> '\\r';\n");
+ sb.append(" case 't' -> '\\t';\n");
+ sb.append(" case '\\\\' -> '\\\\';\n");
+ sb.append(" case ']' -> ']';\n");
+ sb.append(" case '-' -> '-';\n");
+ sb.append(" case 'x' -> {\n");
+ sb.append(" if (i + 4 <= pattern.length()) {\n");
+ sb.append(" try {\n");
+ sb.append(" var hex = pattern.substring(i + 2, i + 4);\n");
+ sb.append(" consumed = 4;\n");
+ sb.append(" yield (char) Integer.parseInt(hex, 16);\n");
+ sb.append(" } catch (NumberFormatException e) { yield 'x'; }\n");
+ sb.append(" }\n");
+ sb.append(" yield 'x';\n");
+ sb.append(" }\n");
+ sb.append(" case 'u' -> {\n");
+ sb.append(" if (i + 6 <= pattern.length()) {\n");
+ sb.append(" try {\n");
+ sb.append(" var hex = pattern.substring(i + 2, i + 6);\n");
+ sb.append(" consumed = 6;\n");
+ sb.append(" yield (char) Integer.parseInt(hex, 16);\n");
+ sb.append(" } catch (NumberFormatException e) { yield 'u'; }\n");
+ sb.append(" }\n");
+ sb.append(" yield 'u';\n");
+ sb.append(" }\n");
+ sb.append(" default -> escaped;\n");
+ sb.append(" };\n");
+ sb.append(" if (caseInsensitive) expected = Character.toLowerCase(expected);\n");
+ sb.append(" if (testChar == expected) return true;\n");
+ sb.append(" i += consumed;\n");
+ sb.append(" continue;\n");
+ sb.append(" }\n");
+ sb.append(" if (i + 2 < pattern.length() && pattern.charAt(i + 1) == '-') {\n");
+ sb.append(" char end = pattern.charAt(i + 2);\n");
+ sb.append(" if (caseInsensitive) {\n");
+ sb.append(" start = Character.toLowerCase(start);\n");
+ sb.append(" end = Character.toLowerCase(end);\n");
+ sb.append(" }\n");
+ sb.append(" if (testChar >= start && testChar <= end) return true;\n");
+ sb.append(" i += 3;\n");
+ sb.append(" } else {\n");
+ sb.append(" if (caseInsensitive) start = Character.toLowerCase(start);\n");
+ sb.append(" if (testChar == start) return true;\n");
+ sb.append(" i++;\n");
+ sb.append(" }\n");
+ sb.append(" }\n");
+ sb.append(" return false;\n");
+ sb.append(" }\n\n");
+ sb.append(" private static SourceSpan computeSpan(int[] lineStarts, int from, int to) {\n");
+ sb.append(" int[] startLc = lineColAt(lineStarts, from);\n");
+ sb.append(" int[] endLc = lineColAt(lineStarts, to);\n");
+ sb.append(" return new SourceSpan(startLc[0], startLc[1], from, endLc[0], endLc[1], to);\n");
+ sb.append(" }\n\n");
+ // Emit per-expression matcher methods.
+ if (hasWhitespace) {
+ var wsExpr = grammar.whitespace()
+ .unwrap();
+ var inner = ExpressionShape.extractInnerExpression(wsExpr);
+ var ctx = new TriviaPostPassEmitContext();
+ // Reserve method id 0 for the entry point.
+ int entryId = ctx.allocate(inner);
+ // Emit the entry method as `matchWsInner`; rename later via direct emission.
+ sb.append(" private static int matchWsInner(String input, int pos, int limit) {\n");
+ sb.append(" return ")
+ .append(ctx.callName(entryId))
+ .append("(input, pos, limit);\n");
+ sb.append(" }\n\n");
+ // Emit all queued methods.
+ while (ctx.hasPending()) {
+ var entry = ctx.nextPending();
+ emitTriviaPostPassMatcher(sb, entry.expr(), entry.id(), ctx);
+ }
+ }
+ sb.append(" }\n\n");
+ }
+
+ /**
+ * Per-emission state for the embedded TriviaPostPass matcher generation.
+ * Allocates unique method ids per expression, deduplicating identical
+ * Reference targets and queueing each newly seen expression for emission.
+ */
+ private static final class TriviaPostPassEmitContext {
+ private final java.util.IdentityHashMap idByExpr = new java.util.IdentityHashMap<>();
+ private final java.util.HashMap idByRule = new java.util.HashMap<>();
+ private final java.util.ArrayDeque queue = new java.util.ArrayDeque<>();
+ private int nextId = 0;
+
+ int allocate(Expression expr) {
+ var existing = idByExpr.get(expr);
+ if (existing != null) return existing;
+ int id = nextId++ ;
+ idByExpr.put(expr, id);
+ queue.add(new TriviaPostPassEntry(id, expr));
+ return id;
+ }
+
+ int allocateForRule(String ruleName, Expression body) {
+ var existing = idByRule.get(ruleName);
+ if (existing != null) return existing;
+ int id = allocate(body);
+ idByRule.put(ruleName, id);
+ return id;
+ }
+
+ boolean hasPending() {
+ return !queue.isEmpty();
+ }
+
+ TriviaPostPassEntry nextPending() {
+ return queue.removeFirst();
+ }
+
+ String callName(int id) {
+ return "matchWs_" + id;
+ }
+ }
+
+ private record TriviaPostPassEntry(int id, Expression expr) {}
+
+ /**
+ * Emit a single matcher method for {@code expr} into {@code sb}, naming it
+ * {@code matchWs_(String input, int pos, int limit)}. Returns the next
+ * absolute offset on a successful match or {@code -1} on failure. Sub-
+ * expressions are queued via {@link TriviaPostPassEmitContext#allocate} and
+ * emitted in subsequent iterations.
+ */
+ private void emitTriviaPostPassMatcher(StringBuilder sb, Expression expr, int id, TriviaPostPassEmitContext ctx) {
+ sb.append(" private static int ")
+ .append(ctx.callName(id))
+ .append("(String input, int pos, int limit) {\n");
+ emitTriviaPostPassMatchBody(sb, expr, ctx, " ");
+ sb.append(" }\n\n");
+ }
+
+ /**
+ * Emit the body of a matcher method for {@code expr}. The body must
+ * {@code return} an int value (next position or -1) on every control path.
+ */
+ private void emitTriviaPostPassMatchBody(StringBuilder sb,
+ Expression expr,
+ TriviaPostPassEmitContext ctx,
+ String indent) {
+ switch (expr) {
+ case Expression.Literal lit -> emitTppLiteral(sb, lit, indent);
+ case Expression.CharClass cc -> emitTppCharClass(sb, cc, indent);
+ case Expression.Any ignored -> {
+ sb.append(indent)
+ .append("return pos < limit ? pos + 1 : -1;\n");
+ }
+ case Expression.Reference ref -> emitTppReference(sb, ref, ctx, indent);
+ case Expression.Sequence seq -> emitTppSequence(sb, seq, ctx, indent);
+ case Expression.Choice ch -> emitTppChoice(sb, ch, ctx, indent);
+ case Expression.ZeroOrMore zom -> emitTppZeroOrMore(sb, zom.expression(), ctx, indent);
+ case Expression.OneOrMore oom -> emitTppOneOrMore(sb, oom.expression(), ctx, indent);
+ case Expression.Optional opt -> emitTppOptional(sb, opt.expression(), ctx, indent);
+ case Expression.Repetition rep -> emitTppRepetition(sb, rep, ctx, indent);
+ case Expression.And and -> {
+ int sub = ctx.allocate(and.expression());
+ sb.append(indent)
+ .append("return ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit) >= 0 ? pos : -1;\n");
+ }
+ case Expression.Not not -> {
+ int sub = ctx.allocate(not.expression());
+ sb.append(indent)
+ .append("return ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit) < 0 ? pos : -1;\n");
+ }
+ case Expression.Group g -> emitTriviaPostPassMatchBody(sb, g.expression(), ctx, indent);
+ case Expression.TokenBoundary tb -> emitTriviaPostPassMatchBody(sb, tb.expression(), ctx, indent);
+ case Expression.Ignore ig -> emitTriviaPostPassMatchBody(sb, ig.expression(), ctx, indent);
+ case Expression.Capture cap -> emitTriviaPostPassMatchBody(sb, cap.expression(), ctx, indent);
+ case Expression.CaptureScope cs -> emitTriviaPostPassMatchBody(sb, cs.expression(), ctx, indent);
+ case Expression.BackReference ignored -> sb.append(indent)
+ .append("return -1;\n");
+ case Expression.Dictionary ignored -> sb.append(indent)
+ .append("return -1;\n");
+ case Expression.Cut ignored -> sb.append(indent)
+ .append("return pos;\n");
+ }
+ }
+
+ private void emitTppLiteral(StringBuilder sb, Expression.Literal lit, String indent) {
+ var literalStr = lit.text();
+ sb.append(indent)
+ .append("String text = ")
+ .append(quoteJavaString(literalStr))
+ .append(";\n");
+ sb.append(indent)
+ .append("int len = text.length();\n");
+ sb.append(indent)
+ .append("if (pos + len > limit) return -1;\n");
+ if (lit.caseInsensitive()) {
+ sb.append(indent)
+ .append("for (int i = 0; i < len; i++) {\n");
+ sb.append(indent)
+ .append(" if (Character.toLowerCase(input.charAt(pos + i)) != Character.toLowerCase(text.charAt(i))) return -1;\n");
+ sb.append(indent)
+ .append("}\n");
+ }else {
+ sb.append(indent)
+ .append("for (int i = 0; i < len; i++) {\n");
+ sb.append(indent)
+ .append(" if (input.charAt(pos + i) != text.charAt(i)) return -1;\n");
+ sb.append(indent)
+ .append("}\n");
+ }
+ sb.append(indent)
+ .append("return pos + len;\n");
+ }
+
+ private void emitTppCharClass(StringBuilder sb, Expression.CharClass cc, String indent) {
+ sb.append(indent)
+ .append("if (pos >= limit) return -1;\n");
+ sb.append(indent)
+ .append("char c = input.charAt(pos);\n");
+ sb.append(indent)
+ .append("boolean inClass = matchesPattern(c, ")
+ .append(quoteJavaString(cc.pattern()))
+ .append(", ")
+ .append(cc.caseInsensitive())
+ .append(");\n");
+ sb.append(indent)
+ .append("boolean ok = ")
+ .append(cc.negated()
+ ? "!inClass"
+ : "inClass")
+ .append(";\n");
+ sb.append(indent)
+ .append("return ok ? pos + 1 : -1;\n");
+ }
+
+ private void emitTppReference(StringBuilder sb,
+ Expression.Reference ref,
+ TriviaPostPassEmitContext ctx,
+ String indent) {
+ var ruleOpt = grammar.rule(ref.ruleName());
+ if (ruleOpt.isEmpty()) {
+ sb.append(indent)
+ .append("return -1;\n");
+ return;
+ }
+ int sub = ctx.allocateForRule(ref.ruleName(),
+ ruleOpt.unwrap()
+ .expression());
+ sb.append(indent)
+ .append("return ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit);\n");
+ }
+
+ private void emitTppSequence(StringBuilder sb,
+ Expression.Sequence seq,
+ TriviaPostPassEmitContext ctx,
+ String indent) {
+ sb.append(indent)
+ .append("int cursor = pos;\n");
+ for (var elt : seq.elements()) {
+ int sub = ctx.allocate(elt);
+ sb.append(indent)
+ .append("cursor = ")
+ .append(ctx.callName(sub))
+ .append("(input, cursor, limit);\n");
+ sb.append(indent)
+ .append("if (cursor < 0) return -1;\n");
+ }
+ sb.append(indent)
+ .append("return cursor;\n");
+ }
+
+ private void emitTppChoice(StringBuilder sb, Expression.Choice ch, TriviaPostPassEmitContext ctx, String indent) {
+ for (var alt : ch.alternatives()) {
+ int sub = ctx.allocate(alt);
+ sb.append(indent)
+ .append("{\n");
+ sb.append(indent)
+ .append(" int r = ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit);\n");
+ sb.append(indent)
+ .append(" if (r >= 0) return r;\n");
+ sb.append(indent)
+ .append("}\n");
+ }
+ sb.append(indent)
+ .append("return -1;\n");
+ }
+
+ private void emitTppZeroOrMore(StringBuilder sb, Expression body, TriviaPostPassEmitContext ctx, String indent) {
+ int sub = ctx.allocate(body);
+ sb.append(indent)
+ .append("int cursor = pos;\n");
+ sb.append(indent)
+ .append("while (true) {\n");
+ sb.append(indent)
+ .append(" int next = ")
+ .append(ctx.callName(sub))
+ .append("(input, cursor, limit);\n");
+ sb.append(indent)
+ .append(" if (next < 0 || next == cursor) break;\n");
+ sb.append(indent)
+ .append(" cursor = next;\n");
+ sb.append(indent)
+ .append("}\n");
+ sb.append(indent)
+ .append("return cursor;\n");
+ }
+
+ private void emitTppOneOrMore(StringBuilder sb, Expression body, TriviaPostPassEmitContext ctx, String indent) {
+ int sub = ctx.allocate(body);
+ sb.append(indent)
+ .append("int first = ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit);\n");
+ sb.append(indent)
+ .append("if (first < 0 || first == pos) return -1;\n");
+ sb.append(indent)
+ .append("int cursor = first;\n");
+ sb.append(indent)
+ .append("while (true) {\n");
+ sb.append(indent)
+ .append(" int next = ")
+ .append(ctx.callName(sub))
+ .append("(input, cursor, limit);\n");
+ sb.append(indent)
+ .append(" if (next < 0 || next == cursor) break;\n");
+ sb.append(indent)
+ .append(" cursor = next;\n");
+ sb.append(indent)
+ .append("}\n");
+ sb.append(indent)
+ .append("return cursor;\n");
+ }
+
+ private void emitTppOptional(StringBuilder sb, Expression body, TriviaPostPassEmitContext ctx, String indent) {
+ int sub = ctx.allocate(body);
+ sb.append(indent)
+ .append("int next = ")
+ .append(ctx.callName(sub))
+ .append("(input, pos, limit);\n");
+ sb.append(indent)
+ .append("return next < 0 ? pos : next;\n");
+ }
+
+ private void emitTppRepetition(StringBuilder sb,
+ Expression.Repetition rep,
+ TriviaPostPassEmitContext ctx,
+ String indent) {
+ int sub = ctx.allocate(rep.expression());
+ var maxExpr = rep.max()
+ .isPresent()
+ ? String.valueOf(rep.max()
+ .unwrap())
+ : "Integer.MAX_VALUE";
+ sb.append(indent)
+ .append("int cursor = pos;\n");
+ sb.append(indent)
+ .append("int count = 0;\n");
+ sb.append(indent)
+ .append("int max = ")
+ .append(maxExpr)
+ .append(";\n");
+ sb.append(indent)
+ .append("while (count < max) {\n");
+ sb.append(indent)
+ .append(" int next = ")
+ .append(ctx.callName(sub))
+ .append("(input, cursor, limit);\n");
+ sb.append(indent)
+ .append(" if (next < 0 || next == cursor) break;\n");
+ sb.append(indent)
+ .append(" cursor = next;\n");
+ sb.append(indent)
+ .append(" count++;\n");
+ sb.append(indent)
+ .append("}\n");
+ sb.append(indent)
+ .append("return count >= ")
+ .append(rep.min())
+ .append(" ? cursor : -1;\n");
+ }
+
+ /**
+ * Quote a string for embedding into Java source, escaping special chars.
+ * Mirrors the conventions used elsewhere in {@code ParserGenerator}.
+ */
+ private static String quoteJavaString(String s) {
+ var out = new StringBuilder(s.length() + 8);
+ out.append('"');
+ for (int i = 0; i < s.length(); i++ ) {
+ char c = s.charAt(i);
+ switch (c) {
+ case'"' -> out.append("\\\"");
+ case'\\' -> out.append("\\\\");
+ case'\n' -> out.append("\\n");
+ case'\r' -> out.append("\\r");
+ case'\t' -> out.append("\\t");
+ case'\b' -> out.append("\\b");
+ case'\f' -> out.append("\\f");
+ default -> {
+ if (c < 0x20 || c == 0x7f) {
+ out.append(String.format("\\u%04x", (int) c));
+ }else {
+ out.append(c);
+ }
+ }
+ }
+ }
+ out.append('"');
+ return out.toString();
+ }
+
// === Match-method emitters (phase 1 perf flags) ===
// Phase 1.7 (D2): match helpers return successNoLoc — endLocation field on
// intermediate (per-leaf) results is never read by callers. The
@@ -7102,7 +8146,9 @@ private void emitMatchLiteralCst(StringBuilder sb) {
}
// Phase 1.7 (D2): build SourceSpan from inline ints — no SourceLocation alloc.
sb.append(" var span = new SourceSpan(startLine, startColumn, startPos, line, column, pos);\n");
- sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, text, takePendingLeading(), List.of());\n");
+ sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, text, ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
sb.append(" return CstParseResult.successNoLoc(node, text);\n");
sb.append(" }\n");
sb.append("\n");
@@ -7142,7 +8188,9 @@ private void emitMatchDictionaryCst(StringBuilder sb) {
sb.append(" }\n");
// Phase 1.7 (D2): primitive-int span; successNoLoc result.
sb.append(" var span = new SourceSpan(startLine, startColumn, startPos, line, column, pos);\n");
- sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, longestMatch, takePendingLeading(), List.of());\n");
+ sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, longestMatch, ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
sb.append(" return CstParseResult.successNoLoc(node, longestMatch);\n");
sb.append(" }\n");
sb.append("\n");
@@ -7195,7 +8243,9 @@ private void emitMatchCharClassCst(StringBuilder sb) {
sb.append(" var text = c < 128 ? ASCII_CHAR_STRINGS[c] : String.valueOf(c);\n");
// Phase 1.7 (D2): primitive-int span; successNoLoc result.
sb.append(" var span = new SourceSpan(startLine, startColumn, startPos, line, column, pos);\n");
- sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_CHAR_CLASS, text, takePendingLeading(), List.of());\n");
+ sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_CHAR_CLASS, text, ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
sb.append(" return CstParseResult.successNoLoc(node, text);\n");
sb.append(" }\n");
sb.append("\n");
@@ -7229,7 +8279,9 @@ private void emitMatchAnyCst(StringBuilder sb) {
sb.append(" var text = c < 128 ? ASCII_CHAR_STRINGS[c] : String.valueOf(c);\n");
// Phase 1.7 (D2): primitive-int span; successNoLoc result.
sb.append(" var span = new SourceSpan(startLine, startColumn, startPos, line, column, pos);\n");
- sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_ANY, text, takePendingLeading(), List.of());\n");
+ sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_ANY, text, ")
+ .append(takePendingExpr())
+ .append(", List.of());\n");
sb.append(" return CstParseResult.successNoLoc(node, text);\n");
sb.append(" }\n");
sb.append("\n");
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java
index 3a06877..884590a 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java
@@ -60,68 +60,82 @@
* left-recursive rules are excluded. Pass a non-empty explicit set to
* override the auto-detection with caller-curated rule names (the
* traditional 0.2.9 behaviour, retained verbatim for that case).
+ * @param triviaPostPass when {@code true}, the interpreter's {@code parseCst}
+ * and {@code parseRuleAt} entry points apply
+ * {@code TriviaPostPass.assignTrivia(input, cst, grammar)} to the
+ * successful CST before returning it. The post-pass replaces the trivia
+ * attribution computed by the engine's buffer/save/restore machinery with
+ * a context-independent attribution derived solely from
+ * {@code (input, span, grammar.whitespace())}. Total trivia text is
+ * preserved (round-trip reconstruction is byte-equal); only the
+ * leading/trailing slot in which a given trivia chunk lives can differ.
+ * Default: {@code true} (Step 4 commit 6 of trivia rework — post-pass
+ * attribution is now the default; opt-out by constructing a
+ * {@code ParserConfig} explicitly with this field set to {@code false}).
+ * When {@code false}, no post-pass is invoked.
*/
-public record ParserConfig(
- boolean packratEnabled,
- RecoveryStrategy recoveryStrategy,
- boolean captureTrivia,
- boolean fastTrackFailure,
- boolean literalFailureCache,
- boolean charClassFailureCache,
- boolean bulkAdvanceLiteral,
- boolean skipWhitespaceFastPath,
- boolean reuseEndLocation,
- boolean choiceDispatch,
- boolean markResetChildren,
- boolean inlineLocations,
- boolean selectivePackrat,
- Set packratSkipRules,
- boolean mutableParseResult,
- boolean tokenFastPath) {
- public static final ParserConfig DEFAULT = new ParserConfig(
- true,
- RecoveryStrategy.BASIC,
- true,
- true,
- true,
- true,
- true,
- true,
- true,
- true,
- false,
- true,
- true,
- Set.of(),
- false,
- true);
+public record ParserConfig(boolean packratEnabled,
+ RecoveryStrategy recoveryStrategy,
+ boolean captureTrivia,
+ boolean fastTrackFailure,
+ boolean literalFailureCache,
+ boolean charClassFailureCache,
+ boolean bulkAdvanceLiteral,
+ boolean skipWhitespaceFastPath,
+ boolean reuseEndLocation,
+ boolean choiceDispatch,
+ boolean markResetChildren,
+ boolean inlineLocations,
+ boolean selectivePackrat,
+ Set packratSkipRules,
+ boolean mutableParseResult,
+ boolean tokenFastPath,
+ boolean triviaPostPass) {
+ public static final ParserConfig DEFAULT = new ParserConfig(true,
+ RecoveryStrategy.BASIC,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ Set.of(),
+ false,
+ true,
+ true);
/**
* Convenience factory for the three-field runtime configuration. Phase 1
- * generator-time perf flags, phase-2 {@code choiceDispatch}, and phase-1.8
- * {@code selectivePackrat} default to {@code true}; remaining phase-2 flags
- * default to {@code false}. Equivalent to {@link #DEFAULT} with the caller's
- * three runtime fields substituted.
+ * generator-time perf flags, phase-2 {@code choiceDispatch}, phase-1.8
+ * {@code selectivePackrat}, and Step-4 {@code triviaPostPass} default to
+ * {@code true}; remaining phase-2 flags default to {@code false}.
+ * Equivalent to {@link #DEFAULT} with the caller's three runtime fields
+ * substituted.
*/
public static ParserConfig parserConfig(boolean packratEnabled,
RecoveryStrategy recoveryStrategy,
boolean captureTrivia) {
- return new ParserConfig(
- packratEnabled,
- recoveryStrategy,
- captureTrivia,
- true,
- true,
- true,
- true,
- true,
- true,
- true,
- false,
- true,
- true,
- Set.of(),
- false,
- true);
+ return new ParserConfig(packratEnabled,
+ recoveryStrategy,
+ captureTrivia,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ Set.of(),
+ false,
+ true,
+ true);
}
}
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java
index a984978..bdff0ae 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java
@@ -7,6 +7,7 @@
import org.pragmatica.peg.tree.IdGenerator;
import org.pragmatica.peg.tree.SourceLocation;
import org.pragmatica.peg.tree.SourceSpan;
+import org.pragmatica.peg.tree.StringSpan;
import org.pragmatica.peg.tree.Trivia;
import java.util.ArrayList;
@@ -192,6 +193,15 @@ public String substring(int start, int end) {
return input.substring(start, end);
}
+ /**
+ * Cleanup E (v0.5.1): produce a {@link StringSpan} view over a slice of the
+ * input without materializing a {@link String}. Used by Terminal/Token
+ * construction to defer substring allocation.
+ */
+ public StringSpan substringSpan(int start, int end) {
+ return new StringSpan(input, start, end);
+ }
+
public String remainingInput() {
return input.substring(pos);
}
@@ -487,8 +497,16 @@ public void exitWhitespaceSkip() {
* Append captured trivia to the pending-leading buffer. Called at sites
* between sibling sequence elements (sequence, zero-or-more, one-or-more,
* repetition) so the following sibling's leaf or rule can claim it.
+ *
+ *
Step 4 commit 3: when {@code config.triviaPostPass()} is true, the
+ * post-pass overrides all leading/trailing attribution after the main
+ * parse, so any work done by this buffer is discarded. Early-out to
+ * eliminate the wasted CPU.
*/
public void appendPendingLeadingTrivia(List captured) {
+ if (config.triviaPostPass()) {
+ return;
+ }
if (!captured.isEmpty()) {
pendingLeadingTrivia.addAll(captured);
}
@@ -499,8 +517,15 @@ public void appendPendingLeadingTrivia(List captured) {
* CstNode that owns {@code leadingTrivia} — leaf terminals, token
* boundaries, or rule wrappers. Returns the previously-pending list; the
* buffer is reset to empty.
+ *
+ *
Step 4 commit 3: under {@code config.triviaPostPass()=true}, returns
+ * an empty list without touching state — the post-pass will reattribute
+ * trivia regardless.
*/
public List takePendingLeadingTrivia() {
+ if (config.triviaPostPass()) {
+ return List.of();
+ }
if (pendingLeadingTrivia.isEmpty()) {
return List.of();
}
@@ -522,8 +547,15 @@ public List takePendingLeadingTrivia() {
* branch later fails, restoring just the size cannot recover the consumed
* items. The full-list snapshot guarantees byte-identical buffer state
* regardless of intermediate drains.
+ *
+ *
Step 4 commit 3: under {@code config.triviaPostPass()=true}, returns
+ * an empty sentinel list — the matching {@code restorePendingLeadingTrivia}
+ * is also a no-op, so the snapshot value is irrelevant.
*/
public List savePendingLeadingTrivia() {
+ if (config.triviaPostPass()) {
+ return List.of();
+ }
return List.copyOf(pendingLeadingTrivia);
}
@@ -532,8 +564,15 @@ public List savePendingLeadingTrivia() {
* Replaces all current contents with those from {@code snapshot},
* recovering items that may have been drained by inner operations
* inside a now-failed branch.
+ *
+ *
Step 4 commit 3: under {@code config.triviaPostPass()=true}, no-op —
+ * the buffer is never written under the flag, so there is nothing to
+ * restore.
*/
public void restorePendingLeadingTrivia(List snapshot) {
+ if (config.triviaPostPass()) {
+ return;
+ }
pendingLeadingTrivia.clear();
pendingLeadingTrivia.addAll(snapshot);
}
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java
index 1d2a487..fb4fef8 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java
@@ -21,6 +21,7 @@
import org.pragmatica.peg.tree.SourceLocation;
import org.pragmatica.peg.tree.SourceSpan;
import org.pragmatica.peg.tree.Trivia;
+import org.pragmatica.peg.tree.TriviaPostPass;
import java.util.ArrayList;
import java.util.HashMap;
@@ -37,6 +38,12 @@ public final class PegEngine implements Parser {
private final ParserConfig config;
private final Map actions;
+ // Cleanup A: cached triviaPostPass flag — read once at construction so the
+ // 38 buffer-call sites can short-circuit without re-reading config on every
+ // hit. Under flag-ON, the post-pass overrides all engine-side trivia
+ // attribution; the buffer methods are no-ops, and we avoid the dispatch.
+ private final boolean triviaPostPass;
+
// Phase-1 optimization: cached first-char set for skipWhitespace fast-path (§6.6).
// Present iff the grammar has a %whitespace rule whose shape is analyzable.
// Unconditional-on for the interpreter; no flag gates this.
@@ -67,6 +74,7 @@ public final class PegEngine implements Parser {
private PegEngine(Grammar grammar, ParserConfig config, Map actions) {
this.grammar = grammar;
this.config = config;
+ this.triviaPostPass = config.triviaPostPass();
this.actions = Map.copyOf(actions);
this.whitespaceFirstChars = computeWhitespaceFirstChars(grammar);
this.suggestionVocabulary = computeSuggestionVocabulary(grammar);
@@ -286,7 +294,39 @@ public Result parseCst(String input, String startRule, IdGenerator idGe
var success = (ParseResult.Success) result;
// Attach trailing trivia to root node
var rootNode = attachTrailingTrivia(success.node(), trailingTrivia);
- return Result.success(rootNode);
+ return Result.success(applyTriviaPostPass(input, rootNode));
+ }
+
+ /**
+ * Step 4 commit 1 (0.5.1): when {@code config.triviaPostPass()} is on,
+ * re-derive trivia attribution from {@code (input, span, grammar.whitespace())}
+ * via {@link TriviaPostPass#assignTrivia(String, CstNode, Grammar)}. The
+ * total trivia text is preserved (round-trip stays byte-equal); only the
+ * leading/trailing slot can shift. Off by default — bit-for-bit identical
+ * to prior releases when the flag is false.
+ *
+ *
Full-parse callers ({@code parseCst}) treat the root as a document
+ * root and scan leading trivia from offset 0.
+ */
+ private CstNode applyTriviaPostPass(String input, CstNode root) {
+ return applyTriviaPostPass(input, root, 0);
+ }
+
+ /**
+ * Step 4 commit 2 (0.5.1): splice-aware overload used by
+ * {@link #parseRuleAt(Class, String, int, IdGenerator)}. The
+ * {@code leadingScanFrom} parameter clamps the root subtree's leading
+ * trivia scan window to {@code [leadingScanFrom, root.span.start)},
+ * matching the gap between the previous sibling and the spliced subtree
+ * during incremental reparse. Without this clamp the post-pass would
+ * attribute the entire input prefix {@code [0, root.span.start)} to the
+ * subtree's leading slot.
+ */
+ private CstNode applyTriviaPostPass(String input, CstNode root, int leadingScanFrom) {
+ if (!config.triviaPostPass()) {
+ return root;
+ }
+ return TriviaPostPass.assignTrivia(input, root, grammar, leadingScanFrom);
}
@Override
@@ -436,7 +476,7 @@ public Result parseRuleAt(Class< ? extends RuleId> ruleId,
.result();
}
var success = (ParseResult.Success) result;
- return Result.success(new PartialParse(success.node(), ctx.pos()));
+ return Result.success(new PartialParse(applyTriviaPostPass(input, success.node(), offset), ctx.pos()));
}
/**
@@ -693,7 +733,9 @@ private ParseResult parseRule(ParsingContext ctx, Rule rule) {
var entry = cachedEntry.unwrap();
var result = entry.result();
if (result instanceof ParseResult.Success success) {
- var hitCarriedLeading = ctx.takePendingLeadingTrivia();
+ var hitCarriedLeading = triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia();
var hitLocalTrivia = skipWhitespace(ctx);
List hitRuleLeading = hitCarriedLeading.isEmpty()
? hitLocalTrivia
@@ -711,7 +753,9 @@ private ParseResult parseRule(ParsingContext ctx, Rule rule) {
// leading whitespace skipped at rule entry. This becomes the rule
// wrapper's leadingTrivia; children of the body see an empty pending
// buffer unless the body itself deposits more between its own siblings.
- var carriedLeading = ctx.takePendingLeadingTrivia();
+ var carriedLeading = triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia();
var localTrivia = skipWhitespace(ctx);
List ruleLeading = carriedLeading.isEmpty()
? localTrivia
@@ -751,11 +795,13 @@ private ParseResult parseRule(ParsingContext ctx, Rule rule) {
CstNode bodyNode = null;
if (result instanceof ParseResult.Success success) {
bodyNode = success.node();
- var pendingAtExit = ctx.savePendingLeadingTrivia();
- if (!pendingAtExit.isEmpty()) {
- ctx.restorePendingLeadingTrivia(List.of());
- bodyNode = attachTrailingToTail(bodyNode, pendingAtExit);
- resultForCache = ParseResult.Success.success(bodyNode, success.endLocation());
+ if (!triviaPostPass) {
+ var pendingAtExit = ctx.savePendingLeadingTrivia();
+ if (!pendingAtExit.isEmpty()) {
+ ctx.restorePendingLeadingTrivia(List.of());
+ bodyNode = attachTrailingToTail(bodyNode, pendingAtExit);
+ resultForCache = ParseResult.Success.success(bodyNode, success.endLocation());
+ }
}
}
// Cache the result at START position
@@ -770,7 +816,7 @@ private ParseResult parseRule(ParsingContext ctx, Rule rule) {
// Restore position and re-deposit the caller's pending so enclosing
// backtracking combinators can roll it back to their own snapshots.
ctx.restoreLocation(startLoc);
- if (!carriedLeading.isEmpty()) {
+ if (!triviaPostPass && !carriedLeading.isEmpty()) {
ctx.appendPendingLeadingTrivia(carriedLeading);
}
// Use custom error message if available
@@ -838,7 +884,9 @@ private ParseResult parseRuleWithLeftRecursion(ParsingContext ctx, Rule rule) {
return result;
}
if (result instanceof ParseResult.Success success) {
- var hitCarriedLeading = ctx.takePendingLeadingTrivia();
+ var hitCarriedLeading = triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia();
var hitLocalTrivia = skipWhitespace(ctx);
List hitRuleLeading = hitCarriedLeading.isEmpty()
? hitLocalTrivia
@@ -854,7 +902,9 @@ private ParseResult parseRuleWithLeftRecursion(ParsingContext ctx, Rule rule) {
// Claim caller's pending-leading + our own leading whitespace. The
// trivia snapshot lives across all growth iterations; each iteration
// re-enters the body at the same post-whitespace position.
- var carriedLeading = ctx.takePendingLeadingTrivia();
+ var carriedLeading = triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia();
var localTrivia = skipWhitespace(ctx);
List ruleLeading = carriedLeading.isEmpty()
? localTrivia
@@ -938,7 +988,7 @@ private ParseResult parseRuleWithLeftRecursion(ParsingContext ctx, Rule rule) {
// Failure path — restore the original caller state and re-deposit
// pending-leading so enclosing backtracking combinators can roll back.
ctx.restoreLocation(startLoc);
- if (!carriedLeading.isEmpty()) {
+ if (!triviaPostPass && !carriedLeading.isEmpty()) {
ctx.appendPendingLeadingTrivia(carriedLeading);
}
if (rule.hasErrorMessage()) {
@@ -975,7 +1025,9 @@ private ParseResult parseRuleWithActions(ParsingContext ctx, Rule rule) {
var startLoc = ctx.location();
// Claim pending-leading (caller deposit) + this rule's leading ws. Same
// semantics as parseRule; see TRIVIA-ATTRIBUTION.md.
- var carriedLeading = ctx.takePendingLeadingTrivia();
+ var carriedLeading = triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia();
var localTrivia = skipWhitespace(ctx);
List ruleLeading = carriedLeading.isEmpty()
? localTrivia
@@ -1012,7 +1064,7 @@ private ParseResult parseRuleWithActions(ParsingContext ctx, Rule rule) {
}
if (result.isFailure()) {
ctx.restoreLocation(startLoc);
- if (!carriedLeading.isEmpty()) {
+ if (!triviaPostPass && !carriedLeading.isEmpty()) {
ctx.appendPendingLeadingTrivia(carriedLeading);
}
// Use custom error message if available
@@ -1126,16 +1178,18 @@ private ParseResult parseTokenBoundaryWithActions(ParsingContext ctx,
return result;
}
var endPos = ctx.pos();
- var text = ctx.substring(startPos, endPos);
+ var textSpan = ctx.substringSpan(startPos, endPos);
// Set token capture so $0 returns this captured text
- tokenCapture[0] = text;
+ tokenCapture[0] = textSpan.toString();
var span = ctx.spanFrom(startLoc);
var node = new CstNode.Token(ctx.idGen()
.next(),
span,
ruleName,
- text,
- ctx.takePendingLeadingTrivia(),
+ textSpan,
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return ParseResult.Success.success(node, ctx.location());
} finally{
@@ -1211,7 +1265,9 @@ private ParseResult parseLiteral(ParsingContext ctx, Expression.Literal lit) {
span,
"",
text,
- ctx.takePendingLeadingTrivia(),
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return new ParseResult.Success(node, endLoc, List.of(), Option.none());
}
@@ -1277,7 +1333,9 @@ private ParseResult parseDictionary(ParsingContext ctx, Expression.Dictionary di
span,
"",
matched,
- ctx.takePendingLeadingTrivia(),
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return new ParseResult.Success(node, endLoc, List.of(), Option.none());
}
@@ -1333,7 +1391,9 @@ private ParseResult parseCharClass(ParsingContext ctx, Expression.CharClass cc)
span,
"",
String.valueOf(c),
- ctx.takePendingLeadingTrivia(),
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return new ParseResult.Success(node, endLoc, List.of(), Option.none());
}
@@ -1452,7 +1512,9 @@ private ParseResult parseAny(ParsingContext ctx, Expression.Any any) {
span,
"",
String.valueOf(c),
- ctx.takePendingLeadingTrivia(),
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return new ParseResult.Success(node, endLoc, List.of(), Option.none());
}
@@ -1478,11 +1540,15 @@ private boolean isPredicate(Expression expr) {
// === Predicate Parsers ===
private ParseResult parseAnd(ParsingContext ctx, Expression.And and, String ruleName) {
var startLoc = ctx.location();
- var entryPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var entryPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
var result = parseExpressionWithMode(ctx, and.expression(), ruleName, ParseMode.standard());
ctx.restoreLocation(startLoc);
// Always restore - predicates don't consume input nor trivia state.
- ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ }
if (result.isSuccess()) {
return new ParseResult.PredicateSuccess(startLoc);
}
@@ -1491,11 +1557,15 @@ private ParseResult parseAnd(ParsingContext ctx, Expression.And and, String rule
private ParseResult parseNot(ParsingContext ctx, Expression.Not not, String ruleName) {
var startLoc = ctx.location();
- var entryPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var entryPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
var result = parseExpressionWithMode(ctx, not.expression(), ruleName, ParseMode.standard());
ctx.restoreLocation(startLoc);
// Always restore - predicates don't consume input nor trivia state.
- ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ }
if (result.isSuccess()) {
return ParseResult.Failure.failure(startLoc, "not " + describeExpression(not.expression()));
}
@@ -1514,14 +1584,16 @@ private ParseResult parseTokenBoundary(ParsingContext ctx, Expression.TokenBound
return result;
}
var endPos = ctx.pos();
- var text = ctx.substring(startPos, endPos);
+ var textSpan = ctx.substringSpan(startPos, endPos);
var span = ctx.spanFrom(startLoc);
var node = new CstNode.Token(ctx.idGen()
.next(),
span,
ruleName,
- text,
- ctx.takePendingLeadingTrivia(),
+ textSpan,
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return ParseResult.Success.success(node, ctx.location());
} finally{
@@ -1584,7 +1656,9 @@ private ParseResult parseBackReference(ParsingContext ctx, Expression.BackRefere
span,
"",
text,
- ctx.takePendingLeadingTrivia(),
+ triviaPostPass
+ ? List.of()
+ : ctx.takePendingLeadingTrivia(),
List.of());
return ParseResult.Success.success(node, ctx.location());
}
@@ -1694,7 +1768,9 @@ private ParseResult parseSequenceWithMode(ParsingContext ctx,
String ruleName,
ParseMode mode) {
var startLoc = ctx.location();
- var pendingSnapshot = ctx.savePendingLeadingTrivia();
+ var pendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
var children = new ArrayList();
boolean cutEncountered = false;
for (var element : seq.elements()) {
@@ -1702,19 +1778,27 @@ private ParseResult parseSequenceWithMode(ParsingContext ctx,
// captured trivia is appended to the pending-leading buffer so the
// following element (leaf or rule) can claim it.
if (mode.shouldSkipWhitespace() && !isPredicate(element)) {
- ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ if (triviaPostPass) {
+ skipWhitespace(ctx);
+ }else {
+ ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ }
}
var result = parseExpressionWithMode(ctx, element, ruleName, mode);
// Handle failures
if (result instanceof ParseResult.CutFailure) {
// Propagate CutFailure immediately
ctx.restoreLocation(startLoc);
- ctx.restorePendingLeadingTrivia(pendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(pendingSnapshot);
+ }
return result;
}
if (result instanceof ParseResult.Failure failure) {
ctx.restoreLocation(startLoc);
- ctx.restorePendingLeadingTrivia(pendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(pendingSnapshot);
+ }
// If cut was encountered, return CutFailure to prevent backtracking
if (cutEncountered) {
return ParseResult.CutFailure.cutFailure(failure.location(), failure.expected());
@@ -1748,7 +1832,9 @@ private ParseResult parseChoiceWithMode(ParsingContext ctx,
String ruleName,
ParseMode mode) {
var startLoc = ctx.location();
- var entryPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var entryPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
ParseResult lastFailure = null;
for (var alt : choice.alternatives()) {
ParseResult result;
@@ -1779,7 +1865,9 @@ private ParseResult parseChoiceWithMode(ParsingContext ctx,
}
lastFailure = result;
ctx.restoreLocation(startLoc);
- ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ }
}
return lastFailure != null
? lastFailure
@@ -1797,9 +1885,15 @@ private ParseResult parseZeroOrMoreWithMode(ParsingContext ctx,
var children = new ArrayList();
while (true) {
var beforeLoc = ctx.location();
- var iterPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var iterPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
if (mode.shouldSkipWhitespace()) {
- ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ if (triviaPostPass) {
+ skipWhitespace(ctx);
+ }else {
+ ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ }
}
ParseResult result;
if (mode.shouldCollectActions()) {
@@ -1824,7 +1918,9 @@ private ParseResult parseZeroOrMoreWithMode(ParsingContext ctx,
}
if (result.isFailure()) {
ctx.restoreLocation(beforeLoc);
- ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ }
break;
}
if (result instanceof ParseResult.Success success) {
@@ -1868,9 +1964,15 @@ private ParseResult parseOneOrMoreWithMode(ParsingContext ctx,
// Subsequent matches are optional
while (true) {
var beforeLoc = ctx.location();
- var iterPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var iterPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
if (mode.shouldSkipWhitespace()) {
- ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ if (triviaPostPass) {
+ skipWhitespace(ctx);
+ }else {
+ ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ }
}
ParseResult result;
if (mode.shouldCollectActions()) {
@@ -1895,7 +1997,9 @@ private ParseResult parseOneOrMoreWithMode(ParsingContext ctx,
}
if (result.isFailure()) {
ctx.restoreLocation(beforeLoc);
- ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ }
break;
}
if (result instanceof ParseResult.Success success) {
@@ -1927,7 +2031,9 @@ private ParseResult parseOptionalWithMode(ParsingContext ctx,
String ruleName,
ParseMode mode) {
var startLoc = ctx.location();
- var entryPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var entryPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
var result = parseExpressionWithMode(ctx, opt.expression(), ruleName, mode);
if (result.isSuccess()) {
return result;
@@ -1938,7 +2044,9 @@ private ParseResult parseOptionalWithMode(ParsingContext ctx,
}
// Optional always succeeds - return empty node on no match
ctx.restoreLocation(startLoc);
- ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(entryPendingSnapshot);
+ }
var span = SourceSpan.sourceSpan(startLoc);
var node = new CstNode.NonTerminal(ctx.idGen()
.next(),
@@ -1967,9 +2075,15 @@ private ParseResult parseRepetitionWithMode(ParsingContext ctx,
: Integer.MAX_VALUE;
while (count < max) {
var beforeLoc = ctx.location();
- var iterPendingSnapshot = ctx.savePendingLeadingTrivia();
+ var iterPendingSnapshot = triviaPostPass
+ ? null
+ : ctx.savePendingLeadingTrivia();
if (count > 0 && mode.shouldSkipWhitespace()) {
- ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ if (triviaPostPass) {
+ skipWhitespace(ctx);
+ }else {
+ ctx.appendPendingLeadingTrivia(skipWhitespace(ctx));
+ }
}
ParseResult result;
if (mode.shouldCollectActions()) {
@@ -1994,7 +2108,9 @@ private ParseResult parseRepetitionWithMode(ParsingContext ctx,
}
if (result.isFailure()) {
ctx.restoreLocation(beforeLoc);
- ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ if (!triviaPostPass) {
+ ctx.restorePendingLeadingTrivia(iterPendingSnapshot);
+ }
break;
}
if (result instanceof ParseResult.Success success) {
@@ -2097,11 +2213,11 @@ private static List concatTrivia(List first, List second
private CstNode wrapWithRuleName(CstNode node, String ruleName, List leadingTrivia) {
return switch (node) {
case CstNode.Terminal t -> new CstNode.Terminal(
- t.id(), t.span(), ruleName, t.text(), leadingTrivia, t.trailingTrivia());
+ t.id(), t.span(), ruleName, t.textSpan(), leadingTrivia, t.trailingTrivia());
case CstNode.NonTerminal nt -> new CstNode.NonTerminal(
nt.id(), nt.span(), ruleName, nt.children(), leadingTrivia, nt.trailingTrivia());
case CstNode.Token tok -> new CstNode.Token(
- tok.id(), tok.span(), ruleName, tok.text(), leadingTrivia, tok.trailingTrivia());
+ tok.id(), tok.span(), ruleName, tok.textSpan(), leadingTrivia, tok.trailingTrivia());
case CstNode.Error err -> new CstNode.Error(
err.id(), err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia());
};
@@ -2118,11 +2234,11 @@ private CstNode attachLeadingTrivia(CstNode node, List leadingTrivia) {
}
return switch (node) {
case CstNode.Terminal t -> new CstNode.Terminal(
- t.id(), t.span(), t.rule(), t.text(), leadingTrivia, t.trailingTrivia());
+ t.id(), t.span(), t.rule(), t.textSpan(), leadingTrivia, t.trailingTrivia());
case CstNode.NonTerminal nt -> new CstNode.NonTerminal(
nt.id(), nt.span(), nt.rule(), nt.children(), leadingTrivia, nt.trailingTrivia());
case CstNode.Token tok -> new CstNode.Token(
- tok.id(), tok.span(), tok.rule(), tok.text(), leadingTrivia, tok.trailingTrivia());
+ tok.id(), tok.span(), tok.rule(), tok.textSpan(), leadingTrivia, tok.trailingTrivia());
case CstNode.Error err -> new CstNode.Error(
err.id(), err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia());
};
@@ -2137,11 +2253,11 @@ private CstNode attachTrailingTrivia(CstNode node, List trailingTrivia)
}
return switch (node) {
case CstNode.Terminal t -> new CstNode.Terminal(
- t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), trailingTrivia);
+ t.id(), t.span(), t.rule(), t.textSpan(), t.leadingTrivia(), trailingTrivia);
case CstNode.NonTerminal nt -> new CstNode.NonTerminal(
nt.id(), nt.span(), nt.rule(), nt.children(), nt.leadingTrivia(), trailingTrivia);
case CstNode.Token tok -> new CstNode.Token(
- tok.id(), tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), trailingTrivia);
+ tok.id(), tok.span(), tok.rule(), tok.textSpan(), tok.leadingTrivia(), trailingTrivia);
case CstNode.Error err -> new CstNode.Error(
err.id(), err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), trailingTrivia);
};
@@ -2175,12 +2291,12 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) {
nt.id(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia());
}
case CstNode.Terminal t -> new CstNode.Terminal(
- t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), combineTrivia(t.trailingTrivia(), trivia));
+ t.id(), t.span(), t.rule(), t.textSpan(), t.leadingTrivia(), combineTrivia(t.trailingTrivia(), trivia));
case CstNode.Token tok -> new CstNode.Token(
tok.id(),
tok.span(),
tok.rule(),
- tok.text(),
+ tok.textSpan(),
tok.leadingTrivia(),
combineTrivia(tok.trailingTrivia(), trivia));
case CstNode.Error err -> new CstNode.Error(
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java
index b2e9f71..ce86d30 100644
--- a/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java
+++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java
@@ -12,6 +12,11 @@
* {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2). IDs are produced by
* {@link IdGenerator} during parse and must be excluded from structural
* equality (§7 R1) so two parses of the same input produce equal trees.
+ *
+ *
v0.5.1 (Cleanup E): {@link Terminal} and {@link Token} carry a
+ * {@link StringSpan textSpan} component instead of a fully-materialized
+ * {@code String}, deferring substring allocation. The {@code text()} accessor
+ * remains a {@link String}-returning method for source-compat with consumers.
*/
public sealed interface CstNode {
/**
@@ -44,25 +49,49 @@ public sealed interface CstNode {
/**
* Terminal node - a leaf that matched literal text.
+ *
+ *
v0.5.1 (Cleanup E): the {@code textSpan} component defers substring
+ * materialization. Use {@link #text()} to obtain a {@link String}.
*/
record Terminal(
long id,
SourceSpan span,
String rule,
- String text,
+ StringSpan textSpan,
List leadingTrivia,
List trailingTrivia) implements CstNode {
+ /**
+ * Convenience constructor accepting a {@link String} for the text. Wraps the
+ * string in a fully-materialized {@link StringSpan}, so no substring is taken.
+ */
+ public Terminal(long id,
+ SourceSpan span,
+ String rule,
+ String text,
+ List leadingTrivia,
+ List trailingTrivia) {
+ this(id, span, rule, StringSpan.ofString(text), leadingTrivia, trailingTrivia);
+ }
+
+ /**
+ * The matched text as a {@link String}. Lazily materializes the underlying
+ * {@link StringSpan} on first access.
+ */
+ public String text() {
+ return textSpan.toString();
+ }
+
@Override
public boolean equals(Object other) {
- return other instanceof Terminal that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text,
- that.text) && Objects.equals(leadingTrivia,
- that.leadingTrivia) && Objects.equals(trailingTrivia,
- that.trailingTrivia);
+ return other instanceof Terminal that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(textSpan,
+ that.textSpan) && Objects.equals(leadingTrivia,
+ that.leadingTrivia) && Objects.equals(trailingTrivia,
+ that.trailingTrivia);
}
@Override
public int hashCode() {
- return Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia);
+ return Objects.hash(Terminal.class, span, rule, textSpan, leadingTrivia, trailingTrivia);
}
}
@@ -94,25 +123,49 @@ public int hashCode() {
/**
* Token node - result of token boundary operator {@code < >}.
* Captures the matched text as a single unit.
+ *
+ *
v0.5.1 (Cleanup E): the {@code textSpan} component defers substring
+ * materialization. Use {@link #text()} to obtain a {@link String}.
*/
record Token(
long id,
SourceSpan span,
String rule,
- String text,
+ StringSpan textSpan,
List leadingTrivia,
List trailingTrivia) implements CstNode {
+ /**
+ * Convenience constructor accepting a {@link String} for the text. Wraps the
+ * string in a fully-materialized {@link StringSpan}, so no substring is taken.
+ */
+ public Token(long id,
+ SourceSpan span,
+ String rule,
+ String text,
+ List leadingTrivia,
+ List trailingTrivia) {
+ this(id, span, rule, StringSpan.ofString(text), leadingTrivia, trailingTrivia);
+ }
+
+ /**
+ * The matched text as a {@link String}. Lazily materializes the underlying
+ * {@link StringSpan} on first access.
+ */
+ public String text() {
+ return textSpan.toString();
+ }
+
@Override
public boolean equals(Object other) {
- return other instanceof Token that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text,
- that.text) && Objects.equals(leadingTrivia,
- that.leadingTrivia) && Objects.equals(trailingTrivia,
- that.trailingTrivia);
+ return other instanceof Token that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(textSpan,
+ that.textSpan) && Objects.equals(leadingTrivia,
+ that.leadingTrivia) && Objects.equals(trailingTrivia,
+ that.trailingTrivia);
}
@Override
public int hashCode() {
- return Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia);
+ return Objects.hash(Token.class, span, rule, textSpan, leadingTrivia, trailingTrivia);
}
}
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/StringSpan.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/StringSpan.java
new file mode 100644
index 0000000..a759522
--- /dev/null
+++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/StringSpan.java
@@ -0,0 +1,141 @@
+package org.pragmatica.peg.tree;
+
+import java.util.Objects;
+
+/**
+ * View into a substring of the parser input. Defers materialization to a
+ * Java {@link String} until first access via {@link #toString()}.
+ *
+ *
Created by parser match helpers (token boundary capture, literal/charclass/any
+ * Terminal construction) instead of eager {@code String.substring(int, int)}, which
+ * (in Java 9+ compact strings) allocates both a fresh {@code byte[]} and a
+ * {@code String} header. StringSpan eliminates both, deferring materialization until
+ * a consumer actually needs a {@link String}.
+ *
+ *
Memory retention: StringSpan holds a reference to the entire input.
+ * CST nodes that survive the parse will keep input alive. This is fine for the
+ * IDE plugin / incremental use case (input lives alongside the CST anyway). For
+ * one-shot parses the cost is bounded by input size.
+ *
+ *
Thread-safety: the parser is single-threaded; the lazy
+ * {@code materialized} field uses a plain write-once pattern with no
+ * synchronization.
+ *
+ *
Equality semantics: {@code equals} compares text content (not
+ * source/start/end), so two StringSpans pointing into different sources but with
+ * identical text are equal. This is required so consumers that put StringSpans
+ * in {@link java.util.Set} / {@link java.util.Map} compare by text.
+ */
+public final class StringSpan implements CharSequence {
+ private final String source;
+ private final int start;
+ private final int end;
+ private String materialized;
+
+ // lazy cache; written-once
+ public StringSpan(String source, int start, int end) {
+ if (start < 0 || end < start || end > source.length()) {
+ throw new IndexOutOfBoundsException("start=" + start + ", end=" + end + ", length=" + source.length());
+ }
+ this.source = source;
+ this.start = start;
+ this.end = end;
+ }
+
+ public static StringSpan of(String source, int start, int end) {
+ return new StringSpan(source, start, end);
+ }
+
+ /**
+ * Wrap a fully-materialized {@link String} as a StringSpan with no deferral.
+ * Used when a callsite already has a String (e.g. a literal pattern), so the
+ * String is captured directly and {@link #toString()} returns it without copying.
+ */
+ public static StringSpan ofString(String text) {
+ var span = new StringSpan(text, 0, text.length());
+ span.materialized = text;
+ return span;
+ }
+
+ public String source() {
+ return source;
+ }
+
+ public int start() {
+ return start;
+ }
+
+ public int end() {
+ return end;
+ }
+
+ @Override
+ public int length() {
+ return end - start;
+ }
+
+ @Override
+ public char charAt(int index) {
+ if (index < 0 || index >= length()) {
+ throw new IndexOutOfBoundsException("index=" + index + ", length=" + length());
+ }
+ return source.charAt(start + index);
+ }
+
+ @Override
+ public CharSequence subSequence(int s, int e) {
+ if (s < 0 || e < s || e > length()) {
+ throw new IndexOutOfBoundsException("s=" + s + ", e=" + e + ", length=" + length());
+ }
+ return new StringSpan(source, start + s, start + e);
+ }
+
+ @Override
+ public String toString() {
+ var m = materialized;
+ if (m == null) {
+ m = source.substring(start, end);
+ materialized = m;
+ }
+ return m;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o instanceof StringSpan that) {
+ int len = this.length();
+ if (len != that.length()) {
+ return false;
+ }
+ // Cheap path: same backing source and same offsets
+ if (this.source == that.source && this.start == that.start && this.end == that.end) {
+ return true;
+ }
+ // Compare characters
+ for (int i = 0; i < len; i++ ) {
+ if (this.source.charAt(this.start + i) != that.source.charAt(that.start + i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ // Match String.hashCode() so StringSpan-by-content semantics are predictable.
+ if (materialized != null) {
+ return materialized.hashCode();
+ }
+ int h = 0;
+ int len = end - start;
+ for (int i = 0; i < len; i++ ) {
+ h = 31 * h + source.charAt(start + i);
+ }
+ return h;
+ }
+}
diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/TriviaPostPass.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/TriviaPostPass.java
new file mode 100644
index 0000000..4f05b65
--- /dev/null
+++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/TriviaPostPass.java
@@ -0,0 +1,689 @@
+package org.pragmatica.peg.tree;
+
+import org.pragmatica.peg.grammar.Expression;
+import org.pragmatica.peg.grammar.Grammar;
+import org.pragmatica.peg.grammar.analysis.ExpressionShape;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Step 3 prototype — context-independent trivia attribution.
+ *
+ *
Given {@code (input, cst, grammar)}, produce a NEW CST with the same
+ * structure (rule names, spans, IDs, children order) but with leading/trailing
+ * trivia re-derived from coordinates: each gap between adjacent node
+ * spans is re-scanned against the grammar's {@code %whitespace} rule and
+ * attributed by a single deterministic policy.
+ *
+ *
This is the prototype validating the hypothesis that trivia attribution
+ * is a function of {@code (input, rule, span)} and does not require the
+ * navigational buffer/save/restore machinery currently in {@code PegEngine}.
+ *
+ *
Attribution policy
+ *
+ *
The post-pass uses a single policy: leading trivia is the gap before
+ * a node; trailing trivia drains into the last-terminal descendant of a
+ * non-terminal, matching the engine's Bug C' compensation; terminals never
+ * own trailing unless they receive drained orphan trivia.
+ *
+ *
The structural shape of the post-pass CST therefore matches the engine:
+ * orphan trivia consumed before a zero-width tail element (empty
+ * {@code ZeroOrMore} / {@code Optional}) is drained into the last
+ * terminal descendant's trailingTrivia via the recursive helper
+ * {@link #attachTrailingToTail}, mirroring {@code PegEngine.attachTrailingToTail}.
+ * This keeps {@code CstReconstruct.emit} (which only emits trailing for the
+ * last child of a wrapper) byte-equal to the original input even when the
+ * orphan-bearing wrapper is not the rightmost child of its parent.
+ *
+ *
Whitespace scanning
+ *
+ *
{@link #scanWhitespace(String, int, int, Grammar)} runs the inner
+ * expression of {@code %whitespace} (extracted via
+ * {@link ExpressionShape#extractInnerExpression(Expression)}) repeatedly over
+ * {@code input[from..to)} and emits one {@link Trivia} per successful match.
+ * Each chunk is classified by content prefix (matches the engine's
+ * {@code classifyTrivia}):
+ *
+ *
starts with {@code "//"} → {@link Trivia.LineComment}
+ *
starts with {@code "/*"} → {@link Trivia.BlockComment}
+ *
otherwise → {@link Trivia.Whitespace}
+ *
+ *
+ *
The matcher is a self-contained recursive evaluator covering the
+ * expression types used in {@code %whitespace} rules across the corpus
+ * (Literal, CharClass, Any, Reference, Sequence, Choice, ZeroOrMore,
+ * OneOrMore, Optional, And, Not, Group, Repetition). It is deliberately
+ * simpler than the production engine: no packrat cache, no left recursion,
+ * no error recovery, no actions — none of which apply to whitespace rules.
+ *
+ * @since 0.5.1 (Step 3 prototype)
+ */
+public final class TriviaPostPass {
+ private TriviaPostPass() {}
+
+ /**
+ * Re-derive leading/trailing trivia for every node in {@code cst} from
+ * {@code (input, span)} coordinates. Returns a new tree with identical
+ * structure (id, span, rule, children order preserved) but trivia
+ * attribution determined by {@link #scanWhitespace}.
+ *
+ *
If {@code grammar.whitespace()} is empty (no {@code %whitespace}
+ * directive), all trivia lists in the result are empty.
+ *
+ *
Equivalent to {@link #assignTrivia(String, CstNode, Grammar, int)}
+ * with {@code leadingScanFrom = 0} (full-document scan).
+ *
+ * @param input full source text
+ * @param cst root CST node from a previous parse
+ * @param grammar the grammar that produced {@code cst}; used only to
+ * resolve {@code %whitespace} and (transitively) any
+ * rules referenced by it
+ * @return new CST with re-attributed trivia
+ */
+ public static CstNode assignTrivia(String input, CstNode cst, Grammar grammar) {
+ return assignTrivia(input, cst, grammar, 0);
+ }
+
+ /**
+ * Same as {@link #assignTrivia(String, CstNode, Grammar)} but constrains
+ * the leading-trivia scan window of the root CST to start at
+ * {@code leadingScanFrom} instead of 0.
+ *
+ *
Use this overload when {@code cst} represents a partial-reparse
+ * subtree and the caller knows the splice point: scanning from offset 0
+ * would attribute all preceding trivia to the subtree's leading slot.
+ * Supplying the splice offset clamps the scan window to
+ * {@code [leadingScanFrom, root.span.start)}, matching the gap between
+ * the previous sibling and the spliced subtree.
+ *
+ *
Inner-node leading scans (between siblings inside the subtree) use
+ * the standard previous-sibling-end coordinate and are not affected by
+ * this argument. The root's trailing-trivia scan (post-EOF window) is
+ * also unaffected.
+ *
+ * @param input the source text the parse was performed against
+ * @param cst the parsed CST (full or partial-reparse subtree)
+ * @param grammar grammar (for {@code %whitespace} access)
+ * @param leadingScanFrom offset where root-level leading scan begins;
+ * must satisfy
+ * {@code 0 <= leadingScanFrom <= cst.span().startOffset()}
+ * @return new CST with re-attributed trivia
+ * @throws IllegalArgumentException when {@code leadingScanFrom} is
+ * negative or greater than the root span's start offset
+ */
+ public static CstNode assignTrivia(String input, CstNode cst, Grammar grammar, int leadingScanFrom) {
+ int rootStart = cst.span()
+ .startOffset();
+ if (leadingScanFrom < 0 || leadingScanFrom > rootStart) {
+ throw new IllegalArgumentException(
+ "leadingScanFrom " + leadingScanFrom + " out of range [0, " + rootStart + "]");
+ }
+ // Pre-compute line-start table once per pass — O(N). Eliminates
+ // O(N²) re-scan that the previous per-chunk computeSpan performed.
+ int[] lineStarts = buildLineStarts(input);
+ return rebuildRoot(input, cst, grammar, lineStarts, leadingScanFrom);
+ }
+
+ /** Build a 1-based line-start offset table. {@code lineStarts[0] = 0} (line 1). */
+ private static int[] buildLineStarts(String input) {
+ int len = input.length();
+ // Worst case: every char is '\n' → len+1 entries. Common case: ~1 per ~30 chars.
+ var starts = new int[Math.max(8, len / 16 + 4)];
+ int n = 0;
+ starts[n++ ] = 0;
+ for (int i = 0; i < len; i++ ) {
+ if (input.charAt(i) == '\n') {
+ if (n == starts.length) {
+ starts = Arrays.copyOf(starts, starts.length * 2);
+ }
+ starts[n++ ] = i + 1;
+ }
+ }
+ return Arrays.copyOf(starts, n);
+ }
+
+ /** Find {@code [line, col]} (1-based) for {@code offset} via binary search into {@code lineStarts}. */
+ private static int[] lineColAt(int[] lineStarts, int offset) {
+ int idx = Arrays.binarySearch(lineStarts, offset);
+ if (idx < 0) idx = - idx - 2;
+ // idx is now the index of the greatest lineStart <= offset.
+ int line = idx + 1;
+ int col = offset - lineStarts[idx] + 1;
+ return new int[]{line, col};
+ }
+
+ /**
+ * Scan {@code input[from..to)} for trivia chunks, segmented by repeated
+ * application of the inner expression of {@code grammar.whitespace()}.
+ * Returns an empty list when the grammar has no {@code %whitespace}
+ * directive or when {@code from >= to}.
+ */
+ public static List scanWhitespace(String input, int from, int to, Grammar grammar) {
+ if (from >= to || grammar.whitespace()
+ .isEmpty()) {
+ return List.of();
+ }
+ // Public API: build a fresh line-start table for one-off callers.
+ // Internal users (assignTrivia recursion) call scanWhitespaceFast
+ // with a shared, pre-built lineStarts array.
+ return scanWhitespaceFast(input, from, to, grammar, buildLineStarts(input));
+ }
+
+ /**
+ * Internal fast variant of {@link #scanWhitespace}: takes a precomputed
+ * line-start table to avoid the O(N²) re-scan-from-zero that the previous
+ * implementation incurred for every trivia chunk.
+ */
+ private static List scanWhitespaceFast(String input,
+ int from,
+ int to,
+ Grammar grammar,
+ int[] lineStarts) {
+ if (from >= to || grammar.whitespace()
+ .isEmpty()) {
+ return List.of();
+ }
+ var inner = ExpressionShape.extractInnerExpression(grammar.whitespace()
+ .unwrap());
+ var trivia = new ArrayList();
+ int pos = from;
+ while (pos < to) {
+ int matched = matchExpression(input, pos, to, inner, grammar);
+ if (matched < 0 || matched == pos) {
+ break;
+ }
+ var text = input.substring(pos, matched);
+ var span = computeSpanFast(lineStarts, pos, matched);
+ trivia.add(classify(span, text));
+ pos = matched;
+ }
+ return List.copyOf(trivia);
+ }
+
+ // === Tree rebuild ===
+ private static CstNode rebuildRoot(String input,
+ CstNode root,
+ Grammar grammar,
+ int[] lineStarts,
+ int leadingScanFrom) {
+ int rootStart = root.span()
+ .startOffset();
+ int rootEnd = root.span()
+ .endOffset();
+ var leading = scanWhitespaceFast(input, leadingScanFrom, rootStart, grammar, lineStarts);
+ // Root trailing covers the gap from rootEnd to end-of-input (matches
+ // current engine behaviour: top-level wrapper carries post-EOF trivia).
+ var trailingExternal = scanWhitespaceFast(input, rootEnd, input.length(), grammar, lineStarts);
+ return rebuildSelf(input, root, grammar, lineStarts, leading, trailingExternal, List.of());
+ }
+
+ /**
+ * Rebuild a node with explicit leading/extra-trailing computed by the
+ * caller. {@code extraTrailing} sticks on this node's own trailing slot
+ * (used only at the root to capture post-EOF trivia). {@code drainExtra}
+ * is the orphan-internal-trailing drained from an outer wrapper that
+ * must reach the deepest-rightmost-leaf descendant — H1 optimization
+ * eliminates the post-construction spine rebuild by threading it during
+ * recursion.
+ *
+ *
Cleanup D fast-path: when the rebuild would produce a node with the
+ * same content as the input (all incoming trivia lists empty AND existing
+ * trivia already empty for leaves), return the input unchanged. For
+ * NonTerminals, the same logic is applied per-child inside
+ * {@link #rebuildNonTerminal}.
+ */
+ private static CstNode rebuildSelf(String input,
+ CstNode node,
+ Grammar grammar,
+ int[] lineStarts,
+ List leading,
+ List extraTrailing,
+ List drainExtra) {
+ return switch (node) {
+ case CstNode.NonTerminal nt -> rebuildNonTerminal(input,
+ nt,
+ grammar,
+ lineStarts,
+ leading,
+ extraTrailing,
+ drainExtra);
+ case CstNode.Terminal t -> rebuildTerminal(t, leading, extraTrailing, drainExtra);
+ case CstNode.Token tk -> rebuildToken(tk, leading, extraTrailing, drainExtra);
+ case CstNode.Error e -> rebuildError(e, leading, extraTrailing, drainExtra);
+ };
+ }
+
+ /**
+ * Cleanup D: skip allocation when the rebuild would produce a leaf
+ * identical to the input. The leaf result has {@code leading} as
+ * leadingTrivia and {@code combine(drainExtra, extraTrailing)} as
+ * trailing. When all three inputs are empty AND the input leaf already
+ * has empty leading/trailing, the rebuilt node would be content-equal
+ * to the input — return the input reference and avoid allocating.
+ */
+ private static CstNode.Terminal rebuildTerminal(CstNode.Terminal t,
+ List leading,
+ List extraTrailing,
+ List drainExtra) {
+ if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, t.leadingTrivia(), t.trailingTrivia())) {
+ return t;
+ }
+ return new CstNode.Terminal(t.id(),
+ t.span(),
+ t.rule(),
+ t.textSpan(),
+ leading,
+ combine(drainExtra, extraTrailing));
+ }
+
+ private static CstNode.Token rebuildToken(CstNode.Token tk,
+ List leading,
+ List extraTrailing,
+ List drainExtra) {
+ if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, tk.leadingTrivia(), tk.trailingTrivia())) {
+ return tk;
+ }
+ return new CstNode.Token(tk.id(),
+ tk.span(),
+ tk.rule(),
+ tk.textSpan(),
+ leading,
+ combine(drainExtra, extraTrailing));
+ }
+
+ private static CstNode.Error rebuildError(CstNode.Error e,
+ List leading,
+ List extraTrailing,
+ List drainExtra) {
+ if (canSkipLeafRebuild(leading, extraTrailing, drainExtra, e.leadingTrivia(), e.trailingTrivia())) {
+ return e;
+ }
+ return new CstNode.Error(e.id(),
+ e.span(),
+ e.skippedText(),
+ e.expected(),
+ leading,
+ combine(drainExtra, extraTrailing));
+ }
+
+ private static boolean canSkipLeafRebuild(List leading,
+ List extraTrailing,
+ List drainExtra,
+ List existingLeading,
+ List existingTrailing) {
+ return leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty() && existingLeading.isEmpty() && existingTrailing.isEmpty();
+ }
+
+ /**
+ * Rebuild a non-root child given its preceding sibling's end offset.
+ * {@code drainExtra} is the orphan-internal-trailing of THIS wrapper
+ * propagated DOWN into the deepest-rightmost descendant of the last
+ * child — eliminates the post-construction
+ * {@code attachTrailingToTail} spine rebuild (H1).
+ *
+ *
Cleanup D fast-path: leaves with no incoming trivia and already-empty
+ * existing trivia are returned unchanged. NonTerminals delegate to
+ * {@link #rebuildNonTerminal}, which has its own skip-when-unchanged path.
+ */
+ private static CstNode rebuildChild(String input,
+ CstNode child,
+ Grammar grammar,
+ int[] lineStarts,
+ int prevEnd,
+ List drainExtra) {
+ int childStart = child.span()
+ .startOffset();
+ var leading = scanWhitespaceFast(input, prevEnd, childStart, grammar, lineStarts);
+ return switch (child) {
+ case CstNode.NonTerminal nt -> rebuildNonTerminal(input,
+ nt,
+ grammar,
+ lineStarts,
+ leading,
+ List.of(),
+ drainExtra);
+ case CstNode.Terminal t -> rebuildTerminal(t, leading, List.of(), drainExtra);
+ case CstNode.Token tk -> rebuildToken(tk, leading, List.of(), drainExtra);
+ case CstNode.Error e -> rebuildError(e, leading, List.of(), drainExtra);
+ };
+ }
+
+ private static CstNode.NonTerminal rebuildNonTerminal(String input,
+ CstNode.NonTerminal nt,
+ Grammar grammar,
+ int[] lineStarts,
+ List leading,
+ List extraTrailing,
+ List drainExtra) {
+ int spanStart = nt.span()
+ .startOffset();
+ int spanEnd = nt.span()
+ .endOffset();
+ var children = nt.children();
+ int childCount = children.size();
+ if (childCount == 0) {
+ // No children: the gap between spanStart and spanEnd is internal
+ // trailing of this wrapper. Combine with both caller-supplied
+ // wrapper-level extraTrailing and any drained extra from outer
+ // wrappers (no deeper descendant to absorb it into).
+ var internalTrailing = scanWhitespaceFast(input, spanStart, spanEnd, grammar, lineStarts);
+ // Cleanup D: skip allocation if rebuild would yield identical content.
+ if (leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty() && internalTrailing.isEmpty() && nt.leadingTrivia()
+ .isEmpty() && nt.trailingTrivia()
+ .isEmpty()) {
+ return nt;
+ }
+ return new CstNode.NonTerminal(nt.id(),
+ nt.span(),
+ nt.rule(),
+ children,
+ leading,
+ combine(combine(drainExtra, internalTrailing), extraTrailing));
+ }
+ // H1: drained orphan trailing flows DOWN through rebuildChild instead
+ // of bubbling UP via a second-pass spine rebuild. The wrapper itself
+ // keeps only its caller-supplied extraTrailing (post-EOF for root,
+ // empty for everyone else). Mirrors PegEngine.attachTrailingToTail
+ // (Bug C' compensation): the orphan trivia ends up in the
+ // deepest-rightmost-leaf's trailing slot, matching CstReconstruct.emit's
+ // last-child-only emission semantics.
+ int lastChildEnd = children.get(childCount - 1)
+ .span()
+ .endOffset();
+ var internalTrailing = scanWhitespaceFast(input, lastChildEnd, spanEnd, grammar, lineStarts);
+ // The last child's drainExtra is THIS wrapper's internalTrailing plus
+ // any drainExtra threaded from an outer wrapper. extraTrailing stays
+ // on the wrapper — only post-EOF root trivia uses that slot.
+ var lastChildDrain = combine(drainExtra, internalTrailing);
+ // Cleanup D: when no incoming trivia and no internal trailing AND existing
+ // wrapper trivia already empty, the rebuild can return nt unchanged IFF
+ // every rebuilt child is reference-equal to its input (i.e. each child
+ // also took its own skip path). Defer the allocation until we discover
+ // the first real change.
+ boolean canSkip = leading.isEmpty() && extraTrailing.isEmpty() && drainExtra.isEmpty() && internalTrailing.isEmpty() && nt.leadingTrivia()
+ .isEmpty() && nt.trailingTrivia()
+ .isEmpty();
+ ArrayList newChildren = null;
+ int cursor = spanStart;
+ for (int i = 0; i < childCount; i++ ) {
+ var c = children.get(i);
+ var drainForThis = (i == childCount - 1)
+ ? lastChildDrain
+ : List.of();
+ var rebuilt = rebuildChild(input, c, grammar, lineStarts, cursor, drainForThis);
+ if (newChildren == null) {
+ if (rebuilt != c) {
+ // First divergence: materialize the list with the children
+ // we have so far (all reference-equal to input) plus this one.
+ newChildren = new ArrayList<>(childCount);
+ for (int j = 0; j < i; j++ ) {
+ newChildren.add(children.get(j));
+ }
+ newChildren.add(rebuilt);
+ }
+ }else {
+ newChildren.add(rebuilt);
+ }
+ cursor = c.span()
+ .endOffset();
+ }
+ if (newChildren == null && canSkip) {
+ // All children unchanged AND wrapper-level rebuild would be a no-op.
+ return nt;
+ }
+ var finalChildren = newChildren == null
+ ? children
+ : List.copyOf(newChildren);
+ return new CstNode.NonTerminal(nt.id(), nt.span(), nt.rule(), finalChildren, leading, extraTrailing);
+ }
+
+ private static List combine(List a, List b) {
+ if (a.isEmpty()) return b;
+ if (b.isEmpty()) return a;
+ var combined = new ArrayList(a.size() + b.size());
+ combined.addAll(a);
+ combined.addAll(b);
+ return List.copyOf(combined);
+ }
+
+ // === Trivia classification ===
+ private static Trivia classify(SourceSpan span, String text) {
+ if (text.startsWith("//")) {
+ return new Trivia.LineComment(span, text);
+ }else if (text.startsWith("/*")) {
+ return new Trivia.BlockComment(span, text);
+ }else {
+ return new Trivia.Whitespace(span, text);
+ }
+ }
+
+ // === Span computation ===
+ /**
+ * Compute a {@link SourceSpan} for {@code input[from..to)} using a
+ * precomputed line-start table. Line/column are 1-based to match
+ * {@link SourceLocation#START}.
+ *
+ *
Replaces the previous O(from)-per-call implementation that re-scanned
+ * {@code [0, from)} for every trivia chunk, producing O(N²) total work in
+ * whitespace-rich source. With a precomputed table built once per pass
+ * (O(N)), this is O(log N) per call via binary search.
+ */
+ private static SourceSpan computeSpanFast(int[] lineStarts, int from, int to) {
+ int[] startLc = lineColAt(lineStarts, from);
+ int[] endLc = lineColAt(lineStarts, to);
+ return new SourceSpan(startLc[0], startLc[1], from, endLc[0], endLc[1], to);
+ }
+
+ // === Mini-PEG matcher for %whitespace ===
+ /**
+ * Try to match {@code expr} against {@code input[pos..limit)}. Returns
+ * the absolute offset where the match ends, or {@code -1} on failure.
+ * Implements the subset of {@link Expression} operators used by
+ * realistic {@code %whitespace} rules. Failures in unsupported branches
+ * also return {@code -1} (best-effort, conservative).
+ */
+ private static int matchExpression(String input, int pos, int limit, Expression expr, Grammar grammar) {
+ if (pos > limit) return - 1;
+ return switch (expr) {
+ case Expression.Literal lit -> matchLiteral(input, pos, limit, lit);
+ case Expression.CharClass cc -> matchCharClass(input, pos, limit, cc);
+ case Expression.Any _ -> pos < limit
+ ? pos + 1
+ : - 1;
+ case Expression.Reference ref -> matchReference(input, pos, limit, ref, grammar);
+ case Expression.Sequence seq -> matchSequence(input, pos, limit, seq, grammar);
+ case Expression.Choice ch -> matchChoice(input, pos, limit, ch, grammar);
+ case Expression.ZeroOrMore zom -> matchZeroOrMore(input, pos, limit, zom.expression(), grammar);
+ case Expression.OneOrMore oom -> matchOneOrMore(input, pos, limit, oom.expression(), grammar);
+ case Expression.Optional opt -> matchOptional(input, pos, limit, opt.expression(), grammar);
+ case Expression.Repetition rep -> matchRepetition(input, pos, limit, rep, grammar);
+ case Expression.And and -> matchExpression(input, pos, limit, and.expression(), grammar) >= 0
+ ? pos
+ : - 1;
+ case Expression.Not not -> matchExpression(input, pos, limit, not.expression(), grammar) < 0
+ ? pos
+ : - 1;
+ case Expression.Group g -> matchExpression(input, pos, limit, g.expression(), grammar);
+ case Expression.TokenBoundary tb -> matchExpression(input, pos, limit, tb.expression(), grammar);
+ case Expression.Ignore ig -> matchExpression(input, pos, limit, ig.expression(), grammar);
+ case Expression.Capture cap -> matchExpression(input, pos, limit, cap.expression(), grammar);
+ case Expression.CaptureScope cs -> matchExpression(input, pos, limit, cs.expression(), grammar);
+ case Expression.BackReference _ -> - 1;
+ // not used in %whitespace
+ case Expression.Dictionary _ -> - 1;
+ // not used in %whitespace
+ case Expression.Cut _ -> pos;
+ };
+ }
+
+ private static int matchLiteral(String input, int pos, int limit, Expression.Literal lit) {
+ var text = lit.text();
+ int len = text.length();
+ if (pos + len > limit) return - 1;
+ if (lit.caseInsensitive()) {
+ for (int i = 0; i < len; i++ ) {
+ if (Character.toLowerCase(input.charAt(pos + i)) != Character.toLowerCase(text.charAt(i))) {
+ return - 1;
+ }
+ }
+ }else {
+ for (int i = 0; i < len; i++ ) {
+ if (input.charAt(pos + i) != text.charAt(i)) return - 1;
+ }
+ }
+ return pos + len;
+ }
+
+ private static int matchCharClass(String input, int pos, int limit, Expression.CharClass cc) {
+ if (pos >= limit) return - 1;
+ char c = input.charAt(pos);
+ boolean inClass = charClassMatches(cc, c);
+ boolean ok = cc.negated()
+ ? !inClass
+ : inClass;
+ return ok
+ ? pos + 1
+ : - 1;
+ }
+
+ /** Convert PEG char-class pattern (e.g. " \\t\\n") into a single-char predicate. */
+ private static boolean charClassMatches(Expression.CharClass cc, char c) {
+ var pat = cc.pattern();
+ char target = cc.caseInsensitive()
+ ? Character.toLowerCase(c)
+ : c;
+ for (int i = 0; i < pat.length();) {
+ char ch = pat.charAt(i);
+ char unescaped;
+ int next;
+ if (ch == '\\' && i + 1 < pat.length()) {
+ unescaped = unescape(pat.charAt(i + 1));
+ next = i + 2;
+ }else {
+ unescaped = ch;
+ next = i + 1;
+ }
+ // Range: a-b
+ if (next < pat.length() && pat.charAt(next) == '-' && next + 1 < pat.length()) {
+ char rangeEndRaw = pat.charAt(next + 1);
+ char rangeEnd;
+ int after;
+ if (rangeEndRaw == '\\' && next + 2 < pat.length()) {
+ rangeEnd = unescape(pat.charAt(next + 2));
+ after = next + 3;
+ }else {
+ rangeEnd = rangeEndRaw;
+ after = next + 2;
+ }
+ char lo = unescaped, hi = rangeEnd;
+ if (cc.caseInsensitive()) {
+ lo = Character.toLowerCase(lo);
+ hi = Character.toLowerCase(hi);
+ }
+ if (target >= lo && target <= hi) return true;
+ i = after;
+ }else {
+ char single = cc.caseInsensitive()
+ ? Character.toLowerCase(unescaped)
+ : unescaped;
+ if (target == single) return true;
+ i = next;
+ }
+ }
+ return false;
+ }
+
+ private static char unescape(char escaped) {
+ return switch (escaped) {
+ case'n' -> '\n';
+ case't' -> '\t';
+ case'r' -> '\r';
+ case'\\' -> '\\';
+ case'\'' -> '\'';
+ case'"' -> '"';
+ case'0' -> '\0';
+ case']' -> ']';
+ case'[' -> '[';
+ case'-' -> '-';
+ default -> escaped;
+ };
+ }
+
+ private static int matchReference(String input, int pos, int limit, Expression.Reference ref, Grammar grammar) {
+ var ruleOpt = grammar.rule(ref.ruleName());
+ if (ruleOpt.isEmpty()) return - 1;
+ return matchExpression(input,
+ pos,
+ limit,
+ ruleOpt.unwrap()
+ .expression(),
+ grammar);
+ }
+
+ private static int matchSequence(String input, int pos, int limit, Expression.Sequence seq, Grammar grammar) {
+ int cursor = pos;
+ for (var elt : seq.elements()) {
+ cursor = matchExpression(input, cursor, limit, elt, grammar);
+ if (cursor < 0) return - 1;
+ }
+ return cursor;
+ }
+
+ private static int matchChoice(String input, int pos, int limit, Expression.Choice ch, Grammar grammar) {
+ for (var alt : ch.alternatives()) {
+ int r = matchExpression(input, pos, limit, alt, grammar);
+ if (r >= 0) return r;
+ }
+ return - 1;
+ }
+
+ private static int matchZeroOrMore(String input, int pos, int limit, Expression body, Grammar grammar) {
+ int cursor = pos;
+ while (true) {
+ int next = matchExpression(input, cursor, limit, body, grammar);
+ if (next < 0 || next == cursor) break;
+ cursor = next;
+ }
+ return cursor;
+ }
+
+ private static int matchOneOrMore(String input, int pos, int limit, Expression body, Grammar grammar) {
+ int first = matchExpression(input, pos, limit, body, grammar);
+ if (first < 0 || first == pos) return - 1;
+ int cursor = first;
+ while (true) {
+ int next = matchExpression(input, cursor, limit, body, grammar);
+ if (next < 0 || next == cursor) break;
+ cursor = next;
+ }
+ return cursor;
+ }
+
+ private static int matchOptional(String input, int pos, int limit, Expression body, Grammar grammar) {
+ int next = matchExpression(input, pos, limit, body, grammar);
+ return next < 0
+ ? pos
+ : next;
+ }
+
+ private static int matchRepetition(String input, int pos, int limit, Expression.Repetition rep, Grammar grammar) {
+ int cursor = pos;
+ int count = 0;
+ int max = rep.max()
+ .isPresent()
+ ? rep.max()
+ .unwrap()
+ : Integer.MAX_VALUE;
+ while (count < max) {
+ int next = matchExpression(input, cursor, limit, rep.expression(), grammar);
+ if (next < 0 || next == cursor) break;
+ cursor = next;
+ count++ ;
+ }
+ return count >= rep.min()
+ ? cursor
+ : - 1;
+ }
+}
diff --git a/peglib-core/src/test/java/org/pragmatica/peg/generator/GeneratedParserTriviaPostPassTest.java b/peglib-core/src/test/java/org/pragmatica/peg/generator/GeneratedParserTriviaPostPassTest.java
new file mode 100644
index 0000000..7513346
--- /dev/null
+++ b/peglib-core/src/test/java/org/pragmatica/peg/generator/GeneratedParserTriviaPostPassTest.java
@@ -0,0 +1,240 @@
+package org.pragmatica.peg.generator;
+
+import org.junit.jupiter.api.Test;
+import org.pragmatica.peg.PegParser;
+import org.pragmatica.peg.error.RecoveryStrategy;
+import org.pragmatica.peg.parser.ParserConfig;
+
+import javax.tools.ToolProvider;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Step 4 commit 4 (0.5.1) — verify the generated parser embeds and invokes a
+ * working {@code TriviaPostPass} when {@code config.triviaPostPass()=true} at
+ * generation time.
+ *
+ *
The tests:
+ *
+ *
Source emission: flag-ON includes the embedded class and the call;
+ * flag-OFF includes neither (default behavior bit-for-bit unchanged).
+ *
Standalone-parser invariant: zero peglib-runtime imports in the
+ * generated source under flag-ON.
+ *
Behavioral: compile + run a flag-ON generated parser and confirm
+ * leading/trailing trivia and content classification are correct.
+ *
Parity: generated parser under flag-ON agrees with the interpreter
+ * under flag-ON on leading-trivia text + kind for the same input.
+ *
+ *
+ *
Test grammars use a single top-level token-shaped rule (matching
+ * {@link org.pragmatica.peg.GeneratedParserTriviaTest}) so behavioral
+ * assertions target the post-pass attribution layer specifically — not the
+ * pre-existing wrap-with-rule-name single-result collapse.
+ */
+class GeneratedParserTriviaPostPassTest {
+
+ private static final String SIMPLE_GRAMMAR = """
+ Number <- < [0-9]+ >
+ %whitespace <- [ \\t\\r\\n]*
+ """;
+
+ private static final String COMMENT_GRAMMAR = """
+ Number <- < [0-9]+ >
+ %whitespace <- ([ \\t]+ / '//' [^\\n]* '\\n')+
+ """;
+
+ @Test
+ void flagOn_emitsEmbeddedTriviaPostPassClass() {
+ var source = generate(SIMPLE_GRAMMAR, "test.tpp.emit", "EmitParser", true);
+ assertThat(source).contains("private static final class TriviaPostPass");
+ assertThat(source).contains("static CstNode assignTrivia(String input, CstNode cst, int leadingScanFrom)");
+ assertThat(source).contains("TriviaPostPass.assignTrivia(input, rootNode, 0);");
+ }
+
+ @Test
+ void flagOff_doesNotEmitEmbeddedTriviaPostPassClass() {
+ var source = generate(SIMPLE_GRAMMAR, "test.tpp.off", "OffParser", false);
+ assertThat(source).doesNotContain("private static final class TriviaPostPass");
+ assertThat(source).doesNotContain("TriviaPostPass.assignTrivia");
+ }
+
+ @Test
+ void flagOn_generatedSourceHasZeroPeglibRuntimeImports() {
+ var source = generate(SIMPLE_GRAMMAR, "test.tpp.invariant", "InvariantParser", true);
+ // Standalone-parser invariant: only pragmatica-lite + java.* imports allowed.
+ assertThat(source).doesNotContain("import org.pragmatica.peg.");
+ }
+
+ @Test
+ void flagOn_parseCapturesLeadingTrivia() throws Exception {
+ var input = " 42";
+ var parserClass = compile(SIMPLE_GRAMMAR, "test.tpp.leading", "LeadingParser", true);
+ var cst = invokeParse(parserClass, input);
+ assertThat(collectLeadingTriviaText(cst)).isEqualTo(" ");
+ }
+
+ @Test
+ void flagOn_parseCapturesTrailingTrivia() throws Exception {
+ var input = "42 ";
+ var parserClass = compile(SIMPLE_GRAMMAR, "test.tpp.trailing", "TrailingParser", true);
+ var cst = invokeParse(parserClass, input);
+ assertThat(collectTrailingTriviaText(cst)).isEqualTo(" ");
+ }
+
+ @Test
+ void flagOn_parseClassifiesLineCommentTrivia() throws Exception {
+ var input = "// hello\n42";
+ var parserClass = compile(COMMENT_GRAMMAR, "test.tpp.comment", "CommentParser", true);
+ var cst = invokeParse(parserClass, input);
+ var leading = leadingTriviaList(cst);
+ assertThat(leading).hasSize(1);
+ // First trivia should be classified as LineComment by content prefix.
+ assertThat(leading.getFirst()
+ .getClass()
+ .getSimpleName()).isEqualTo("LineComment");
+ }
+
+ @Test
+ void flagOn_generatedAndInterpreterAgreeOnLeadingTrivia() throws Exception {
+ var input = " // first\n 42";
+ var parserClass = compile(COMMENT_GRAMMAR, "test.tpp.parity", "ParityParser", true);
+ var generatedCst = invokeParse(parserClass, input);
+
+ var interpreterParser = PegParser.builder(COMMENT_GRAMMAR)
+ .triviaPostPass(true)
+ .build()
+ .unwrap();
+ var interpreterCst = interpreterParser.parseCst(input)
+ .unwrap();
+
+ var genLeading = leadingTriviaList(generatedCst);
+ assertThat(genLeading).hasSameSizeAs(interpreterCst.leadingTrivia());
+ for (int i = 0; i < interpreterCst.leadingTrivia()
+ .size(); i++) {
+ var rt = interpreterCst.leadingTrivia()
+ .get(i);
+ var gt = genLeading.get(i);
+ assertThat(gt.getClass()
+ .getSimpleName()).isEqualTo(rt.getClass()
+ .getSimpleName());
+ assertThat((String) gt.getClass()
+ .getMethod("text")
+ .invoke(gt)).isEqualTo(rt.text());
+ }
+ }
+
+ @Test
+ void flagOff_defaultBehaviorParsesAndCapturesTrivia() throws Exception {
+ // Validate flag-OFF generation still parses and captures trivia (the
+ // pre-existing engine path; no post-pass invoked).
+ var input = " 42 ";
+ var parserClass = compile(SIMPLE_GRAMMAR, "test.tpp.unchanged", "UnchangedParser", false);
+ var cst = invokeParse(parserClass, input);
+ assertThat(collectLeadingTriviaText(cst)).isEqualTo(" ");
+ assertThat(collectTrailingTriviaText(cst)).isEqualTo(" ");
+ }
+
+ // === Generator helpers ===
+ private String generate(String grammar, String pkg, String cls, boolean triviaPostPass) {
+ var base = ParserConfig.parserConfig(true, RecoveryStrategy.BASIC, true);
+ var config = new ParserConfig(
+ base.packratEnabled(),
+ base.recoveryStrategy(),
+ base.captureTrivia(),
+ base.fastTrackFailure(),
+ base.literalFailureCache(),
+ base.charClassFailureCache(),
+ base.bulkAdvanceLiteral(),
+ base.skipWhitespaceFastPath(),
+ base.reuseEndLocation(),
+ base.choiceDispatch(),
+ base.markResetChildren(),
+ base.inlineLocations(),
+ base.selectivePackrat(),
+ Set.of(),
+ base.mutableParseResult(),
+ base.tokenFastPath(),
+ triviaPostPass);
+ return PegParser.generateCstParser(grammar, pkg, cls, ErrorReporting.BASIC, config)
+ .unwrap();
+ }
+
+ private Class> compile(String grammar, String pkg, String cls, boolean triviaPostPass) throws Exception {
+ var source = generate(grammar, pkg, cls, triviaPostPass);
+ var fqcn = pkg + "." + cls;
+ var tempDir = Files.createTempDirectory("peglib-tpp-test");
+ var packageDir = tempDir.resolve(pkg.replace('.', '/'));
+ Files.createDirectories(packageDir);
+ var sourceFile = packageDir.resolve(cls + ".java");
+ Files.writeString(sourceFile, source);
+ var compiler = ToolProvider.getSystemJavaCompiler();
+ int rc = compiler.run(null, null, null,
+ "-d", tempDir.toString(),
+ "-cp", System.getProperty("java.class.path"),
+ sourceFile.toString());
+ if (rc != 0) {
+ throw new RuntimeException("Compilation failed for " + fqcn);
+ }
+ var loader = new URLClassLoader(new URL[]{tempDir.toUri()
+ .toURL()});
+ return loader.loadClass(fqcn);
+ }
+
+ private Object invokeParse(Class> parserClass, String input) throws Exception {
+ var parser = parserClass.getDeclaredConstructor()
+ .newInstance();
+ var parseMethod = findParseMethod(parserClass);
+ var result = parseMethod.invoke(parser, input);
+ var unwrap = result.getClass()
+ .getMethod("unwrap");
+ return unwrap.invoke(result);
+ }
+
+ private Method findParseMethod(Class> parserClass) {
+ for (var m : parserClass.getMethods()) {
+ if ("parse".equals(m.getName()) && m.getParameterCount() == 1
+ && m.getParameterTypes()[0] == String.class) {
+ return m;
+ }
+ }
+ throw new IllegalStateException("No parse(String) method on " + parserClass);
+ }
+
+ private List