Skip to content

Cache the parsed dictionary, hoist repeated elem.toString(), and correct a wrong CLAUDE.md claim - #76

Merged
ghedwards merged 3 commits into
masterfrom
claude/caching-pr-review-80vzln
Sep 23, 2026
Merged

ghedwards merged 3 commits into
masterfrom
claude/caching-pr-review-80vzln

Conversation

@ghedwards

Copy link
Copy Markdown

Two small parser optimisations, and a correction to a CLAUDE.md claim that turned out to be wrong — the third commit is the one that matters most, because it invalidates the reasoning that motivated the first.

What is here

Commit Effect
Cache the parsed syntax dictionary per version new CFMLParser() 13.3 ms → ~0
Hoist the repeated elem.toString() in visit() ~16% on a tag-heavy file
Correct the CFLint parser-construction claim documentation

The dictionary was re-parsed on every parser construction

getDictionaryByVersion() had no cache lookup — it built a fresh SQLSyntaxDictionary and SAX-parsed every grammar XML file on each call. A dictionaryVersionCache field already existed, but was only read by getDictionaryByVersionAlt, an unused JDOM variant.

CFMLParser's constructor calls it, so each new CFMLParser() cost 13.3 ms of pure XML re-parsing (measured over 200 constructions). The cache key includes prefsSignature(fPrefs), so a later initDictionaries(prefs) with different preferences still gets a freshly loaded dictionary. Sharing the instance is consistent with how the class already behaves — dictionariesCache hands the same object out today, and the parser only reads it.

Segment.toString() allocates the element's whole extent

Jericho's implementation is source.subSequence(begin, end).toString() with no caching, so every call allocates a fresh String. For a <cfif> that extent spans the entire body including nested content — and visit() called it four times in the cfif branch, twice in the cfset branch.

Reading it once per branch: 33.2 ms → 27.8 ms over a 154 KB source of 60 <cfif> blocks.

The correction, and why it matters

CLAUDE.md said CFLint "constructs a new CFMLParser per file, so the expression cache is per-file and never accumulates across a scan." Both halves are wrong. CFLint.java has

private CFMLParser cfmlParser = new CFMLParser();

as a field initialiser, so one parser serves the whole run. I instrumented the constructor and ran a real scan: exactly one construction for 158 files.

I built CFLint 1.5.17 against this branch and scanned 158 files. Output is byte-identical to baseline except the timestamp field — but the scan took 5125 ms against a 5133 ms baseline. No gain, because I had optimised a pattern CFLint does not use. The dictionary fix is still correct and still worth having for a consumer that does construct per file; it simply is not a CFLint improvement, and the docs said otherwise.

The toString hoist did not fire either: parseCFExpression was called zero times over that corpus, which is script-based .cfcs.

What a scan actually costs

Instrumented over the 5073 ms scan:

parseCFExpression     n=0    ms=0
parseCFMLExpression   n=19   ms=0      cacheHits=0
parseScriptBlock      n=64   ms=3756   LLfallback=0

74% is parseScriptBlock, the SLL→LL fallback never fires, and the 2000-entry expression LRU takes zero hits. Almost all of it is ANTLR building its DFA the first time it meets each decision — same 158 files in one JVM:

pass
first (cold DFA) 4676 ms
second / third (warm) 86 ms

54×. It is fixed per-process cost, so scan time barely tracks codebase size: 158 files → 5037 ms, 632 files → 5945 ms, a marginal ~2 ms per file.

CLAUDE.md now records this, plus the fact that clearDFA() is process-global (_decisionToDFA is static final on the generated parser — I confirmed the array is the same object across instances and that clearing via one parser drops another's DFA from 38 states to 0). Calling it to release memory discards exactly this warm-up for every parser in the JVM.

Verification

  • 326 tests, 0 failures — including TestDictionaryManager, which exercises the preference-switching path the cache key has to invalidate on
  • CFLint 1.5.17 built against this branch, 158-file scan, JSON output byte-identical apart from timestamp
  • Benchmarks above were run against this exact diff, baseline measured on master immediately before

Not verified here

./gradlew build could not be run — Maven Central returned HTTP 429 on plugin-classpath resolution across two attempts. It fails during project configuration, before compiling anything, so it is independent of this diff, but CI is the real check.

Deliberately not included

The SLL stage in parseCFExpression/parseCFMLExpression runs under DefaultErrorStrategy rather than BailErrorStrategy, so the catch → LL stage 2 is dead code. I verified it: 13 malformed inputs, 12 produced syntax errors, stage 2 ran zero times. parseScriptBlockContext gets this right. That is a correctness bug rather than a tuning item and can change parse results, so it wants its own PR and fixture coverage.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35


Generated by Claude Code

getDictionaryByVersion() had no cache lookup: it built a fresh
SQLSyntaxDictionary and SAX-parsed every grammar XML file on each call.
A dictionaryVersionCache field already existed but was only ever read by
getDictionaryByVersionAlt, an unused JDOM variant.

Since CFMLParser's constructor calls it, every `new CFMLParser()` cost
13.3 ms of pure XML re-parsing. Measured over 200 constructions that drops
to effectively zero.

The cache key includes prefsSignature(fPrefs) so a later
initDictionaries(prefs) with different preferences still gets a freshly
loaded dictionary rather than a stale one. The loaded dictionary is
immutable in practice -- the parser only reads it, and DictionaryManager
already hands the same instance out through dictionariesCache -- so
sharing it is consistent with how the rest of the class behaves.

Scope note: this does NOT speed up a CFLint scan. CFLint builds one
CFMLParser for an entire run, so it pays this once. It matters to a
consumer that constructs a parser per file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35
Jericho's Segment.toString() is source.subSequence(begin, end).toString()
with no caching, so each call allocates a fresh String spanning the
element's whole extent. For a <cfif> that extent covers the entire body
including nested content, and visit() called it four times in the cfif
branch and twice in the cfset branch.

Reading it once per branch cuts ~16% off a tag-heavy file: 33.2 ms to
27.8 ms over a 154 KB source of 60 <cfif> blocks.

Scope note: this only helps tag-based CFML. A scan of script-based
components never reaches these branches -- parseCFExpression was called
zero times over a 158-file .cfc corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35
CLAUDE.md stated that CFLint "constructs a new CFMLParser per file, so
the expression cache is per-file and never accumulates across a scan."
Both halves are wrong. CFLint.java has

    private CFMLParser cfmlParser = new CFMLParser();

as a field initialiser, so one parser serves the whole run -- an
instrumented 158-file scan recorded exactly one construction. The
expression cache therefore spans the entire scan.

The claim is load-bearing: it invites optimising per-file construction
cost, which is a pattern CFLint does not use. It is why the dictionary
cache in this branch was expected to speed up a scan and does not.

Replaces it with what a scan actually costs, measured rather than
assumed: cold ANTLR DFA construction, 4676 ms on the first pass over 158
files against 86 ms on the second in the same JVM. That is fixed
per-process cost, so 4x the files bought only 18% more wall clock.

Also records that clearDFA() is process-global, since _decisionToDFA is
static final on the generated parser -- calling it discards the warm-up
for every parser in the JVM, not just the receiver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35
@ghedwards
ghedwards merged commit ea7af4d into master Sep 23, 2026
6 checks passed
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.

2 participants