Skip to content

mruby-regexp: split a character class range at the ASCII boundary - #7052

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-charclass-mixed-range
Aug 9, 2026
Merged

mruby-regexp: split a character class range at the ASCII boundary#7052
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-charclass-mixed-range

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

compile_charclass() stores a range in one of two places. Both bounds below
128 go into the class bitmap through class_set_range(); anything else goes
into the codepoint list through class_add_range(). A range that starts in
ASCII and ends above it took the second path with the span left whole.

class_match() answers a codepoint below 128 from the bitmap alone and never
reads the codepoint list, so the ASCII half of such a range is unreachable.

/[a-\u0100]/.match?("a")  # CRuby: true, mruby: false
/[a-\u0100]/.match?("z")  # CRuby: true, mruby: false
/[0-\u0100]/.match?("5")  # CRuby: true, mruby: false

The upper half still answers, so the class reads as half alive rather than
broken.

/[a-\u0100]/.match?("\u0100")  # CRuby: true, mruby: true

A negated class is worse. Negation is applied at match time against the same
bitmap, so a class that matches nothing below 128 makes its negated form match
everything below 128, including the characters it was written to reject.
Nothing raises.

/[^a-\u0100]/.match?("a")  # CRuby: false, mruby: true

Fix

Split the range at the boundary instead of picking one side for the whole
span. The bitmap takes cp through 127 and the codepoint list takes 128
through hi.

A range that stays on one side reaches exactly one of the two calls and is
stored where it was before: [a-c] sets bitmap bits only, [\u0100-\u0110]
appends a codepoint range only. Only the straddling case changes, and it now
occupies both.

The cp <= hi guard moves out to cover both calls. It used to guard the
codepoint path alone, while the ASCII path relied on class_set_range()'s
loop not running for an inverted range. Neither call is reached now when
cp > hi, so a descending range still stores nothing.

This also restores class_add_range()'s documented contract that both of its
bounds are at least 128. The straddling case was the one caller that broke it.

compute_first_set() needs no change. first_set_walk() bails out on an
RE_CLASS whose codepoint list is non-empty, which a split range still leaves
non-empty, so the first-byte skip stays disabled for these patterns exactly as
it was.

Scope

Non-ASCII case folding is unchanged and still out of scope: /[\u0100]/i does
not match "\u0101". What this fixes is the ASCII half of a mixed range,
which the bitmap already knew how to hold.

#7049 folds /i into that same bitmap at the end of compile_charclass(), so
the two compose: with the ASCII half now stored where the fold walks,
/[a-\u0100]/i matches "A" and /[^a-\u0100]/i rejects it, both of which
agree with CRuby. That fold could not reach a straddling range on its own,
because the range never reached the bitmap.

Tests

mrbgems/mruby-regexp/test/regexp.rb, a new
Regexp - character class range across the ASCII boundary block next to
Regexp - character class: the ASCII half of [a-\u0100], both of its
boundaries (U+0060 below and U+0101 above), the non-ASCII half, a quantified
form, all four negated cases, the two /i forms, and two single-sided ranges
as regression guards.

It carries no __ENCODING__ guard, unlike the other multibyte blocks in the
file. It reads only match results, never a character offset, so it runs on a
build without MRB_UTF8_STRING, which is what CI builds. With the guard the
block would never execute in CI.

Verified on x86_64-linux:

  • Every reproduction above now agrees with CRuby 4.0.6.
  • rake test: 1976 total, 1958 OK, 0 KO, 0 crash, and bintest 105 OK.
  • An MRB_INT32 build with clang and -Wall -Wextra: 1878 total, 1860 OK,
    0 KO, 0 crash, and no new warning from any mruby-regexp file.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed regular expression character classes that span the ASCII and non-ASCII boundary.
    • Improved matching for positive, negated, quantified, and case-insensitive character ranges.
    • Preserved correct behavior for ranges entirely within ASCII or non-ASCII characters.
  • Tests

    • Added regression coverage for boundary-spanning character-class ranges.

`compile_charclass()` stores a range with both bounds below 128 in the class
bitmap and every other range in the codepoint list. A range that starts in
ASCII and ends above it went to the codepoint list whole. `class_match()`
answers a codepoint below 128 from the bitmap alone and never reads the
codepoint list, so the ASCII half of such a range matched nothing.

```ruby
/[a-\u0100]/.match?("a")   # CRuby: true, mruby: false
/[0-\u0100]/.match?("5")   # CRuby: true, mruby: false
```

Negation is applied at match time against the same bitmap, so a negated form
accepted every ASCII character it was written to reject. Nothing raises.

```ruby
/[^a-\u0100]/.match?("a")  # CRuby: false, mruby: true
```

Split the range at the boundary: the bitmap takes `cp` through 127 and the
codepoint list 128 through `hi`. A range that stays on one side reaches
exactly one of the two calls and is stored as before, so only the straddling
case changes. The `cp <= hi` guard moves out to cover both calls, which
leaves a descending range storing nothing, as before.

With the ASCII half in the bitmap, the `/i` fold at the end of the same
function reaches it, so `/[a-\u0100]/i` now matches `"A"` too. Non-ASCII case
folding stays unapplied, so `/[\u0100]/i` still does not match `"\u0101"`.

This also restores `class_add_range()`'s documented contract that both of its
bounds are at least 128; the straddling case was the one caller that broke it.

`compute_first_set()` needs no change: `first_set_walk()` bails out on a class
whose codepoint list is non-empty, which a split range still leaves non-empty,
so the first-byte skip stays disabled for these patterns exactly as it was.
@takumin
takumin requested a review from matz as a code owner August 9, 2026 14:58
@coderabbitai

coderabbitai Bot commented Aug 9, 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: 11a828f0-c258-409b-9534-80052a735957

📥 Commits

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

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

📝 Walkthrough

Walkthrough

Regexp compilation now splits character-class ranges that cross the ASCII/non-ASCII boundary. Tests cover matching, negation, case-insensitive matching, quantification, and same-side ranges.

Changes

Regexp character-class boundary handling

Layer / File(s) Summary
Split mixed character ranges and add regression coverage
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
Mixed ranges store ASCII values in the bitmap and non-ASCII values in the codepoint range list. Tests verify matching, negation, /i, quantification, invalid ranges, and same-side ranges.

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

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 change: splitting character class ranges at the ASCII boundary.
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