mruby-regexp: make an escaped multibyte literal one atom - #7066
Merged
Conversation
`bcacba1e1` made a multibyte literal a single atom, so `/Ā+/` repeats the whole
character. A backslash in front of the same character takes a different branch
of `compile_atom()`, which never got that treatment: the escape path calls
`parse_escape()`, whose default arm returns the single byte it read, and emits
that one byte. The parse loop picks up the continuation bytes as atoms of their
own and `compile_quantified()` binds the quantifier to the last of them, so
`\Ā+` compiled as `\xC4(\x80)+`. The same backslash inside `[...]` goes through
`read_class_atom()`, which splits the character the same way, so the class ends
up holding two wrong codepoints.
```ruby
Regexp.new("\\Ā+").match("ĀĀ")[0].bytesize # CRuby: 4, mruby: 2
Regexp.new("\\Ā{2}").match?("ĀĀ") # CRuby: true, mruby: false
Regexp.new("[\\Ā]").match?("Ā") # CRuby: true, mruby: false
Regexp.new("[\\Ā]").match?("Ä") # CRuby: false, mruby: true
```
A backslash before a character with no special meaning is just that character,
so `\Ā` and `Ā` name the same pattern. The `/.../` spelling hides the
difference, because the lexer strips the backslash before the gem sees the
pattern: `/\Ā/.source` is the two bytes of `Ā` alone. A pattern built at runtime
arrives through `Regexp.new` with the backslash still in it.
Route a backslash before a byte at or above `0xC0` away from `parse_escape()` in
both places. `compile_atom()` emits the whole character through
`emit_char_bytes()`, extracted from the branch that already did this for the
unescaped spelling, and `read_class_atom()` falls through to the
`mrb_re_utf8_decode()` path that `[Ā]` already takes. What the two paths share
is the invariant `compile_quantified()` depends on: an atom that consumes a
character has to emit all of that character's bytes before it returns.
The dispatch happens before `parse_escape()` reads the letter, since `\xNN` and
octal `\NNN` name a byte rather than a character and have to keep returning one.
`Regexp.new("\\xC4\\x80+")` therefore still repeats `\x80` alone. CRuby joins
byte escapes that spell a valid UTF-8 sequence into one character and matches
four bytes there; that gap is separate from this change.
|
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 (2)
📝 WalkthroughWalkthroughRegexp compilation now treats escaped UTF-8 characters as complete multibyte atoms. Tests cover quantifiers, character classes, ranges, and raw byte escapes. ChangesUTF-8 regexp atom handling
Estimated code review effort: 3 (Moderate) | ~20 minutes 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 12, 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.
bcacba1e1made a multibyte literal a single atom, so/Ā+/repeats the wholecharacter instead of its last byte. A backslash in front of the same character
takes a different branch of
compile_atom(), which never got the sametreatment: the escape path emits only the lead byte and lets the parse loop pick
up the continuation bytes as atoms of their own. The quantifier then binds to
the last of those. The same backslash inside
[...]goes throughread_class_atom(), which splits the character the same way, so the class endsup holding two wrong codepoints.
In CRuby
/\Ā/and/Ā/are the same pattern: a backslash before a characterwith no special meaning is just that character. Here they differ as soon as a
quantifier follows or a character class encloses them, which is exactly the bug
bcacba1e1fixed for the unescaped spelling.The
/.../literal spelling hides this, because the lexer strips the backslashbefore the gem ever sees the pattern:
/\Ā/.sourceis the two bytes ofĀalone, so both
/\Ā+/and/[\Ā]/behave.Regexp.newwith a string, which ishow a pattern built at runtime arrives, does not.
Cause
compile_atom()has two paths that can see the bytes of a multibyte character.The default path asks
mrb_re_utf8_charlen()how long the character is andemits every byte of it before returning, so the atom the quantifier sees is the
whole character. The escape path calls
parse_escape(), whosedefaultarmreturns the single byte it read, and emits that one byte as
RE_CHAR.compile_quantified()measures the atom as what was emitted betweenstartandcode_len, which is now one byte, and the remaining0x80is left for the nextturn of
compile_seq()to emit as its own atom. So\Ā+compiles as\xC4followed by
(\x80)+, which matchesC4 80and stops.read_class_atom()carries the same split. Its own multibyte path decodes awhole codepoint through
mrb_re_utf8_decode(), but the escape path hands thebackslash to
parse_escape()and gets one byte back. The continuation byte isthen read as a class atom of its own, so
[\Ā]enumeratesU+00C4andU+0080where
[Ā]enumeratesU+0100.Fix
A backslash before a byte at or above
0xC0has no escape meaning in thisparser, so route it away from
parse_escape()in both places, before thatfunction consumes the letter.
compile_atom()emits the whole character throughemit_char_bytes(),extracted from the default branch that already did this for the unescaped
spelling, so there is one description of what a character atom is instead of
two.
read_class_atom()falls through to themrb_re_utf8_decode()path that[Ā]already takes.The invariant the two share, and the one
compile_quantified()depends on, isthat an atom that consumes a character has to emit all of that character's bytes
before it returns.
parse_escape()itself is unchanged. Returning anintbyte is right for everyescape it is meant to serve; the multibyte case just should not reach it.
Byte escapes are left as they are
\xNNand octal\NNNname a byte rather than a character, and the distinctionlives in the pattern text rather than in the value, which is why the dispatch has
to happen before
parse_escape()reads the letter. They keep taking the oldpath, so
Regexp.new("\\xC4\\x80+")still repeats\x80alone. CRuby joins byteescapes that spell a valid UTF-8 sequence into one character and matches four
bytes there. That is a pre-existing difference and closing it belongs to a
separate change; the test added here pins the current behaviour so the next
person sees which way it goes.
Tests
mrbgems/mruby-regexp/test/regexp.rbgets a group next to the onebcacba1e1added for the unescaped form, so the two spellings sit together. It covers the
quantifier and
{n}forms, three and four byte characters, a quantified escapeafter another atom, the non-greedy form,
[\Ā]from both sides, an escaped rangeinside a class, and one
\xNNrow for the byte escape above. Every assertionexcept that last one matches CRuby 4.0.6.
rake test: 1991 OK, 0 KO.Summary by CodeRabbit
Bug Fixes
Tests