mruby-regexp: rewind a lookbehind by characters - #7087
Merged
Conversation
mruby#7069 stopped `compute_fixed_len()` from measuring a character class that can match a non-ASCII codepoint, so a lookbehind over one raises `RegexpError` instead of rewinding into the middle of a character and answering wrongly. That traded a wrong answer for an exception; it did not make the pattern work. These all raised, and all answer in CRuby: ```ruby "Āx" =~ /(?<=[Ā])x/ # CRuby: 1, mruby: RegexpError "Āb" =~ /(?<![Ā])b/ # CRuby: nil, mruby: RegexpError "あx" =~ /(?<=[^あ])x/ # CRuby: nil, mruby: RegexpError "aあx" =~ /(?<=a\W)x/ # CRuby: 2, mruby: RegexpError "ax" =~ /(?<=.)x/ # CRuby: 1, mruby: RegexpError ``` The count in `re_inst.a` is a byte count, and both lookbehind opcodes subtract it from `sp` directly. A class has no fixed byte width, but it consumes exactly one character whatever its members are, so a character count makes every class measurable, and `RE_ANY` with it. One count cannot serve both subjects, though. `bt_match()` advances a binary subject one byte at a time and hands `class_match()` the raw byte as its codepoint, so against `"Āx".b` the stored byte count is exactly right, and a character count would rewind `(?<=Ā)` one byte instead of two. The compiler does not know the subject, so the opcode carries both: `a` keeps the byte count, and a carrier instruction, `RE_LB_WIDTH`, emitted right after it holds the character count, with the sub-pattern body starting at `pc + 2`. The instruction stream already holds logical units spanning several 4-byte words, since a multibyte literal is a run of one-byte `RE_CHAR` instructions forming one atom. `compute_fixed_len()` returns both counts from its one walk. The byte count stays one per consuming instruction, because a binary subject advances one byte whatever the instruction is. The character count adds one per class or `RE_ANY` and counts only lead bytes across an `RE_CHAR` run. The `class_is_ascii_only()` test and the `RE_NCLASS` and `RE_ANY` rejections all disappear; `RE_SPLIT` stays rejected, so `(?<=ab|c)` keeps raising as before. The executor keeps `sp - a` for a binary subject and otherwise steps back `code[pc + 1].a` characters. The backward step is built on `mrb_re_utf8_interior_p()`, whose definition (a continuation byte no lead reaches is a character of its own) keeps the walk on the same boundaries the forward decode uses, broken input included. Running out of text keeps its current meaning: the positive form fails, the negative form succeeds. The 255 limit stays a byte limit, and the character count never exceeds the byte count, so it fits the carrier's `uint8_t`.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughLookbehind bytecode now stores byte and character widths. Compilation accepts multibyte character classes and wildcards. Execution rewinds by characters for UTF-8 subjects and by bytes for binary subjects. Tests cover both modes. ChangesLookbehind width handling
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant RegexpCompiler
participant RegexpBytecode
participant LookbehindExecutor
participant Subject
RegexpCompiler->>RegexpCompiler: compute byte and character widths
RegexpCompiler->>RegexpBytecode: emit RE_LB_WIDTH and lookbehind body
LookbehindExecutor->>Subject: inspect binary or UTF-8 subject
LookbehindExecutor->>LookbehindExecutor: rewind by bytes or characters
LookbehindExecutor->>RegexpBytecode: execute lookbehind body from pc + 2
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This was referenced Aug 11, 2026
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.
Follow-up to #7069, which stopped
compute_fixed_len()from measuring a characterclass that can match a non-ASCII codepoint. A lookbehind over one raises
RegexpErrorinstead of rewinding into the middle of a character and answering wrongly. That traded
a wrong answer for an exception; it did not make the pattern work. These all raise
today and answer in CRuby:
The last row is not a class at all:
RE_ANYwas rejected bycompute_fixed_len()forthe same reason, and the same change lets it through. With this patch every row
answers; the indices differ from CRuby only where this build counts bytes and CRuby
counts characters, so the first row answers 2 here and 1 there for the same position.
Why one count cannot do it
The count in
re_inst.ais a byte count, and both lookbehind opcodes subtract it fromspdirectly. A class has no fixed byte width, but it consumes exactly one characterwhatever its members are, so a character count makes every class measurable. The
subject decides the unit, though, and the compiler does not know the subject:
bt_match()advances a binary subject one byte at a time and handsclass_match()the raw byte as its codepoint, so a byte count is right there, for a literal and for a
class alike. A character count is right for a class against a UTF-8 subject and wrong
for a multibyte literal, which is 2 bytes and 1 character. Storing only one of them
regresses the other:
(?<=Ā)xmeasures correctly today because 2 is a byte count. Make it a charactercount and the same pattern rewinds 1 byte against that subject, landing on
\x80,and stops matching.
Fix
The lookbehind carries both numbers.
akeeps the byte count, and a carrierinstruction,
RE_LB_WIDTH, emitted right after the lookbehind holds the charactercount; the sub-pattern body starts at
pc + 2. The stream already holds logical unitsspanning several 4-byte words, since a multibyte literal is a run of one-byte
RE_CHARinstructions forming one atom, so the carrier adds no new kind of shape.compute_fixed_len()returns both counts from its one walk. The byte count staysone per consuming instruction, because a binary subject advances one byte whatever
the instruction is. The character count adds one per class or
RE_ANYand countsonly lead bytes across an
RE_CHARrun. Theclass_is_ascii_only()test and theRE_NCLASSandRE_ANYrejections all disappear;RE_SPLITstays rejected, so(?<=ab|c)keeps raising as before.sp - afor a binary subject and otherwise steps backcode[pc + 1].acharacters. The backward step is built onmrb_re_utf8_interior_p(), whose definition (a continuation byte no lead reachesis a character of its own) keeps the walk on the same boundaries the forward decode
uses, broken input included. Running out of text keeps its current meaning: the
positive form fails, the negative form succeeds.
boundaries, and each atom of a fixed-length sub-pattern consumes exactly one whole
character going forward, so
RE_MATCHarrives back atspby the same argument asthe byte version.
count, so it fits the carrier's
uint8_t.Tests
The case from #7069 that pinned the raising patterns flips to real match assertions,
covering the class forms, the negated classes, the shorthands and
(?<=.); the casethat pinned what must keep working is untouched. A new case pins binary subjects,
where the byte count is the one a character count would silently regress.
rake testis clean.
Summary by CodeRabbit
New Features
Bug Fixes