Skip to content

mruby-regexp: read the \u escape - #7074

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-unicode-escape
Aug 10, 2026
Merged

mruby-regexp: read the \u escape#7074
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-unicode-escape

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The regexp engine has no \u escape. parse_escape()
(re_compile.c:339) knows \n, \t, \r, \f, \v, \a, \e, \b, the
octal form \NNN and the hex form \xHH, and returns every other escaped
character as itself (re_compile.c:389). \u is not among them, so the
backslash is dropped and the u becomes an ordinary literal, along with
whatever follows it.

"µ"     =~ /\u00b5/     # CRuby: 0,   mruby: nil
"u00b5" =~ /\u00b5/     # CRuby: nil, mruby: 0
"µ"     =~ /\u{b5}/     # CRuby: 0,   mruby: nil
"u{b5}" =~ /\u{b5}/     # CRuby: nil, mruby: 0
"µ"     =~ /[\u00b5]/   # CRuby: 0,   mruby: nil
"あ"    =~ /\u{3042}/   # CRuby: 0,   mruby: nil
"ab"    =~ /\u{61 62}/  # CRuby: 0,   mruby: nil

The parser is not involved. /\u00b5/.source is "\\u00b5" in both, so the
escape reaches the engine intact and the engine is where it is lost.

Cause

What the leftover text means depends on the spelling, and neither reading is
the one that was written.

\uXXXX leaves five ASCII characters. A pattern that means one codepoint
quietly means u00b5, and a subject that happens to carry that text matches. A
short \uXX, which is a RegexpError in CRuby, likewise matches the text
uXX.

\u{...} leaves a u followed by a brace group, so a body that reads as a
repetition count becomes a quantifier on the u. /\u{3042}/ is u{3042},
which matches 3042 us and nothing else, and /\u{110000}/ raises
RegexpError: quantifier too large, naming something the pattern never said. A
body that is not a count stays literal, so /\u{b5}/ matches u{b5}, and
/\u{}/ matches u{} where CRuby raises invalid Unicode list.

("u" * 3042) =~ /\u{3042}/  # CRuby: nil, mruby: 0
"uXX"        =~ /\uXX/      # CRuby: RegexpError (invalid Unicode escape),
                            # mruby: 0
/\u{110000}/                # CRuby: RegexpError (invalid Unicode range),
                            # mruby: RegexpError (quantifier too large)

So the quiet wrong answer is the common case, but it is not the only one: the
\u{...} spelling can also fail loudly under a message about a quantifier the
author never wrote.

Neither the README's Pattern Syntax list nor its Limitations section said
anything about \u, and the character escapes that do work (\n, \t,
\xHH, \NNN) were not listed either, so the gap was not written down
anywhere.

Fix

Read the escape. unicode_escape_first() takes \uXXXX as exactly four hex
digits, or opens a \u{...} list whose codepoints unicode_escape_next()
yields one at a time, one to six hex digits each and whitespace between them.

A codepoint above 127 has to become its UTF-8 byte sequence, which is a run of
RE_CHAR, the same shape emit_char_bytes() already emits for a multibyte
character written literally in the pattern (re_compile.c:681), and for the
same reason: the run has to be one atom, or a following quantifier binds to the
last byte alone. emit_codepoint() is that function for a codepoint the
pattern names rather than spells, so the bytes come from
mrb_re_utf8_encode(), the encoding counterpart of the
mrb_re_utf8_decode() the gem already has.

The \u branch sits ahead of the ch >= 0xC0 branch 0e96b2c2d added, which
routes a backslash before a multibyte character away from parse_escape().
u is below 0xC0, so the order is what keeps \u from reaching
parse_escape() as a literal u; the same holds for the peek(c) < 0xC0
guard in read_class_atom().

The list form is where the two contexts part.

Outside a character class /\u{61 62}/ is a sequence of atoms rather than one,
and a quantifier after it repeats the last codepoint only, so /\u{61 62}+/ is
a followed by b+. compile_quantified() used to take the atom's start from
the position it saved before calling compile_atom(); it now takes it from
c->atom_start, which the \u case moves past every codepoint but the last.
The field is saved and restored around the call, so a nested
compile_quantified() inside a group leaves nothing behind for the outer one.

Inside a class each codepoint is a member of its own, and read_class_atom()
adds all but the last to the class itself and returns the last, so it can still
open a range: /[\u{61 62}-z]/ is a plus b-z, which is how CRuby reads it.
read_class_atom() takes the class for that, and class_add_member() routes a
member to the ASCII bitmap or the codepoint range list.

The malformed spellings raise rather than fall back on a shorter codepoint or
on literal text, with CRuby's messages: invalid Unicode escape for a \uXXXX
with fewer than four hex digits, too short escape sequence for a bare \u at
the end of the pattern, invalid Unicode list for \u{}, for a separator
CRuby does not take and for an unterminated group, and invalid Unicode range
for more than six digits, for a surrogate and for anything above U+10FFFF. A
surrogate and an out of range value have no UTF-8 encoding, so rejecting them
is also what keeps mrb_re_utf8_encode() total.

One more pass had to learn about the escape. Whitespace separates the
codepoints of a list, and preprocess_pattern() strips whitespace for /x
before the parser runs, so it now copies a \u{...} group whole; without that
/\u{61 62}/x would join into the single codepoint \u{6162}.

The executors are not touched. They see the RE_CHAR run and the class
members any other pattern produces.

The change comes in two commits: the first lifts out the three pieces the
escape reuses (emit_char(), class_add_member(), hex_value()) with no
behaviour change, the second reads the escape. emit_char() sits beside the
emit_char_bytes() that 0e96b2c2d extracted and names the same job for a
character that is a single byte.

Tests

Three cases in mrbgems/mruby-regexp/test/regexp.rb:

  • Regexp - \u escapes: both spellings against the character and against the
    text they used to match, a codepoint in each UTF-8 length, the quantifier
    binding for a single codepoint and for a list, the /x case that the
    free-spacing pass would otherwise join, and the /i folding of an ASCII
    letter reached through \u.
  • Regexp - \u escapes in a character class: single members, a codepoint
    range, a list, a list that opens a range, and a negated class.
  • Regexp - malformed \u escapes: the eleven spellings CRuby refuses, with the
    message asserted for three of them, \u{110000} among them, since that is
    the one that used to raise about a quantifier instead.

Patterns are always parsed as UTF-8, so the subjects are written as explicit
bytes and the assertions hold in both MRB_UTF8_STRING and byte-string builds.

Verified on x86_64-linux

  • A differential sweep against CRuby 4.0.6 over 273 pattern and flag
    combinations and 4700 answers: 57 patterns under no flag, /i and /x
    against 20 subjects; 26 patterns exercising the interaction with groups,
    repetition counts, alternation and backreferences against the same subjects;
    27 malformed or boundary patterns under no flag and /x against 10 subjects;
    and 22 patterns crossing the escape with the escaped multibyte literal
    0e96b2c2d added, with the surrogate boundary and the astral plane, against
    10 subjects. Every answer and every error class and message agrees with
    CRuby. The one difference left is that mruby's RegexpError message does not
    carry the flag suffix (/\uXX/ where CRuby writes /\uXX/i), which is how
    compile_error() has always formatted a pattern and is unrelated to this
    change.
  • rake test: 2020 total, 2002 OK, 0 KO, 0 crash, and bintest 105 OK.
  • MRUBY_CONFIG=build_config/asan.rb rake test (clang, ASAN and UBSan): 2197
    total, 2195 OK, 0 KO, 0 crash, no sanitizer report, and bintest 78 OK.
  • A full-core build with MRB_UTF8_STRING: 2197 total, 2195 OK, 0 KO,
    0 crash, and bintest 78 OK. The sweep above runs there.
  • clang -Wall -Wextra over re_compile.c and re_utf8.c: no new warning.

Summary by CodeRabbit

  • New Features

    • Added support for Unicode escape sequences in regular expressions, including fixed-width and braced formats.
    • Supports Unicode codepoint lists, character classes, quantifiers, extended mode, and case-insensitive matching.
    • Added UTF-8 encoding for valid Unicode codepoints.
  • Bug Fixes

    • Invalid, malformed, surrogate, and out-of-range Unicode escapes now produce clear regular expression errors.
  • Documentation

    • Documented supported character escapes, examples, and hexadecimal escape limitations.
  • Tests

    • Added comprehensive coverage for Unicode escapes, case folding, and invalid input handling.

@takumin
takumin requested a review from matz as a code owner August 10, 2026 11:27
@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: c6c23233-6105-461f-99e9-838ac2108aa7

📥 Commits

Reviewing files that changed from the base of the PR and between 048e5da and 0b665e3.

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/test/ascii_case.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • mrbgems/mruby-regexp/test/ascii_case.rb

📝 Walkthrough

Walkthrough

The regexp gem adds fixed-width and braced Unicode escapes. It validates and encodes codepoints as UTF-8, supports lists in character classes, updates quantifier and case-folding behavior, and adds tests and documentation.

Changes

Unicode regexp escape support

Layer / File(s) Summary
Unicode encoding and escape parsing
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_utf8.c, mrbgems/mruby-regexp/src/re_compile.c
The gem exposes UTF-8 encoding and parses validated \uXXXX and \u{...} codepoints and lists.
Unicode class and atom compilation
mrbgems/mruby-regexp/src/re_compile.c
Character classes consume Unicode lists. Atom emission handles UTF-8 encoding and case folding. Quantifiers target the final codepoint in a list. Extended mode preserves list whitespace.
Unicode escape coverage and documentation
mrbgems/mruby-regexp/test/*.rb, mrbgems/mruby-regexp/README.md
Tests cover valid and malformed escapes, lists, ranges, quantification, extended mode, and case folding. Documentation describes supported escapes and \xHH limits.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Pattern
  participant RegexpCompiler
  participant UTF8Encoder
  participant Bytecode
  Pattern->>RegexpCompiler: provide Unicode escape
  RegexpCompiler->>RegexpCompiler: parse and validate codepoint
  RegexpCompiler->>UTF8Encoder: encode codepoint
  UTF8Encoder-->>RegexpCompiler: return UTF-8 bytes
  RegexpCompiler->>Bytecode: emit atom instructions
Loading

Possibly related PRs

  • mruby/mruby#7049: Modifies ASCII case folding for character classes in the same regexp compiler.
  • mruby/mruby#7056: Modifies regexp atom emission and quantifier handling for multibyte characters.
  • mruby/mruby#7058: Extends regexp Unicode case-folding paths to support \u escapes and adds 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 identifies mruby-regexp and the main change: adding support for the \u escape.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.
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.

@matz

matz commented Aug 10, 2026

Copy link
Copy Markdown
Member

This needs a rebase: #7058 landed as 3bc4b88 and it adds emit_char_folded() right where this adds emit_codepoint(). Three hunks in re_compile.c and one in the README.

The textual part is mechanical, both sides being new functions side by side. The part I would rather you decide than guess at is what \u should do under /i, because the two changes answer it differently today.

On your branch as it stands:

Regexp.new("\\u00c0", Regexp::IGNORECASE) =~ "à"   # nil

On master, the same character written literally:

Regexp.new("À", Regexp::IGNORECASE) =~ "à"         # RegexpError

A naive merge keeps both, and then one character answers "silently no match" when it is named by codepoint and "refused" when it is spelled, in the same build. That is the split #7058 exists to remove, so whichever way it goes it should go the same way for both spellings.

I have no view on where \u should hook in, only that the two should agree. Presumably the escape wants to reach emit_char_folded() the way a literal does, so that a codepoint the build cannot fold is refused whichever way it was written, and one it can fold is folded. Your call, and it may be simpler than that once the two are in the same file.

The rest I checked before running into this. All seven rows in your table reproduce on master, /\u{3042}/, /\u{61 62}/ and /[µ]/ all answer correctly on your branch, and /A/i matching "a" shows the ASCII side is unaffected. It is only the non-ASCII /i case that the rebase has to settle.

`compile_atom()` spells the /i literal out twice, once for the escape path
and once for the plain one, and each spelling branches on the letter's case
to pick the byte to fold to. `compile_charclass()` likewise decides inline
which side of 128 a class member belongs to. A `\u` escape is about to want
all of it: it emits a literal, it adds class members, and it reads hex digits
the way `\xHH` already does.

Lift the three out. `emit_char()` emits one byte, folding an ASCII letter to
a two-member class under /i, and reaches the other case with `ch ^ 0x20`
rather than a branch per case. `class_add_member()` routes a codepoint to the
ASCII bitmap or to the codepoint range list. `hex_value()` reads one hex
digit, returning -1 for anything else, which covers the -1 that `peek()`
gives at the end of the pattern.

`emit_char()` sits beside `emit_char_bytes()` and names the same job for a
character that is a single byte, so the escape path and the plain path reach
one of the two by the width of what they read.

No behaviour change. The folding condition already implied a byte below 128,
so the plain path's separate `ch < 128` test drops with it.
`parse_escape()` knows `\n`, `\t`, `\xHH` and the octal form, and returns every
other escaped character as itself. `\u` is not among them, so the backslash is
dropped and the `u` becomes an ordinary literal, along with whatever follows
it.

```ruby
"µ"     =~ /\u00b5/     # CRuby: 0,   mruby: nil
"u00b5" =~ /\u00b5/     # CRuby: nil, mruby: 0
"µ"     =~ /[\u00b5]/   # CRuby: 0,   mruby: nil
"あ"    =~ /\u{3042}/   # CRuby: 0,   mruby: nil
"ab"    =~ /\u{61 62}/  # CRuby: 0,   mruby: nil
```

The parser is not involved. `/\u00b5/.source` is `"\\u00b5"` in both, so the
escape reaches the engine intact and the engine is where it is lost.

What the leftover text then means depends on the spelling, and neither reading
is the one that was written. `\uXXXX` leaves five ASCII characters, so a
pattern that names one codepoint quietly names the text `u00b5`, and a subject
that happens to carry that text matches. `\u{...}` leaves a `u` followed by a
brace group, so a body that reads as a repetition count becomes a quantifier on
the `u`: `/\u{3042}/` matches 3042 `u`s and nothing else, and `/\u{110000}/`
raises `RegexpError: quantifier too large`, naming something the pattern never
said.

```ruby
("u" * 3042) =~ /\u{3042}/  # CRuby: nil, mruby: 0
```

Read the escape instead. `\uXXXX` takes exactly four hex digits, `\u{...}` one
to six per codepoint and several codepoints separated by whitespace. A
codepoint above 127 becomes its UTF-8 byte sequence, emitted as the run of
`RE_CHAR` that `emit_char_bytes()` already produces for a multibyte literal, so
the run is one atom and a following quantifier repeats the whole character
rather than its last byte. The bytes come from `mrb_re_utf8_encode()`, the
encoding counterpart of the `mrb_re_utf8_decode()` the gem already has, since
a named codepoint is not spelled out in the pattern to copy from.

Under `/i` a codepoint above 127 takes the route a spelled out literal takes
since `emit_char_folded()` arrived: the class of its case counterparts, or a
refusal when the build has no folding for the character. Naming a character
rather than spelling it does not change what `/i` means by it, so `/\u{212a}/i`
and `/K/i` compile alike.

The branch has to sit ahead of the `ch >= 0xC0` one that routes a backslash
before a multibyte character away from `parse_escape()`, and ahead of the
matching `peek(c) < 0xC0` guard in `read_class_atom()`. `u` is below `0xC0`,
so either would otherwise take `\u` back to `parse_escape()` as a literal.

The list form is where the two contexts part. Outside a character class
`/\u{61 62}/` is a sequence of atoms rather than one, and a quantifier after it
repeats the last codepoint alone, so `compile_quantified()` now takes the
atom's start from the compiler state rather than from the position it saved:
the `\u` case moves it past every codepoint but the last. Inside a class each
codepoint is a member of its own, and the last one is handed back to
`compile_charclass()` so it can still open a range, as in `/[\u{61 62}-z]/`.

The malformed spellings raise instead of falling back on a shorter codepoint or
on literal text, with CRuby's messages. A short `\uXX`, an overlong
`\u{0000061}`, an out of range `\u{110000}`, a surrogate and an empty `\u{}`
each used to be a quiet wrong answer or an error about a quantifier.

Whitespace separates the codepoints of a list, so `preprocess_pattern()` copies
a `\u{...}` group whole. The free-spacing pass runs before the parser and would
otherwise join `/\u{61 62}/x` into the single codepoint `\u{6162}`.

The README lists the escape alongside the character escapes that already
worked, which it never mentioned either.
@takumin
takumin force-pushed the regexp-unicode-escape branch from a259474 to 048e5da Compare August 10, 2026 14:11
@takumin

takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased, onto beefa1e02 rather than 3bc4b8884 since #7075 and #7076 landed in the meantime. Neither touches re_compile.c, so the only overlap with them was one README section and one test file, both automatic.

On the question you left to me: the escape reaches the folding the way a literal does, as you guessed. emit_char_folded() had the decode and the folding in one function, and only the decode wants the pattern, so the folding half is now emit_cp_folded() and both callers reach it: emit_char_folded() after decoding what the pattern spells, and emit_codepoint() with the codepoint the escape named. No logic moved with it.

Both spellings answer alike now, and both spellings of the escape do too:

# Build without MRB_REGEXP_UNICODE_CASE
Regexp.new("À",         Regexp::IGNORECASE)  # RegexpError
Regexp.new("\\u00c0",   Regexp::IGNORECASE)  # RegexpError
Regexp.new("\\u{c0}",   Regexp::IGNORECASE)  # RegexpError
Regexp.new("[\\u{c0}]", Regexp::IGNORECASE)  # RegexpError

# Build with MRB_REGEXP_UNICODE_CASE
Regexp.new("À",       Regexp::IGNORECASE) =~ "à"  # 0
Regexp.new("\\u00c0", Regexp::IGNORECASE) =~ "à"  # 0

A character class was already consistent before this, since a class member is a codepoint whichever way it was written and the closure runs over the finished class. The literal path is what needed the change.

The tests for it sit with the rest of the case folding tests rather than with the \u ones, since what they assert depends on the build: test/ascii_case.rb takes the refusals and the U+212A case that folds to ASCII without the table, test/unicode_case.rb takes the foldings. Each covers the plain form, the class, the negated class and a range.

Checked both builds, and checked that the tests fail without the change: without it the option build answers nil where it now matches, so the two paths were not agreeing by accident.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mrbgems/mruby-regexp/test/ascii_case.rb (1)

29-39: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the option-specific message for Unicode escape paths.

Lines 29-33 check only RegexpError. Lines 34-39 check MRB_REGEXP_UNICODE_CASE only for the literal path. A regression can return a generic RegexpError for \uXXXX, \u{...}, or character-class escapes and still pass. Check that each escape parser path reports the option-specific message.

🤖 Prompt for 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.

In `@mrbgems/mruby-regexp/test/ascii_case.rb` around lines 29 - 39, Update the
Unicode escape assertions in the RegexpError tests for \uXXXX, \u{...}, and
character-class escape paths to also verify that the exception message includes
“MRB_REGEXP_UNICODE_CASE”. Preserve coverage for the existing escape variants
and ensure each assertion validates both the error type and option-specific
message.
🤖 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.

Outside diff comments:
In `@mrbgems/mruby-regexp/test/ascii_case.rb`:
- Around line 29-39: Update the Unicode escape assertions in the RegexpError
tests for \uXXXX, \u{...}, and character-class escape paths to also verify that
the exception message includes “MRB_REGEXP_UNICODE_CASE”. Preserve coverage for
the existing escape variants and ensure each assertion validates both the error
type and option-specific message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e75e4d22-3937-41ab-91fe-f2d81bb9705e

📥 Commits

Reviewing files that changed from the base of the PR and between a259474 and 048e5da.

📒 Files selected for processing (7)
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/re_utf8.c
  • mrbgems/mruby-regexp/test/ascii_case.rb
  • mrbgems/mruby-regexp/test/regexp.rb
  • mrbgems/mruby-regexp/test/unicode_case.rb
🚧 Files skipped from review as they are similar to previous changes (4)
  • mrbgems/mruby-regexp/src/re_utf8.c
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c

The `\u` cases asserted `RegexpError` and nothing else, and a `\u`
pattern has reasons of its own to raise that class: a regression in the
escape parser could report a malformed escape for `\u{100}` and the test
would still pass. Assert the message instead. It names the option, and
its tail says whether the character path or the character class path
refused, so each spelling is pinned to the refusal it is there for.

`assert_raise_with_message` also replaces the `begin`/`rescue` that
checked the literal path's message, which asserted nothing at all when
the pattern compiled.
@takumin

takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

The CodeRabbit nitpick on test/ascii_case.rb has no inline thread to answer
in, so: fixed in 0b665e3.

The point was fair. The \u cases asserted RegexpError and nothing else, and
a \u pattern raises that class for its own spelling too, so a regression in
the escape parser could report a malformed escape for \u{100} and the test
would still pass. They now assert the message through
assert_raise_with_message, whose tail (for this character against for this character class) also pins which of the two paths refused.

The begin/rescue that checked the literal path's message went the same way,
since it asserted nothing at all when the pattern compiled.

rake test: 2037 total, 2019 OK, 0 KO, 0 crash, and bintest 105 OK. Breaking
the expected message on purpose turns the test red, so the assertions do run.

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