Skip to content

mruby-regexp: make a multibyte literal one atom - #7056

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-multibyte-literal-quantifier
Aug 9, 2026
Merged

mruby-regexp: make a multibyte literal one atom#7056
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-multibyte-literal-quantifier

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

compile_atom() emits a literal one byte at a time
(re_compile.c:931). For a character above 127 that means the lead byte and
each continuation byte become atoms of their own, and compile_quantified()
binds a quantifier to the last atom emitted. /Ā+/ therefore compiled as
\xC4(\x80)+: the lead byte once, then the continuation byte repeated.

/Ā+/.match("ĀĀ")[0]      # CRuby: "ĀĀ", mruby: "Ā"
/Ā*/.match("ĀĀ")[0]      # CRuby: "ĀĀ", mruby: "Ā"
/Ā{2}/.match?("ĀĀ")      # CRuby: true,  mruby: false
/Ā{2,3}/.match("ĀĀĀ")   # CRuby: a match, mruby: nil

Three and four byte characters go the same way, and a quantified literal
after another atom is affected too.

/日+/.match("日日")[0]     # CRuby: "日日",   mruby: "日"
/𝕏+/.match("𝕏𝕏")[0]     # CRuby: "𝕏𝕏",   mruby: "𝕏"
/aĀ+/.match("aĀĀ")[0]   # CRuby: "aĀĀ", mruby: "aĀ"

Scanning turns one run into one match per character, which is the form most
likely to be noticed as wrong output rather than as a missing match.

"ĀĀxĀ".scan(/Ā+/)  # CRuby: ["ĀĀ", "Ā"], mruby: ["Ā", "Ā", "Ā"]

Nothing raises in any of these.

Fix

Emit every byte of the character in one go, so the atom spans the whole
character.

That is all it takes, because the quantifier machinery already works on a
span rather than on a single instruction: emit_atom_copy() copies the whole
[start, code_len) range for {n,m}, and the RE_SPLIT that
insert_inst() puts in front of the atom for * and ? covers the range as
well. A group, which is many instructions, has always relied on this; a
multibyte literal was the one atom that did not produce a span.

Where the character ends comes from mrb_re_utf8_charlen(), which derived it
from the lead byte alone and never looked at the bytes that follow. That is too
loose to build an atom from. For the pattern bytes C4 78 2B it reports 2, so
x joins the atom and + repeats \xC4x; for C4 2B it reports 2 as well,
and the + itself is swallowed and stops being a quantifier at all.

b = "\xC4"  # lead byte of a two byte character, standing on its own
Regexp.new(b + "x+").match(b + "xxx")[0].bytesize  # 2 without the check, 4 with it
Regexp.new(b + "+").match(b + b)                   # nil without the check, a match with it

So this checks the continuation bytes there as well. A sequence that never
completes keeps reporting a length of 1, its bytes stay atoms of their own, and
such a pattern reads as the bytes it is made of. mrb_re_utf8_decode() takes
its length from the same function, so the string being matched is read the same
way: . takes an incomplete lead byte alone instead of pairing it with
whatever follows.

Nothing downstream changes. compute_fixed_len() counts each RE_CHAR as one
byte and reaches the same total, first_set_walk() already returns early on a
lead byte above 127, and the literal prefix scan reads whatever run of
RE_CHAR it finds.

Tests

mrbgems/mruby-regexp/test/regexp.rb, two new blocks before
Regexp - multibyte (UTF-8) match extraction.

Regexp - quantifier on a multibyte literal: +, *, {n} and {n,m} on
a two byte literal, the three and four byte cases, a quantified literal
preceded by another atom, a non-greedy form, scan, and an absent optional
literal. The assertions compare bytesize, since a match that stops one
character early is still a match and only the length tells the two apart.

Regexp - quantifier on an invalid multibyte literal: a lead byte followed by
an ASCII byte, a lead byte followed by the quantifier itself, a sequence cut
short by the end of the pattern, a valid character right after a stray lead
byte, and . over a stray lead byte on the subject side.

Verified on x86_64-linux:

  • A differential sweep of 3223 cases against CRuby 4.0.6: four literals from
    one to four bytes, eleven quantifier forms, each plain, preceded by another
    atom and preceded by a lookbehind, over four subject lengths, plus five stray
    lead bytes with five followers each. 234 cases disagreed before this change
    and none do after. Patterns that are not valid UTF-8 are compared against a
    binary CRuby regexp, since CRuby rejects them outright while mruby has no
    encodings.
  • rake test: 1977 total, 1959 OK, 0 KO, 0 crash, and bintest 105 OK.
  • An MRB_INT32 build with clang and -Wall -Wextra: 2046 total, 2036 OK,
    0 KO, 0 crash, and no new warning from any mruby-regexp file (regexp.c
    already emits four -Wunused-parameter).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed regular expression handling for multibyte UTF-8 characters, ensuring complete characters are treated as single pattern atoms.
    • Improved quantifier, optional-match, non-greedy, and scanning behavior for multibyte literals.
    • Invalid or incomplete UTF-8 sequences continue to be handled safely.
  • Tests

    • Added regression coverage for 2-, 3-, and 4-byte UTF-8 characters across supported quantifier patterns.

@takumin
takumin requested a review from matz as a code owner August 9, 2026 15:42
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The regex compiler now emits multibyte UTF-8 characters as single atoms. Regression tests cover quantifiers, optional matches, non-greedy matches, and scanning behavior.

Changes

UTF-8 regex atom handling

Layer / File(s) Summary
Complete UTF-8 atom compilation
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
The compiler consumes and emits complete multibyte UTF-8 characters. Tests verify correct quantifier behavior and matching counts.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • mruby/mruby#7019: Both changes modify compile_atom() and add regression tests, but address different parsing issues.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: treating a multibyte literal as one regex atom.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mrbgems/mruby-regexp/src/re_compile.c`:
- Around line 938-944: Ensure the atom-building logic around mrb_re_utf8_charlen
validates every required UTF-8 continuation byte before consuming it, so invalid
or truncated sequences fall back to a single lead byte and do not absorb
following pattern characters. Update mrb_re_utf8_charlen or add pre-loop
validation, preserving valid multibyte decoding, and add regression coverage for
invalid-continuation and truncated sequences.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4072ebc2-153e-4ae3-ae71-a7ae9cc19954

📥 Commits

Reviewing files that changed from the base of the PR and between 9233195 and ca96faa.

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

Comment thread mrbgems/mruby-regexp/src/re_compile.c
`compile_atom()` emits a literal one byte at a time. For a character above
127 that means the lead byte and each continuation byte become atoms of their
own, and `compile_quantified()` binds a quantifier to the last atom emitted.
`/Ā+/` therefore compiled as `\xC4(\x80)+`.

```ruby
/Ā+/.match("ĀĀ")[0]     # CRuby: "ĀĀ", mruby: "Ā"
/Ā{2}/.match?("ĀĀ")     # CRuby: true,  mruby: false
"ĀĀxĀ".scan(/Ā+/).size  # CRuby: 2,     mruby: 3
```

The repetition forms go the same way, since `{n,m}` copies that same last
atom, and so do three and four byte characters.

Emit every byte of the character in one go instead. The atom then spans the
whole character, `emit_atom_copy()` copies all of its bytes for `{n,m}`, and
the `RE_SPLIT` that `insert_inst()` puts in front of it for `*` and `?` covers
all of them too.

The byte count comes from `mrb_re_utf8_charlen()`, which derived it from the
lead byte alone. That is too loose to build an atom from. For the pattern bytes
`C4 78 2B` it reports 2, so `x` joins the atom and `+` repeats `\xC4x`; for
`C4 2B` it reports 2 as well, and the `+` itself is swallowed and stops being a
quantifier at all. Check the continuation bytes there, so a sequence that never
completes keeps reporting a length of 1 and its bytes stay atoms of their own,
which is how such a pattern reads as bytes.

`mrb_re_utf8_decode()` takes its length from the same function, so the string
being matched is read the same way: `.` takes an incomplete lead byte alone
instead of pairing it with whatever follows.

Nothing downstream changes. `compute_fixed_len()` counts each `RE_CHAR` as one
byte and totals the same, `first_set_walk()` already gives up on a lead byte
above 127, and the literal prefix scan reads whatever `RE_CHAR` run it finds.
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