Cache the parsed dictionary, hoist repeated elem.toString(), and correct a wrong CLAUDE.md claim - #76
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
new CFMLParser()13.3 ms → ~0elem.toString()invisit()The dictionary was re-parsed on every parser construction
getDictionaryByVersion()had no cache lookup — it built a freshSQLSyntaxDictionaryand SAX-parsed every grammar XML file on each call. AdictionaryVersionCachefield already existed, but was only read bygetDictionaryByVersionAlt, an unused JDOM variant.CFMLParser's constructor calls it, so eachnew CFMLParser()cost 13.3 ms of pure XML re-parsing (measured over 200 constructions). The cache key includesprefsSignature(fPrefs), so a laterinitDictionaries(prefs)with different preferences still gets a freshly loaded dictionary. Sharing the instance is consistent with how the class already behaves —dictionariesCachehands the same object out today, and the parser only reads it.Segment.toString()allocates the element's whole extentJericho'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 — andvisit()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
CFMLParserper file, so the expression cache is per-file and never accumulates across a scan." Both halves are wrong.CFLint.javahasas 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
timestampfield — 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
toStringhoist did not fire either:parseCFExpressionwas called zero times over that corpus, which is script-based.cfcs.What a scan actually costs
Instrumented over the 5073 ms scan:
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: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 (_decisionToDFAisstatic finalon 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
TestDictionaryManager, which exercises the preference-switching path the cache key has to invalidate ontimestampmasterimmediately beforeNot verified here
./gradlew buildcould 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/parseCFMLExpressionruns underDefaultErrorStrategyrather thanBailErrorStrategy, so thecatch→ LL stage 2 is dead code. I verified it: 13 malformed inputs, 12 produced syntax errors, stage 2 ran zero times.parseScriptBlockContextgets 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