fix(codegen): consume original code units for length-changing case-insensitive folds#662
Conversation
…sensitive folds A case-insensitive string literal whose `.toLowerCase()` changes the number of UTF-16 code units (for example `"İ"i`, where U+0130 is one code unit but lower-cases to `"i" + U+0307`, two units) was mis-compiled. For `MATCH_STRING_IC`, generate-js used the stored, already-lower-cased literal's length as the input chunk length, while the paired `ACCEPT_N` advanced by the original literal's length. When the two lengths disagreed, the fast-path fusion guard failed and the generated test read more code units than it consumed, so `start = "İ"i .` failed to parse `"İx"` even though the case-sensitive `start = "İ" .` parses it to `["İ", "x"]`. Use the paired `ACCEPT_N` count (the original code-unit length) as the input chunk length so the read, compare, and accept spans agree. Same-length folds keep the existing fused fast path and unchanged output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8e131ac to
d2745b9
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #662 +/- ##
=======================================
Coverage 99.48% 99.49%
=======================================
Files 34 34
Lines 4313 4314 +1
=======================================
+ Hits 4291 4292 +1
Misses 22 22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Great catch. If you're into Turkish, how do you handle case-insensitive matching of "I" and "ı" (U+0131: LATIN SMALL LETTER DOTLESS I)? Full Unicode case folding is more complex and locale-specific than |
|
peggy folds with plain
So One nuance: even full Unicode case folding (CaseFolding.txt) keeps This PR does not touch any of that. It keeps the existing |
Repro
"İ"is U+0130 (LATIN CAPITAL LETTER I WITH DOT ABOVE): one UTF-16 code unit, but"İ".toLowerCase()is"i" + U+0307(two code units).mainstart = "İ" .(case-sensitive control)"İx"["İ", "x"]["İ", "x"]✅start = "İ"i .(case-insensitive)"İx"["İ", "x"]Expected "İ" but "İ" found.❌The control parses fine; only the
i(case-insensitive) flag breaks. Theexpected == founderror text is the tell of a broken match length. Same-length folds ("ß"i,"K"i,"Σ"i) are unaffected, which isolates the length change as the cause.Root cause
A case-insensitive string literal is stored already lower-cased, so for a
SOMETIMES_MATCHliteral the bytecode is:In
generate-js.js,MATCH_STRING_ICusedliterals[litNum].length(the lower-cased length, 2) as the input chunk length, while the pairedACCEPT_Nadvances by the original length (1). When the two disagree:bc[...] === inputChunkLength, i.e.1 === 2) fails, so the match falls back to the unfused form;input.substr(peg$currPos, 2).toLowerCase()and compares it to the 2-unit literal, but only 1 code unit is ever consumed.For
"İx",input.substr(0, 2).toLowerCase()is"i̇x"(3 units), which never equals"i̇", so the match fails. The read span, compare span, and accept span must all be equal.Fix
In the
MATCH_STRING_ICcase, derive the input chunk length from the pairedACCEPT_Ncount (the original code-unit length) instead of the lower-cased literal length:The generated test now reads and consumes
node.value.lengthcode units and lower-cases that span before comparing, so the read, compare, and accept spans agree.MATCH_STRING_ICis only emitted byliteral()paired withACCEPT_N node.value.length, so this is always available; theliterals[litNum].lengthfallback is defensive.For same-length folds the chunk length is unchanged, the fusion fast path still engages, and the generated output is byte-for-byte identical (verified: the bootstrap
lib/parser.jsregenerates with no diff).Tests
Added a behavior test under
literal > matching:main(fails across all option variants), green with the fix.29test suites,1375tests passing.npm run parserregenerateslib/parser.jswith no diff (bootstrap/self-compile stable), lint clean.