Skip to content

release 0.2.2: perf rework (4.23× speedup)#13

Merged
siy merged 12 commits into
mainfrom
release-0.2.2
Apr 21, 2026
Merged

release 0.2.2: perf rework (4.23× speedup)#13
siy merged 12 commits into
mainfrom
release-0.2.2

Conversation

@siy

@siy siy commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Performance rework for the generated CST parser. Landing the spec at docs/PERF-REWORK-SPEC.md.

Measured on the 1,900-LOC FactoryClassGenerator.java.txt fixture (JMH avgT, JDK 25.0.2):

  • Pre-0.2.2 baseline: 425.6 ms/op
  • New DEFAULT (phase-1 + choiceDispatch): 100.7 ms/op4.23× speedup (exceeds the spec's 1.5× bar and the 2× stretch)

What changed

  • Phase 1 (6 flags, default on): fastTrackFailure, literalFailureCache, charClassFailureCache, bulkAdvanceLiteral, skipWhitespaceFastPath, reuseEndLocation — hand-edits ported to the generator's emitter templates per spec §6. Combined measured win: 1.70× vs baseline.
  • Phase 2 (4 flags):
    • choiceDispatch → default on (2.49× over phase-1 baseline).
    • markResetChildren, inlineLocations, selectivePackrat → default off — no statistically significant individual win on the reference JVM.
  • Flag model: all 10 flags are consumed at generation time. The emitted parser has a single code path per flag combination — no runtime branching, no size bloat.
  • §6.5 message unification: matchCharClassCst failure string is now the bracketed label ("[a-z]") instead of the generic "character class". Zero breakage in existing tests.

Verification

  • 11 commits, each parity-validated against committed CST hashes on a 22-file Java 25 corpus.
  • 9 parametrized parity test classes covering individual flags and composed flag sets.
  • GeneratorFlagInertnessTest asserts byte-identical generator output when flags are off.
  • Test suite: 350 → 565 passing, 0 failures, 1 skipped (RoundTripTest — documents a pre-existing intra-sequence trivia-drop gap).
  • Raw JMH data + packrat probe output committed at docs/bench-results/.

Test plan

  • mvn test — 565/565 + 1 skipped
  • mvn install — artifact published to local ~/.m2
  • Corpus parity: 22 files × 9 variants = 198 CST hash checks
  • mvn -Pbench -DskipTests package && java -jar target/benchmarks.jar — JMH matrix completes

Summary by CodeRabbit

  • New Features

    • Added configurable performance optimizations to the CST parser generation via ParserConfig flags (phase-1: failure tracking, caching, whitespace fast-path; phase-2: choice dispatch, location inlining, selective packrat).
    • Generator-time flag configuration with no runtime overhead.
  • Performance

    • Achieved 4.23× measured speedup on Java 25 parsing.
  • Tests

    • Expanded test coverage to 565 tests (1 skipped).
    • Added comprehensive CST parity validation across optimization variants.
  • Documentation

    • New performance rework specification and benchmark results.
  • Chores

    • Version bumped to 0.2.2.

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduced a major performance rework framework for the PEG CST parser generator, featuring generator-time ParserConfig flag configurability (12 new optimization flags across two phases), relocated the Java 25 grammar to a classpath resource, established JMH benchmarking infrastructure, and added comprehensive CST validation/hashing utilities and performance corpus baseline tracking.

Changes

Cohort / File(s) Summary
Configuration & Generator Core
src/main/java/org/pragmatica/peg/parser/ParserConfig.java, src/main/java/org/pragmatica/peg/generator/ParserGenerator.java, src/main/java/org/pragmatica/peg/PegParser.java
Added 12 generator-time performance flags to ParserConfig (fast-track failure, failure caching, bulk advancement, whitespace fast-path, location reuse, choice dispatch, mark/reset optimization, inline locations, selective packrat); updated ParserGenerator to accept and consume config flags during code emission (failure tracking, caching, whitespace early-exit, choice dispatch codegen, inline location handling, selective packrat memoization); updated PegParser factory methods to thread config through.
Choice Dispatch Optimization
src/main/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzer.java
New analyzer utility classifying choice expressions for character-dispatch optimization, providing helpers to extract first literal characters, group alternatives by dispatch char (handling case-insensitivity), and coalesce into dispatch buckets.
CST Validation & Reconstruction
src/test/java/org/pragmatica/peg/perf/CstHash.java, src/test/java/org/pragmatica/peg/perf/CstReconstruct.java, src/test/java/org/pragmatica/peg/perf/GeneratedJava25Parser.java
New utilities for deterministic CST structural hashing, source reconstruction from CST, and runtime generation/compilation/caching of Java 25 parser with per-config variant support via reflective method invocation.
Performance Baseline Tracking
src/test/java/org/pragmatica/peg/perf/BaselineGenerator.java, src/test/java/org/pragmatica/peg/perf/BaselineGeneratorRunner.java, src/test/java/org/pragmatica/peg/perf/PackratStatsProbe.java
New baseline artifact generation (per-file CST hashes and rule-hit counts), conditional test runner for regeneration (perf.regen=1), and packrat cache statistics probe with reflective cache key decoding.
CST Parity Tests
src/test/java/org/pragmatica/peg/perf/CorpusParityTest.java, src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java, src/test/java/org/pragmatica/peg/perf/Phase2*.java (5 variants), src/test/java/org/pragmatica/peg/perf/RoundTripTest.java
New parameterized JUnit 5 corpus-based tests validating CST hash equality across baseline and phase-1/phase-2 optimization variants, plus disabled round-trip reconstruction test documenting trivia-handling gap.
Generator Verification Tests
src/test/java/org/pragmatica/peg/generator/GeneratorFlagInertnessTest.java, src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java
New tests asserting byte-identical code generation when using ParserConfig.DEFAULT, and validating ChoiceDispatchAnalyzer classification, grouping, and bucketing logic across literal and wrapper-expression cases.
Runtime Test Updates
src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java, src/test/java/org/pragmatica/peg/examples/Java25GrammarExample.java
Updated ParsingContextTest to use ParserConfig.of(...) factory for backward compatibility; refactored Java25GrammarExample to load grammar from classpath resource /java25.peg instead of inline string.
JMH Benchmark Setup
src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java, pom.xml
New JMH parameterized benchmark class testing seven config variants with trial-level setup (grammar load, parser generation, runtime compilation via JDK compiler, class loading via URLClassLoader, method caching) and per-invocation parse benchmarking; added Maven bench profile enabling JMH annotation processing, compilation, and shaded uber-jar packaging.
Performance Corpus & Baselines
src/test/resources/perf-corpus/*, src/test/resources/perf-corpus-baseline/*, src/test/resources/java25.peg
Added Java 25 PEG grammar resource, 20+ Java format-example sources (annotations, lambdas, records, switch expressions, etc.) and 1 large fixture for perf testing; committed CST structural-hash baselines (.hash files) and rule-hit frequency maps (.ruleHits.txt) and aggregate coverage summary.
Documentation & Results
CHANGELOG.md, CLAUDE.md, README.md, docs/PERF-REWORK-SPEC.md, docs/bench-results/java25-parse.json, docs/bench-results/packrat-stats.txt
Updated changelog with 0.2.2 release notes; updated test counts (350→565 tests, 1 skipped); added performance section to README and specification document detailing optimization design, rollout strategy, verification plan, and JMH metrics; recorded benchmark results and packrat cache statistics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The PR spans a substantial and dense refactor of ParserGenerator (779 lines changed) introducing multiple optimization code paths (failure caching, choice dispatch, whitespace fast-path, inline locations, selective packrat), coupled with broad infrastructure additions (6+ new utility/test classes, JMH benchmarking, performance corpus, baselines) and ParserConfig signature expansion (12 new fields). Heterogeneous changes across configuration, code generation, testing, and documentation require separate reasoning for generator logic, test correctness, and resource adequacy.

Possibly related PRs

  • chore: release 0.2.0 #11: Modifies whitespace-skipping logic in ParserGenerator (closely related to the new skip-whitespace fast-path optimization introduced here).
  • chore: release 0.1.8 #9: Adjusts failure-tracking and error-reporting implementation in ParserGenerator (directly overlaps with the new trackFailure fast-track and failure-caching changes).
  • chore: release 0.2.1 #12: Updates CST parser generation and validation (related to overall CST generation refactoring and structural changes in ParserGenerator).

Poem

🐰 Hark! The generator now speaks in flags and tunes,
Phase-one swift and phase-two wise, a dance of optimized runes.
CST hashes dance in baseline rows, dispatch chars take their bows,
Four-fold faster parsed the Java tongue—the rabbit hops and vows! 🥕

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release-0.2.2

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (12)
src/test/resources/perf-corpus/flow-format-examples/BlankLineRules.java (2)

26-26: Minor formatting inconsistency in record declaration.

The record declaration is missing a space before the opening brace. Standard Java formatting conventions typically include a space.

✨ Suggested formatting improvement
-    record Inner(String value){}
+    record Inner(String value) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/flow-format-examples/BlankLineRules.java` at
line 26, The record declaration for Inner lacks a space before the opening
brace; update the declaration of record Inner(String value) {} (symbol: Inner)
to include a space before the '{' so it follows standard Java formatting
conventions (i.e., change "record Inner(String value){}" to "record Inner(String
value) {}").

6-6: Note: Unused field active in test fixture.

The active field is initialized in the constructor but never exposed via a getter or used elsewhere in the class. This is acceptable for test fixture data, but if this field is intended to be accessed, consider adding a getter method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/flow-format-examples/BlankLineRules.java` at
line 6, The private field `active` in class `BlankLineRules` is never used
outside the constructor; either remove the unused field or add a public accessor
if it should be exposed—locate the `private final boolean active;` declaration
and the constructor that initializes it, then either delete the field and
related constructor parameter or implement a getter method (e.g., `isActive()`
or `getActive()`) so callers can read the value.
src/test/java/org/pragmatica/peg/perf/CstReconstruct.java (1)

85-93: setAccessible(true) is unnecessary for public record accessors.

The CST node types are records with public accessor methods. setAccessible(true) is only needed for non-public members and adds slight overhead. Since this is test utility code, it's not critical, but it could be removed.

♻️ Optional simplification
 private static Method findMethod(Class<?> cls, String name) {
     try {
         var m = cls.getMethod(name);
-        m.setAccessible(true);
         return m;
     } catch (NoSuchMethodException e) {
         throw new RuntimeException("No method " + name + "() on " + cls, e);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/pragmatica/peg/perf/CstReconstruct.java` around lines 85 -
93, The helper findMethod(Class<?> cls, String name) calls m.setAccessible(true)
unnecessarily for public record accessor methods; remove the
m.setAccessible(true) call and simply return the Method obtained from
cls.getMethod(name), keeping the existing try/catch and RuntimeException
behavior for NoSuchMethodException so tests still fail cleanly when the accessor
is missing.
docs/bench-results/java25-parse.json (1)

8-13: Normalize host-specific JVM path in committed benchmark snapshots.

Committing absolute local paths makes this artifact noisier and less portable; prefer a sanitized value (e.g., just JDK/VM version fields).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/bench-results/java25-parse.json` around lines 8 - 13, The benchmark
snapshot currently commits a host-specific absolute JVM path under the "jvm" key
which makes the artifact non-portable; change the snapshot generation (or
post-processing) so that the "jvm" field is normalized to a generic value (e.g.,
null, "", or a placeholder like "JVM_PATH_OMITTED") or omitted entirely while
retaining the actual version details in "jdkVersion", "vmName", and "vmVersion";
update the code that writes the JSON (the logic producing the "jvm" key) to
substitute the sanitized value instead of the absolute path and ensure tests or
consumers expect the normalized form.
docs/bench-results/packrat-stats.txt (1)

1-2: Drop machine-specific warning preamble from committed benchmark output.

These lines are environment noise and reduce artifact stability across contributors/CI runs.

Proposed cleanup
-Note: /var/folders/m7/lwdzwk711cl83l1tf25ssnkm0000gn/T/peglib-bench-generated11399443917318641120/generated/bench/probe/Java25ProbeParser.java uses or overrides a deprecated API.
-Note: Recompile with -Xlint:deprecation for details.
 === PackratStatsProbe ===
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/bench-results/packrat-stats.txt` around lines 1 - 2, The committed
benchmark output contains machine-specific compiler warnings at the top that add
noise; remove the two preamble lines ("uses or overrides a deprecated API" and
"Recompile with -Xlint:deprecation for details") from
docs/bench-results/packrat-stats.txt, or update the benchmark generation
workflow to strip such stderr/stdout warnings before committing (i.e., filter
these exact warning patterns from generated bench output so the artifact is
stable across machines).
src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java (1)

52-80: Consider extracting shared test infrastructure.

The corpusFiles() method and the baseline-loading/comparison logic are duplicated across all 9 parity test classes. Extracting this to a shared base class or utility would reduce maintenance burden and ensure consistent behavior.

Example extraction:

// In a new ParityTestSupport.java
public final class ParityTestSupport {
    public static final Path CORPUS_ROOT = Path.of("src/test/resources/perf-corpus");
    public static final Path BASELINE_ROOT = Path.of("src/test/resources/perf-corpus-baseline");
    
    public static Stream<Path> corpusFiles() throws IOException { ... }
    public static String loadBaseline(Path corpusFile) throws IOException { ... }
}

The current duplicated approach is acceptable if you prefer explicit test isolation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java`
around lines 52 - 80, Extract the duplicated corpus/baseline logic into a shared
helper (e.g., ParityTestSupport) and update the test to use it: move CORPUS_ROOT
and BASELINE_ROOT constants plus the corpusFiles() Stream and a new
loadBaseline(Path) method into ParityTestSupport, then change the MethodSource
to refer to that shared corpusFiles and replace the baseline-reading/comparison
code inside phase2MarkResetChildrenCstHashMatchesBaseline to call
ParityTestSupport.loadBaseline(file) (and use
ParityTestSupport.CORPUS_ROOT/BASELINE_ROOT as needed) so all nine parity tests
share the same implementation.
src/test/java/org/pragmatica/peg/perf/CstHash.java (1)

127-135: setAccessible(true) is unnecessary for public methods.

Record accessor methods (like span(), text(), rule(), children()) are public by definition. Calling setAccessible(true) is redundant and may trigger warnings or reflection overhead on recent JVMs with strong encapsulation.

♻️ Suggested simplification
 private static Method findMethod(Class<?> cls, String name) {
     try {
-        var m = cls.getMethod(name);
-        m.setAccessible(true);
-        return m;
+        return cls.getMethod(name);
     } catch (NoSuchMethodException e) {
         throw new RuntimeException("No method " + name + "() on " + cls, e);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/pragmatica/peg/perf/CstHash.java` around lines 127 - 135,
The findMethod helper unnecessarily calls setAccessible(true) for public
methods; remove the m.setAccessible(true) invocation from findMethod(Class<?>
cls, String name) so it simply looks up and returns the public method (keeping
the existing NoSuchMethodException handling) — this eliminates redundant
reflection calls for record accessors like span(), text(), rule(), children().
src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java (1)

71-84: Cache field access handles null correctly but could fail if field doesn't exist.

The null check on line 78 handles the case where cache is empty, but getDeclaredField("cache") will throw NoSuchFieldException if a future generator change removes or renames the field. Consider wrapping in try-catch with a descriptive message.

♻️ Defensive field access
-        Field cacheField = parserClass.getDeclaredField("cache");
-        cacheField.setAccessible(true);
-        `@SuppressWarnings`("unchecked")
-        var cache = (Map<Long, ?>) cacheField.get(instance);
+        Map<Long, ?> cache;
+        try {
+            Field cacheField = parserClass.getDeclaredField("cache");
+            cacheField.setAccessible(true);
+            `@SuppressWarnings`("unchecked")
+            var cacheValue = (Map<Long, ?>) cacheField.get(instance);
+            cache = cacheValue;
+        } catch (NoSuchFieldException e) {
+            System.err.println("Warning: no 'cache' field found — packrat may be disabled");
+            cache = Map.of();
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java` around lines 71
- 84, The reflection call parserClass.getDeclaredField("cache") can throw
NoSuchFieldException if the generated parser no longer has a "cache" field;
update PackratStatsProbe to defensively wrap the getDeclaredField/get access in
a try-catch that catches NoSuchFieldException (and optionally
IllegalAccessException), and handle it by logging or returning gracefully with a
clear message (include parserClass and field name) rather than letting the
exception propagate; ensure references to cacheField, cache, and the existing
loop remain unchanged when the field is present.
src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java (1)

160-187: Release the generated compiler artifacts after each trial.

The compileAndLoad() method creates a temp directory and URLClassLoader per trial without cleanup. In repeated JMH trials, this accumulates generated source trees under the temp directory and keeps files pinned on some platforms due to open loaders. Store both as fields and add @TearDown(Level.Trial) to properly close the loader and delete the directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java` around lines
160 - 187, The compileAndLoad method leaks resources by creating tempDir and
URLClassLoader per trial without cleanup; modify the class so tempDir and
classLoader returned/created by compileAndLoad are stored in fields (e.g., Path
tempDir and URLClassLoader classLoader on the Java25ParseBenchmark class),
change compileAndLoad to assign those fields instead of local vars, and add a
`@TearDown`(Level.Trial) method (e.g., tearDownTrial) that closes classLoader and
recursively deletes tempDir (handling exceptions) to release files and allow
cleanup after each trial.
src/main/java/org/pragmatica/peg/generator/ParserGenerator.java (1)

2321-2333: Unwind visiting after recursive reference analysis.

Right now a successful walk leaves the rule name in the set, so a later sibling reference to the same non-recursive helper will be treated as recursion and disable the fast path unnecessarily.

Minimal fix
             case Expression.Reference ref -> {
                 if (!visiting.add(ref.ruleName())) yield false;
                 // recursion — bail
                 var target = grammar.rules()
                                     .stream()
                                     .filter(r -> r.name()
                                                   .equals(ref.ruleName()))
                                     .findFirst();
-                if (target.isEmpty()) yield false;
-                yield collectFirstChars(target.get()
-                                              .expression(),
-                                        out,
-                                        visiting);
+                if (target.isEmpty()) {
+                    visiting.remove(ref.ruleName());
+                    yield false;
+                }
+                var ok = collectFirstChars(target.get()
+                                                 .expression(),
+                                           out,
+                                           visiting);
+                visiting.remove(ref.ruleName());
+                yield ok;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/pragmatica/peg/generator/ParserGenerator.java` around lines
2321 - 2333, The visiting set is not unwound after handling
Expression.Reference, causing later sibling references to the same non-recursive
rule to be treated as recursion; modify the branch that handles
Expression.Reference (using ref.ruleName(), visiting, grammar.rules(), and
collectFirstChars) to remove ref.ruleName() from visiting before yielding the
result (ensure you add visiting.add(ref.ruleName()) early, call
collectFirstChars(..., visiting), capture its boolean result, then remove
ref.ruleName() from visiting and return that captured result).
src/main/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzer.java (1)

159-165: Consider skipping leading Cut here too.

Expression.Cut is zero-width, so sequences like ^ 'foo' still have a fixed first consumed char. Treating Cut like the predicate wrappers would let more choices use dispatch without changing semantics.

Small refactor
     private static FirstChar firstLiteralInSequence(Expression.Sequence seq) {
         for (var el : seq.elements()) {
-            if (el instanceof Expression.And || el instanceof Expression.Not) {
+            if (el instanceof Expression.And || el instanceof Expression.Not || el instanceof Expression.Cut) {
                 continue;
             }
             return firstLiteral(el);
         }
         return null;
Based on learnings: "Applies to src/main/java/org/pragmatica/peg/grammar/Expression.java : Use sealed interfaces for expression types (Expression.java) with implementations for Literal, CharClass, Any, Reference, Sequence, Choice, ZeroOrMore, OneOrMore, Optional, Repetition, And, Not, TokenBoundary, Ignore, Capture, BackReference, Cut, and Group"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzer.java`
around lines 159 - 165, The method firstLiteralInSequence currently skips
predicate wrappers but not zero-width Cut, so update firstLiteralInSequence to
also treat Expression.Cut as a non-consuming element (i.e., continue past it)
when iterating seq.elements(); specifically, add a check for "el instanceof
Expression.Cut" alongside the existing "el instanceof Expression.And || el
instanceof Expression.Not" so the method defers to firstLiteral(el) only for
truly consuming elements and preserves semantics for sequences like Cut followed
by a literal.
src/test/resources/perf-corpus/format-examples/ChainAlignment.java (1)

134-152: Prefer fail-fast stubs over null returns in helper placeholders.

If this fixture is accidentally executed, null can obscure root cause and cause downstream NPEs. Using explicit UnsupportedOperationException keeps failures immediate and obvious.

Proposed fail-fast stub update
     Result<String> validate(String s) {
-        return null;
+        throw new UnsupportedOperationException("perf corpus fixture stub");
     }
@@
     Result<Response> createResponse(Object tuple) {
-        return null;
+        throw new UnsupportedOperationException("perf corpus fixture stub");
     }
@@
     Result<String> expensiveValidation(String s) {
-        return null;
+        throw new UnsupportedOperationException("perf corpus fixture stub");
     }
@@
     String combine(A a, B b, C c) {
-        return null;
+        throw new UnsupportedOperationException("perf corpus fixture stub");
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/format-examples/ChainAlignment.java` around
lines 134 - 152, Replace the helper stubs that return null with fail-fast
exceptions so accidental execution doesn't produce NPEs: in methods
Result<String> validate(String), Result<Response> createResponse(Object),
Result<String> expensiveValidation(String), String combine(A,B,C), and any
logging helpers log(String) / logError(Object) where applicable, throw new
UnsupportedOperationException (with a brief message identifying the method)
instead of returning null; update those method bodies (e.g., validate,
createResponse, expensiveValidation, combine, log, logError) to raise the
exception so failures are immediate and descriptive.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/PERF-REWORK-SPEC.md`:
- Around line 3-6: Update the status banner in PERF-REWORK-SPEC.md by replacing
the phrase "Not yet executed" with a release-accurate status (e.g., "Implemented
/ released" or "Shipped with verification artifacts") so the spec reflects that
the ParserGenerator rework and its verification artifacts are included in this
PR; edit the markdown line that currently reads "**Status:** proposal / spec for
an implementing agent. Not yet executed." to state the new status and optionally
add a brief parenthetical like "(shipped with this PR)" for clarity.

In `@src/main/java/org/pragmatica/peg/generator/ParserGenerator.java`:
- Around line 2305-2316: The Sequence branch in collectFirstChars incorrectly
stops at the first non-predicate element; change it to treat nullable prefixes
(e.g., Expression.Optional and Expression.ZeroOrMore) as potentially empty so
the walker continues to later elements when they can produce ε. Update
ParserGenerator.collectFirstChars: inside the case Expression.Sequence seq ->
loop, for each element skip predicates (Expression.And/Expression.Not) as
before, but if an element is nullable (create/use a helper like
isNullable(Expression) or reuse existing nullability logic for
Expression.Optional and Expression.ZeroOrMore) then still call collectFirstChars
on that element to accumulate starters and continue to the next element; only
return/stop early when you encounter a non-nullable element whose collected set
determines the result. Ensure Expression.OneOrMore is treated as non-nullable.
- Around line 4100-4112: The non-cached char-class branch currently returns
CstParseResult.failure("character class") making diagnostics differ from the
cached path; update the failure to use the same bracketed label used in
trackFailure so diagnostics are independent of charClassFailureCache — replace
the CstParseResult.failure("character class") calls in the else branch (the
block that uses isAtEnd(), startLoc, peek(), matchesPattern, negated,
trackFailure) so they return CstParseResult.failure("[" + (negated ? "^" : "") +
pattern + "]") while leaving trackFailure and matchesPattern unchanged.

In `@src/main/java/org/pragmatica/peg/parser/ParserConfig.java`:
- Around line 52-66: Add a compact record constructor to ParserConfig that
defensively copies the packratSkipRules parameter using
Set.copyOf(packratSkipRules) so the record always holds an immutable snapshot;
locate the record declaration ParserConfig and implement a compact constructor
that assigns all components by delegating to the canonical constructor while
replacing the incoming packratSkipRules with Set.copyOf(packratSkipRules) (and
handle null if needed) to prevent external mutations from affecting the record's
equality/cache-dependent behavior.

In `@src/test/java/org/pragmatica/peg/generator/GeneratorFlagInertnessTest.java`:
- Around line 24-61: The tests in GeneratorFlagInertnessTest (methods
generateCst_withDefaultConfig_isByteIdenticalToLegacyFactory,
generateCst_withDefaultConfigAndAdvancedReporting_isByteIdenticalToLegacyFactory,
generate_withDefaultConfig_isByteIdenticalToLegacyFactory) are only comparing
the no-arg ParserGenerator.create(...) overload to the explicit
ParserConfig.DEFAULT and so never exercise an actual "all flags off" config;
update each test to construct and pass an explicit all-off ParserConfig (e.g.,
build a ParserConfig with every perf flag set to false or use a dedicated
ParserConfig.ALL_OFF if available) when creating configuredSource so the
assertions compare legacySource to a true all-off configuration, or
alternatively rename the tests to state they verify overload parity rather than
flag inertness if you intend to keep the current behavior.

In `@src/test/java/org/pragmatica/peg/perf/GeneratedJava25Parser.java`:
- Around line 48-64: The publish ordering of parserClass and parseMethod is
racy: ensureLoaded currently assigns parserClass before parseMethod so another
thread can see parserClass != null and try to use a still-null parseMethod; to
fix, compute/compile the Class and obtain the Method into local variables (e.g.,
localParserClass and localParseMethod) inside the synchronized(LOCK) block and
only assign the shared parserClass (or a single holder field) as the last step
(or replace parserClass/parseMethod with one immutable holder object) so both
values become visible atomically; update ensureLoaded to set the shared
reference last and keep compileAndLoad(...) and parserClass/getMethod("parse",
String.class) lookups unchanged otherwise.
- Around line 146-167: The configKey(ParserConfig c) currently omits the
recoveryStrategy, causing different ParserConfig instances to collide; update
configKey to include a deterministic encoding of c.recoveryStrategy() (for
example append a separator and either the strategy name or its ordinal/string)
so that recoveryStrategy values produce distinct keys; locate the method
configKey(ParserConfig c) in GeneratedJava25Parser.java and append the
recoveryStrategy representation to the StringBuilder (using a stable,
filesystem-safe form) before returning the key.

In `@src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java`:
- Around line 49-52: The corpusFiles() helper silently returns an empty Stream
when CORPUS_ROOT is missing which yields false-green tests; change corpusFiles()
to fail fast by checking Files.isDirectory(CORPUS_ROOT) and throwing an
exception (e.g., throw new IOException("Missing corpus root: " + CORPUS_ROOT) or
an AssertionError) instead of returning Stream.empty(), and factor that check
into a shared helper (e.g., ensureCorpusRootExists or validateCorpusRoot) so
other phase parity tests can call it as well.

In `@src/test/resources/perf-corpus/format-examples/Imports.java`:
- Line 39: The declaration List<String> list = new ArrayList(); uses a raw
ArrayList; change the instantiation to use the diamond operator (List<String>
list = new ArrayList<>()) to remove unchecked-assignment warnings, or if the raw
type is intentional for testing, add an explicit comment next to the
instantiation (e.g., "// raw type intentional for parsing test") to document the
intent; update the occurrence of ArrayList() in Imports.java accordingly.

In `@src/test/resources/perf-corpus/format-examples/MultilineArguments.java`:
- Around line 105-107: The declaration ValidRequest currently defined as an
interface contains a constructor-like member which is invalid; change the
declaration kind from interface to a concrete type (e.g., convert the interface
ValidRequest to a record or class) and keep the parameter list (Object... args)
as the record/class constructor signature so the constructor body is valid;
update references to ValidRequest accordingly and remove the interface keyword
so the compiler accepts the constructor-like declaration.

In `@src/test/resources/perf-corpus/format-examples/MultilineParameters.java`:
- Around line 58-64: Replace the invalid interface declarations for ValidRequest
and Response with record declarations so the file compiles: change the interface
ValidRequest and interface Response blocks into records named ValidRequest and
Response that take an Object[] args component (e.g., record
ValidRequest(Object[] args) {} and record Response(Object[] args) {}), keeping
the empty bodies so the earlier new ValidRequest(...) and new Response(...)
instantiations are valid.

In `@src/test/resources/perf-corpus/format-examples/SwitchExpressions.java`:
- Around line 53-60: The switch expression in guardedSwitch contains duplicate
unguarded patterns (multiple "case String s" and "case Integer i") which are
illegal; update those cases to either add appropriate when-guards (e.g., case
String s when s.isEmpty(), case String s when s.length() < N, case Integer i
when i < 0, etc.) or merge/refactor them into a single case per type that
dispatches internally. Locate the switch inside guardedSwitch and apply guards
to each duplicate pattern or consolidate logic so each pattern type appears only
once.

---

Nitpick comments:
In `@docs/bench-results/java25-parse.json`:
- Around line 8-13: The benchmark snapshot currently commits a host-specific
absolute JVM path under the "jvm" key which makes the artifact non-portable;
change the snapshot generation (or post-processing) so that the "jvm" field is
normalized to a generic value (e.g., null, "", or a placeholder like
"JVM_PATH_OMITTED") or omitted entirely while retaining the actual version
details in "jdkVersion", "vmName", and "vmVersion"; update the code that writes
the JSON (the logic producing the "jvm" key) to substitute the sanitized value
instead of the absolute path and ensure tests or consumers expect the normalized
form.

In `@docs/bench-results/packrat-stats.txt`:
- Around line 1-2: The committed benchmark output contains machine-specific
compiler warnings at the top that add noise; remove the two preamble lines
("uses or overrides a deprecated API" and "Recompile with -Xlint:deprecation for
details") from docs/bench-results/packrat-stats.txt, or update the benchmark
generation workflow to strip such stderr/stdout warnings before committing
(i.e., filter these exact warning patterns from generated bench output so the
artifact is stable across machines).

In `@src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java`:
- Around line 160-187: The compileAndLoad method leaks resources by creating
tempDir and URLClassLoader per trial without cleanup; modify the class so
tempDir and classLoader returned/created by compileAndLoad are stored in fields
(e.g., Path tempDir and URLClassLoader classLoader on the Java25ParseBenchmark
class), change compileAndLoad to assign those fields instead of local vars, and
add a `@TearDown`(Level.Trial) method (e.g., tearDownTrial) that closes
classLoader and recursively deletes tempDir (handling exceptions) to release
files and allow cleanup after each trial.

In `@src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java`:
- Around line 71-84: The reflection call parserClass.getDeclaredField("cache")
can throw NoSuchFieldException if the generated parser no longer has a "cache"
field; update PackratStatsProbe to defensively wrap the getDeclaredField/get
access in a try-catch that catches NoSuchFieldException (and optionally
IllegalAccessException), and handle it by logging or returning gracefully with a
clear message (include parserClass and field name) rather than letting the
exception propagate; ensure references to cacheField, cache, and the existing
loop remain unchanged when the field is present.

In `@src/main/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzer.java`:
- Around line 159-165: The method firstLiteralInSequence currently skips
predicate wrappers but not zero-width Cut, so update firstLiteralInSequence to
also treat Expression.Cut as a non-consuming element (i.e., continue past it)
when iterating seq.elements(); specifically, add a check for "el instanceof
Expression.Cut" alongside the existing "el instanceof Expression.And || el
instanceof Expression.Not" so the method defers to firstLiteral(el) only for
truly consuming elements and preserves semantics for sequences like Cut followed
by a literal.

In `@src/main/java/org/pragmatica/peg/generator/ParserGenerator.java`:
- Around line 2321-2333: The visiting set is not unwound after handling
Expression.Reference, causing later sibling references to the same non-recursive
rule to be treated as recursion; modify the branch that handles
Expression.Reference (using ref.ruleName(), visiting, grammar.rules(), and
collectFirstChars) to remove ref.ruleName() from visiting before yielding the
result (ensure you add visiting.add(ref.ruleName()) early, call
collectFirstChars(..., visiting), capture its boolean result, then remove
ref.ruleName() from visiting and return that captured result).

In `@src/test/java/org/pragmatica/peg/perf/CstHash.java`:
- Around line 127-135: The findMethod helper unnecessarily calls
setAccessible(true) for public methods; remove the m.setAccessible(true)
invocation from findMethod(Class<?> cls, String name) so it simply looks up and
returns the public method (keeping the existing NoSuchMethodException handling)
— this eliminates redundant reflection calls for record accessors like span(),
text(), rule(), children().

In `@src/test/java/org/pragmatica/peg/perf/CstReconstruct.java`:
- Around line 85-93: The helper findMethod(Class<?> cls, String name) calls
m.setAccessible(true) unnecessarily for public record accessor methods; remove
the m.setAccessible(true) call and simply return the Method obtained from
cls.getMethod(name), keeping the existing try/catch and RuntimeException
behavior for NoSuchMethodException so tests still fail cleanly when the accessor
is missing.

In
`@src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java`:
- Around line 52-80: Extract the duplicated corpus/baseline logic into a shared
helper (e.g., ParityTestSupport) and update the test to use it: move CORPUS_ROOT
and BASELINE_ROOT constants plus the corpusFiles() Stream and a new
loadBaseline(Path) method into ParityTestSupport, then change the MethodSource
to refer to that shared corpusFiles and replace the baseline-reading/comparison
code inside phase2MarkResetChildrenCstHashMatchesBaseline to call
ParityTestSupport.loadBaseline(file) (and use
ParityTestSupport.CORPUS_ROOT/BASELINE_ROOT as needed) so all nine parity tests
share the same implementation.

In `@src/test/resources/perf-corpus/flow-format-examples/BlankLineRules.java`:
- Line 26: The record declaration for Inner lacks a space before the opening
brace; update the declaration of record Inner(String value) {} (symbol: Inner)
to include a space before the '{' so it follows standard Java formatting
conventions (i.e., change "record Inner(String value){}" to "record Inner(String
value) {}").
- Line 6: The private field `active` in class `BlankLineRules` is never used
outside the constructor; either remove the unused field or add a public accessor
if it should be exposed—locate the `private final boolean active;` declaration
and the constructor that initializes it, then either delete the field and
related constructor parameter or implement a getter method (e.g., `isActive()`
or `getActive()`) so callers can read the value.

In `@src/test/resources/perf-corpus/format-examples/ChainAlignment.java`:
- Around line 134-152: Replace the helper stubs that return null with fail-fast
exceptions so accidental execution doesn't produce NPEs: in methods
Result<String> validate(String), Result<Response> createResponse(Object),
Result<String> expensiveValidation(String), String combine(A,B,C), and any
logging helpers log(String) / logError(Object) where applicable, throw new
UnsupportedOperationException (with a brief message identifying the method)
instead of returning null; update those method bodies (e.g., validate,
createResponse, expensiveValidation, combine, log, logError) to raise the
exception so failures are immediate and descriptive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae75d478-a183-4511-ae3e-4ac0e9474af3

📥 Commits

Reviewing files that changed from the base of the PR and between 9ec25a5 and 2c33e61.

📒 Files selected for processing (100)
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • docs/PERF-REWORK-SPEC.md
  • docs/bench-results/java25-parse.json
  • docs/bench-results/packrat-stats.txt
  • pom.xml
  • src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java
  • src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java
  • src/main/java/org/pragmatica/peg/PegParser.java
  • src/main/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzer.java
  • src/main/java/org/pragmatica/peg/generator/ParserGenerator.java
  • src/main/java/org/pragmatica/peg/parser/ParserConfig.java
  • src/test/java/org/pragmatica/peg/examples/Java25GrammarExample.java
  • src/test/java/org/pragmatica/peg/generator/ChoiceDispatchAnalyzerTest.java
  • src/test/java/org/pragmatica/peg/generator/GeneratorFlagInertnessTest.java
  • src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java
  • src/test/java/org/pragmatica/peg/perf/BaselineGenerator.java
  • src/test/java/org/pragmatica/peg/perf/BaselineGeneratorRunner.java
  • src/test/java/org/pragmatica/peg/perf/CorpusParityTest.java
  • src/test/java/org/pragmatica/peg/perf/CstHash.java
  • src/test/java/org/pragmatica/peg/perf/CstReconstruct.java
  • src/test/java/org/pragmatica/peg/perf/GeneratedJava25Parser.java
  • src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2AllStructuralParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchAndMarkResetParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2InlineLocationsParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratEmptySetParityTest.java
  • src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratParityTest.java
  • src/test/java/org/pragmatica/peg/perf/RoundTripTest.java
  • src/test/resources/java25.peg
  • src/test/resources/perf-corpus-baseline/flow-format-examples/BlankLineRules.java.hash
  • src/test/resources/perf-corpus-baseline/flow-format-examples/BlankLineRules.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Annotations.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Annotations.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/BlankLines.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/BlankLines.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/ChainAlignment.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/ChainAlignment.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/ClassLiterals.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/ClassLiterals.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Comments.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Comments.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/CompoundAssignments.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/CompoundAssignments.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/DeepGenerics.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/DeepGenerics.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Enums.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Enums.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/ExhaustiveSwitchPatterns.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/ExhaustiveSwitchPatterns.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Imports.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Imports.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/KeywordPrefixedIdentifiers.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/KeywordPrefixedIdentifiers.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Lambdas.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Lambdas.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/LineWrapping.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/LineWrapping.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/ModuleInfo.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/ModuleInfo.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/MultilineArguments.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/MultilineArguments.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/MultilineParameters.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/MultilineParameters.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/Records.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/Records.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/SwitchExpressions.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/SwitchExpressions.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/TernaryOperators.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/TernaryOperators.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/format-examples/TextBlocks.java.hash
  • src/test/resources/perf-corpus-baseline/format-examples/TextBlocks.java.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/large/FactoryClassGenerator.java.txt.hash
  • src/test/resources/perf-corpus-baseline/large/FactoryClassGenerator.java.txt.ruleHits.txt
  • src/test/resources/perf-corpus-baseline/ruleCoverage.txt
  • src/test/resources/perf-corpus/flow-format-examples/BlankLineRules.java
  • src/test/resources/perf-corpus/format-examples/Annotations.java
  • src/test/resources/perf-corpus/format-examples/BlankLines.java
  • src/test/resources/perf-corpus/format-examples/ChainAlignment.java
  • src/test/resources/perf-corpus/format-examples/ClassLiterals.java
  • src/test/resources/perf-corpus/format-examples/Comments.java
  • src/test/resources/perf-corpus/format-examples/CompoundAssignments.java
  • src/test/resources/perf-corpus/format-examples/DeepGenerics.java
  • src/test/resources/perf-corpus/format-examples/Enums.java
  • src/test/resources/perf-corpus/format-examples/ExhaustiveSwitchPatterns.java
  • src/test/resources/perf-corpus/format-examples/Imports.java
  • src/test/resources/perf-corpus/format-examples/KeywordPrefixedIdentifiers.java
  • src/test/resources/perf-corpus/format-examples/Lambdas.java
  • src/test/resources/perf-corpus/format-examples/LineWrapping.java
  • src/test/resources/perf-corpus/format-examples/ModuleInfo.java
  • src/test/resources/perf-corpus/format-examples/MultilineArguments.java
  • src/test/resources/perf-corpus/format-examples/MultilineParameters.java
  • src/test/resources/perf-corpus/format-examples/Records.java
  • src/test/resources/perf-corpus/format-examples/SwitchExpressions.java
  • src/test/resources/perf-corpus/format-examples/TernaryOperators.java
  • src/test/resources/perf-corpus/format-examples/TextBlocks.java
  • src/test/resources/perf-corpus/large/FactoryClassGenerator.java.txt

Comment thread docs/PERF-REWORK-SPEC.md
Comment on lines +3 to +6
**Target repo:** `java-peglib` (self-contained; no dependency on any other repo).
**Status:** proposal / spec for an implementing agent. Not yet executed.
**Scope:** optimizations to the PEG code generator (`ParserGenerator`) only.
`PegEngine` (the interpreter) is **out of scope** for this pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the status banner to match the release state.

Line 4 still says this work is “Not yet executed”, but this PR already ships the rework and its verification artifacts. Leaving that in place will make the spec look stale immediately after release.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/PERF-REWORK-SPEC.md` around lines 3 - 6, Update the status banner in
PERF-REWORK-SPEC.md by replacing the phrase "Not yet executed" with a
release-accurate status (e.g., "Implemented / released" or "Shipped with
verification artifacts") so the spec reflects that the ParserGenerator rework
and its verification artifacts are included in this PR; edit the markdown line
that currently reads "**Status:** proposal / spec for an implementing agent. Not
yet executed." to state the new status and optionally add a brief parenthetical
like "(shipped with this PR)" for clarity.

Comment on lines +2305 to +2316
case Expression.Sequence seq -> {
for (var el : seq.elements()) {
// skip over leading predicates — they don't consume
if (el instanceof Expression.And || el instanceof Expression.Not) continue;
yield collectFirstChars(el, out, visiting);
}
yield false;
}
case Expression.Group grp -> collectFirstChars(grp.expression(), out, visiting);
case Expression.ZeroOrMore zom -> collectFirstChars(zom.expression(), out, visiting);
case Expression.OneOrMore oom -> collectFirstChars(oom.expression(), out, visiting);
case Expression.Optional opt -> collectFirstChars(opt.expression(), out, visiting);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't treat nullable prefixes as fixed starters.

This sequence walker stops at the first non-predicate element, but Optional/ZeroOrMore prefixes can yield ε. For whitespace shapes like spaces* comment, the derived set misses '/', so the fast path can incorrectly return early and skip valid trivia.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/pragmatica/peg/generator/ParserGenerator.java` around lines
2305 - 2316, The Sequence branch in collectFirstChars incorrectly stops at the
first non-predicate element; change it to treat nullable prefixes (e.g.,
Expression.Optional and Expression.ZeroOrMore) as potentially empty so the
walker continues to later elements when they can produce ε. Update
ParserGenerator.collectFirstChars: inside the case Expression.Sequence seq ->
loop, for each element skip predicates (Expression.And/Expression.Not) as
before, but if an element is nullable (create/use a helper like
isNullable(Expression) or reuse existing nullability logic for
Expression.Optional and Expression.ZeroOrMore) then still call collectFirstChars
on that element to accumulate starters and continue to the next element; only
return/stop early when you encounter a non-nullable element whose collected set
determines the result. Ensure Expression.OneOrMore is treated as non-nullable.

Comment on lines +4100 to +4112
}else {
sb.append(" if (isAtEnd()) {\n");
sb.append(" trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
sb.append(" return CstParseResult.failure(\"character class\");\n");
sb.append(" }\n");
sb.append(" var startLoc = location();\n");
sb.append(" char c = peek();\n");
sb.append(" boolean matches = matchesPattern(c, pattern, caseInsensitive);\n");
sb.append(" if (negated) matches = !matches;\n");
sb.append(" if (!matches) {\n");
sb.append(" trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
sb.append(" return CstParseResult.failure(\"character class\");\n");
sb.append(" }\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep char-class failure text independent of the cache flag.

The cached branch returns the bracketed label, but this path still returns "character class". That makes diagnostics depend on charClassFailureCache, which should be a perf-only toggle.

Suggested fix
         } else {
             sb.append("        if (isAtEnd()) {\n");
-            sb.append("            trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
-            sb.append("            return CstParseResult.failure(\"character class\");\n");
+            sb.append("            var expected = \"[\" + (negated ? \"^\" : \"\") + pattern + \"]\";\n");
+            sb.append("            trackFailure(expected);\n");
+            sb.append("            return CstParseResult.failure(expected);\n");
             sb.append("        }\n");
             sb.append("        var startLoc = location();\n");
             sb.append("        char c = peek();\n");
             sb.append("        boolean matches = matchesPattern(c, pattern, caseInsensitive);\n");
             sb.append("        if (negated) matches = !matches;\n");
             sb.append("        if (!matches) {\n");
-            sb.append("            trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
-            sb.append("            return CstParseResult.failure(\"character class\");\n");
+            sb.append("            var expected = \"[\" + (negated ? \"^\" : \"\") + pattern + \"]\";\n");
+            sb.append("            trackFailure(expected);\n");
+            sb.append("            return CstParseResult.failure(expected);\n");
             sb.append("        }\n");
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}else {
sb.append(" if (isAtEnd()) {\n");
sb.append(" trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
sb.append(" return CstParseResult.failure(\"character class\");\n");
sb.append(" }\n");
sb.append(" var startLoc = location();\n");
sb.append(" char c = peek();\n");
sb.append(" boolean matches = matchesPattern(c, pattern, caseInsensitive);\n");
sb.append(" if (negated) matches = !matches;\n");
sb.append(" if (!matches) {\n");
sb.append(" trackFailure(\"[\" + (negated ? \"^\" : \"\") + pattern + \"]\");\n");
sb.append(" return CstParseResult.failure(\"character class\");\n");
sb.append(" }\n");
}else {
sb.append(" if (isAtEnd()) {\n");
sb.append(" var expected = \"[\" + (negated ? \"^\" : \"\") + pattern + \"]\";\n");
sb.append(" trackFailure(expected);\n");
sb.append(" return CstParseResult.failure(expected);\n");
sb.append(" }\n");
sb.append(" var startLoc = location();\n");
sb.append(" char c = peek();\n");
sb.append(" boolean matches = matchesPattern(c, pattern, caseInsensitive);\n");
sb.append(" if (negated) matches = !matches;\n");
sb.append(" if (!matches) {\n");
sb.append(" var expected = \"[\" + (negated ? \"^\" : \"\") + pattern + \"]\";\n");
sb.append(" trackFailure(expected);\n");
sb.append(" return CstParseResult.failure(expected);\n");
sb.append(" }\n");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/pragmatica/peg/generator/ParserGenerator.java` around lines
4100 - 4112, The non-cached char-class branch currently returns
CstParseResult.failure("character class") making diagnostics differ from the
cached path; update the failure to use the same bracketed label used in
trackFailure so diagnostics are independent of charClassFailureCache — replace
the CstParseResult.failure("character class") calls in the else branch (the
block that uses isAtEnd(), startLoc, peek(), matchesPattern, negated,
trackFailure) so they return CstParseResult.failure("[" + (negated ? "^" : "") +
pattern + "]") while leaving trackFailure and matchesPattern unchanged.

Comment on lines 52 to +66
public record ParserConfig(
boolean packratEnabled,
RecoveryStrategy recoveryStrategy,
boolean captureTrivia) {
boolean captureTrivia,
boolean fastTrackFailure,
boolean literalFailureCache,
boolean charClassFailureCache,
boolean bulkAdvanceLiteral,
boolean skipWhitespaceFastPath,
boolean reuseEndLocation,
boolean choiceDispatch,
boolean markResetChildren,
boolean inlineLocations,
boolean selectivePackrat,
Set<String> packratSkipRules) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and read the ParserConfig.java file
find . -name "ParserConfig.java" -type f

Repository: siy/java-peglib

Length of output: 118


🏁 Script executed:

# Read the ParserConfig.java file
cat -n ./src/main/java/org/pragmatica/peg/parser/ParserConfig.java

Repository: siy/java-peglib

Length of output: 4901


🏁 Script executed:

# Search for usages of ParserConfig to understand how it's being used
rg "new ParserConfig" -A 5 -B 2

Repository: siy/java-peglib

Length of output: 12779


🏁 Script executed:

# Search for cases where packratSkipRules is being passed - check if any mutable sets are used
rg "packratSkipRules|ParserConfig\(" -B 5 -A 10 | grep -A 20 "packratSkipRules"

Repository: siy/java-peglib

Length of output: 38873


Add a compact record constructor to defensively copy packratSkipRules.

The record currently documents that packratSkipRules must be treated as immutable by callers and places the burden on them to wrap mutable sources with Set.copyOf(...). However, the constructor does not enforce this. If a caller passes a mutable set and later mutates it externally, any code generation or caching logic that depends on the config's equality or contents becomes unstable. A compact constructor using Set.copyOf(...) shifts the defensive copy to the record itself, eliminating caller error surface.

Suggested fix
 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<String> packratSkipRules) {
+    public ParserConfig {
+        packratSkipRules = Set.copyOf(packratSkipRules);
+    }
+
     public static final ParserConfig DEFAULT = new ParserConfig(
     true, RecoveryStrategy.BASIC, true, true, true, true, true, true, true, true, false, false, false, Set.of());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public record ParserConfig(
boolean packratEnabled,
RecoveryStrategy recoveryStrategy,
boolean captureTrivia) {
boolean captureTrivia,
boolean fastTrackFailure,
boolean literalFailureCache,
boolean charClassFailureCache,
boolean bulkAdvanceLiteral,
boolean skipWhitespaceFastPath,
boolean reuseEndLocation,
boolean choiceDispatch,
boolean markResetChildren,
boolean inlineLocations,
boolean selectivePackrat,
Set<String> packratSkipRules) {
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<String> packratSkipRules) {
public ParserConfig {
packratSkipRules = Set.copyOf(packratSkipRules);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/pragmatica/peg/parser/ParserConfig.java` around lines 52 -
66, Add a compact record constructor to ParserConfig that defensively copies the
packratSkipRules parameter using Set.copyOf(packratSkipRules) so the record
always holds an immutable snapshot; locate the record declaration ParserConfig
and implement a compact constructor that assigns all components by delegating to
the canonical constructor while replacing the incoming packratSkipRules with
Set.copyOf(packratSkipRules) (and handle null if needed) to prevent external
mutations from affecting the record's equality/cache-dependent behavior.

Comment on lines +24 to +61
@Test
void generateCst_withDefaultConfig_isByteIdenticalToLegacyFactory() {
var grammar = loadJava25Grammar();

var legacySource = ParserGenerator.create(grammar, "gen", "Java25Parser")
.generateCst();
var configuredSource = ParserGenerator.create(grammar, "gen", "Java25Parser", ParserConfig.DEFAULT)
.generateCst();

assertEquals(legacySource, configuredSource,
"Generator output must be byte-identical when all perf flags are off");
}

@Test
void generateCst_withDefaultConfigAndAdvancedReporting_isByteIdenticalToLegacyFactory() {
var grammar = loadJava25Grammar();

var legacySource = ParserGenerator.create(grammar, "gen", "Java25Parser", ErrorReporting.ADVANCED)
.generateCst();
var configuredSource = ParserGenerator.create(grammar, "gen", "Java25Parser",
ErrorReporting.ADVANCED, ParserConfig.DEFAULT)
.generateCst();

assertEquals(legacySource, configuredSource,
"Generator output must be byte-identical for ADVANCED reporting when all perf flags are off");
}

@Test
void generate_withDefaultConfig_isByteIdenticalToLegacyFactory() {
var grammar = loadJava25Grammar();

var legacySource = ParserGenerator.create(grammar, "gen", "Java25Parser")
.generate();
var configuredSource = ParserGenerator.create(grammar, "gen", "Java25Parser", ParserConfig.DEFAULT)
.generate();

assertEquals(legacySource, configuredSource,
"Action-bearing generator output must be byte-identical when all perf flags are off");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This test never exercises the “flags off” invariant.

Per src/main/java/org/pragmatica/peg/generator/ParserGenerator.java:114-138, the no-config create(...) overload already delegates to ParserConfig.DEFAULT, and the configured side passes that same config explicitly. So these assertions only prove the overloads are wired identically; they cannot catch regressions when generator-time flags are actually off, which is what the class name/Javadoc claim. Use an explicit all-off config plus a golden/pre-change output, or rename this to overload-parity if that is the real contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/pragmatica/peg/generator/GeneratorFlagInertnessTest.java`
around lines 24 - 61, The tests in GeneratorFlagInertnessTest (methods
generateCst_withDefaultConfig_isByteIdenticalToLegacyFactory,
generateCst_withDefaultConfigAndAdvancedReporting_isByteIdenticalToLegacyFactory,
generate_withDefaultConfig_isByteIdenticalToLegacyFactory) are only comparing
the no-arg ParserGenerator.create(...) overload to the explicit
ParserConfig.DEFAULT and so never exercise an actual "all flags off" config;
update each test to construct and pass an explicit all-off ParserConfig (e.g.,
build a ParserConfig with every perf flag set to false or use a dedicated
ParserConfig.ALL_OFF if available) when creating configuredSource so the
assertions compare legacySource to a true all-off configuration, or
alternatively rename the tests to state they verify overload parity rather than
flag inertness if you intend to keep the current behavior.

Comment on lines +49 to +52
static Stream<Path> corpusFiles() throws IOException {
if (!Files.isDirectory(CORPUS_ROOT)) {
return Stream.empty();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't silently skip the corpus.

If CORPUS_ROOT is missing, this returns zero arguments and the entire parity class passes without exercising anything. For a regression suite, that is a false green; fail fast on a missing corpus root instead, and ideally share that guard with the other phase parity tests too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java` around lines 49
- 52, The corpusFiles() helper silently returns an empty Stream when CORPUS_ROOT
is missing which yields false-green tests; change corpusFiles() to fail fast by
checking Files.isDirectory(CORPUS_ROOT) and throwing an exception (e.g., throw
new IOException("Missing corpus root: " + CORPUS_ROOT) or an AssertionError)
instead of returning Stream.empty(), and factor that check into a shared helper
(e.g., ensureCorpusRootExists or validateCorpusRoot) so other phase parity tests
can call it as well.

Promise<String> promise = Promise.success("test");
Result<String> result = success("test");
Unit u = unit();
List<String> list = new ArrayList();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Raw type usage: consider using diamond operator.

new ArrayList() is a raw type. For Java 7+ code, use new ArrayList<>() to avoid unchecked assignment warnings. If this is intentional to test raw type parsing, consider adding a comment to clarify.

💡 Suggested fix
-        List<String> list = new ArrayList();
+        List<String> list = new ArrayList<>();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<String> list = new ArrayList();
List<String> list = new ArrayList<>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/format-examples/Imports.java` at line 39, The
declaration List<String> list = new ArrayList(); uses a raw ArrayList; change
the instantiation to use the diamond operator (List<String> list = new
ArrayList<>()) to remove unchecked-assignment warnings, or if the raw type is
intentional for testing, add an explicit comment next to the instantiation
(e.g., "// raw type intentional for parsing test") to document the intent;
update the occurrence of ArrayList() in Imports.java accordingly.

Comment on lines +105 to +107
interface ValidRequest {
ValidRequest(Object... args) {}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Invalid Java syntax: interfaces cannot have constructors.

The ValidRequest interface declares what looks like a constructor with a body. Interfaces cannot have constructors in Java. This should be a record or class instead.

💡 Suggested fix - use a record
-    interface ValidRequest {
-        ValidRequest(Object... args) {}
-    }
+    record ValidRequest(Object... args) {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface ValidRequest {
ValidRequest(Object... args) {}
}
record ValidRequest(Object... args) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/format-examples/MultilineArguments.java`
around lines 105 - 107, The declaration ValidRequest currently defined as an
interface contains a constructor-like member which is invalid; change the
declaration kind from interface to a concrete type (e.g., convert the interface
ValidRequest to a record or class) and keep the parameter list (Object... args)
as the record/class constructor signature so the constructor body is valid;
update references to ValidRequest accordingly and remove the interface keyword
so the compiler accepts the constructor-like declaration.

Comment on lines +58 to +64
interface ValidRequest {
ValidRequest(Object... args) {}
}

interface Response {
Response(Object... args) {}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Are constructors allowed in Java interfaces, and can an interface be instantiated directly with new InterfaceName(...) in Java 25?

💡 Result:

No, constructors are not allowed in Java interfaces, and interfaces cannot be instantiated directly with new InterfaceName(...) in Java 25. Interfaces in Java are abstract reference types that define contracts via abstract methods, default methods, static methods, and constants, but they explicitly do not support constructors. The Java Language Specification (JLS) Chapter 9 on Interfaces describes interface declarations and members (fields, methods, nested types), with no provision for constructors in the grammar or semantics. Interface members are limited to constant declarations, abstract methods, default methods, static methods, and nested types; constructors are absent. Attempting to declare a constructor in an interface results in a compile-time error. Interfaces cannot be instantiated directly because they are implicitly abstract. Direct instantiation like new InterfaceName(...) is invalid and produces a compile-time error: "InterfaceName is abstract; cannot be instantiated." Objects compatible with an interface type must be instances of concrete classes that implement the interface, anonymous classes, or lambda expressions (for functional interfaces). No changes in Java 25 alter this fundamental behavior. JDK 25 release notes and JEPs (e.g., JEP 513 on flexible constructor bodies) address class constructors only, with no mention of interfaces gaining constructors or direct instantiation capability. This holds across JLS versions from SE 6 to SE 24, confirming consistency up to Java 25.

Citations:


Replace interface declarations with records to ensure corpus contains valid Java.

Interfaces cannot declare constructors, and the new ValidRequest(...) and new Response(...) instantiations on earlier lines are invalid. As a perf-corpus fixture, this code should represent compilable Java; replace with record types instead.

Suggested shape
-    interface ValidRequest {
-        ValidRequest(Object... args) {}
-    }
-
-    interface Response {
-        Response(Object... args) {}
-    }
+    record ValidRequest(Email email, Password password, Option<ReferralCode> referral) {}
+
+    record Response(String userId,
+                    String token,
+                    long expiresAt,
+                    boolean active,
+                    Option<String> refreshToken) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/format-examples/MultilineParameters.java`
around lines 58 - 64, Replace the invalid interface declarations for
ValidRequest and Response with record declarations so the file compiles: change
the interface ValidRequest and interface Response blocks into records named
ValidRequest and Response that take an Object[] args component (e.g., record
ValidRequest(Object[] args) {} and record Response(Object[] args) {}), keeping
the empty bodies so the earlier new ValidRequest(...) and new Response(...)
instantiations are valid.

Comment on lines +53 to +60
String guardedSwitch(Object obj) {
return switch (obj){
case String s -> "empty string";
case String s -> "short string: " + s;
case String s -> "long string: " + s;
case Integer i -> "negative: " + i;
case Integer i -> "non-negative: " + i;
default -> "other";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Java 25 switch pattern matching, are later case String s ->orcase Integer i ->labels allowed after an earlier unguarded case of the same type, or are they dominated unless guarded withwhen?

💡 Result:

No, later case String s -> or case Integer i -> labels (unguarded) are not allowed after an earlier unguarded case of the same type; they are dominated and cause a compile-time error. They are only allowed if guarded with when, as guarded patterns are not checked for dominance against other patterns (though ordering is still recommended for runtime behavior).

Citations:


Add when guards or refactor to avoid duplicate pattern types.

The code contains unreachable dominated patterns. In Java 25, later case String s -> labels cannot follow an earlier unguarded case String s -> (same applies to Integer i). These cases cause a compile-time error unless guarded with when.

Suggested fix
-            case String s -> "empty string";
-            case String s -> "short string: " + s;
-            case String s -> "long string: " + s;
-            case Integer i -> "negative: " + i;
-            case Integer i -> "non-negative: " + i;
+            case String s when s.isEmpty() -> "empty string";
+            case String s when s.length() < 5 -> "short string: " + s;
+            case String s -> "long string: " + s;
+            case Integer i when i < 0 -> "negative: " + i;
+            case Integer i -> "non-negative: " + i;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/resources/perf-corpus/format-examples/SwitchExpressions.java` around
lines 53 - 60, The switch expression in guardedSwitch contains duplicate
unguarded patterns (multiple "case String s" and "case Integer i") which are
illegal; update those cases to either add appropriate when-guards (e.g., case
String s when s.isEmpty(), case String s when s.length() < N, case Integer i
when i < 0, etc.) or merge/refactor them into a single case per type that
dispatches internally. Locate the switch inside guardedSwitch and apply guards
to each duplicate pattern or consolidate logic so each pattern type appears only
once.

@siy siy merged commit b01644d into main Apr 21, 2026
2 checks passed
@siy siy deleted the release-0.2.2 branch April 21, 2026 07:18
This was referenced May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant