Skip to content

mruby-regexp: make an escaped multibyte literal one atom - #7066

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-escaped-multibyte-atom
Aug 10, 2026
Merged

mruby-regexp: make an escaped multibyte literal one atom#7066
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-escaped-multibyte-atom

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

bcacba1e1 made a multibyte literal a single atom, so /Ā+/ repeats the whole
character instead of its last byte. A backslash in front of the same character
takes a different branch of compile_atom(), which never got the same
treatment: 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 through
read_class_atom(), which splits the character the same way, so the class ends
up holding two wrong codepoints.

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
Regexp.new("\\Ā").match?("Ā")               # CRuby: true,  mruby: true

In CRuby /\Ā/ and /Ā/ are the same pattern: a backslash before a character
with 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
bcacba1e1 fixed for the unescaped spelling.

The /.../ literal spelling hides this, because the lexer strips the backslash
before the gem ever sees the pattern: /\Ā/.source is the two bytes of Ā
alone, so both /\Ā+/ and /[\Ā]/ behave. Regexp.new with a string, which is
how 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 and
emits every byte of it before returning, so the atom the quantifier sees is the
whole character. The escape path calls parse_escape(), whose default arm
returns the single byte it read, and emits that one byte as RE_CHAR.
compile_quantified() measures the atom as what was emitted between start and
code_len, which is now one byte, and the remaining 0x80 is left for the next
turn of compile_seq() to emit as its own atom. So \Ā+ compiles as \xC4
followed by (\x80)+, which matches C4 80 and stops.

read_class_atom() carries the same split. Its own multibyte path decodes a
whole codepoint through mrb_re_utf8_decode(), but the escape path hands the
backslash to parse_escape() and gets one byte back. The continuation byte is
then read as a class atom of its own, so [\Ā] enumerates U+00C4 and U+0080
where [Ā] enumerates U+0100.

Fix

A backslash before a byte at or above 0xC0 has no escape meaning in this
parser, so route it away from parse_escape() in both places, before that
function consumes the letter.

  • compile_atom() emits the whole character through emit_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 the mrb_re_utf8_decode() path that
    [Ā] already takes.

The invariant the two share, and the one compile_quantified() depends on, is
that an atom that consumes a character has to emit all of that character's bytes
before it returns.

parse_escape() itself is unchanged. Returning an int byte is right for every
escape it is meant to serve; the multibyte case just should not reach it.

Byte escapes are left as they are

\xNN and octal \NNN name a byte rather than a character, and the distinction
lives 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 old
path, so Regexp.new("\\xC4\\x80+") still repeats \x80 alone. CRuby joins byte
escapes 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.rb gets a group next to the one bcacba1e1
added for the unescaped form, so the two spellings sit together. It covers the
quantifier and {n} forms, three and four byte characters, a quantified escape
after another atom, the non-greedy form, [\Ā] from both sides, an escaped range
inside a class, and one \xNN row for the byte escape above. Every assertion
except that last one matches CRuby 4.0.6.

rake test: 1991 OK, 0 KO.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed regular expressions so escaped UTF-8 characters are matched as complete characters.
    • Corrected quantifier behavior for escaped multibyte characters, including optional, bounded, greedy, and nongreedy patterns.
    • Preserved byte-oriented behavior for raw byte escapes.
  • Tests

    • Added coverage for escaped two-, three-, and four-byte characters, character classes, ranges, and quantifier combinations.

`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.
@takumin
takumin requested a review from matz as a code owner August 10, 2026 08:30
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 581904e6-a0a1-4afb-9b6d-f76a25c09d17

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad6907 and 0e96b2c.

📒 Files selected for processing (2)
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp.rb

📝 Walkthrough

Walkthrough

Regexp compilation now treats escaped UTF-8 characters as complete multibyte atoms. Tests cover quantifiers, character classes, ranges, and raw byte escapes.

Changes

UTF-8 regexp atom handling

Layer / File(s) Summary
Escaped UTF-8 atom compilation
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
The compiler preserves escaped multibyte characters, centralizes UTF-8 byte emission, and applies the behavior to character classes and unescaped characters. Regression tests cover quantifiers, ranges, and byte-oriented hexadecimal escapes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • mruby/mruby#7056: Both changes update regexp UTF-8 atom emission and related tests.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: treating an escaped multibyte literal as one regexp atom.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants