Skip to content

mruby-regexp: keep the word class inside ASCII under /i - #7265

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-icase-ctype-class-bits
Aug 18, 2026
Merged

mruby-regexp: keep the word class inside ASCII under /i#7265
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-icase-ctype-class-bits

Conversation

@takumin

@takumin takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closing a character class under /i folded every ASCII bit alike, whichever form of the class had set it. The bits \w, [:word:] and [:ascii:] set were folded across the ASCII boundary with the rest, so k reached U+212A and s reached U+017F through them, and the negated forms rejected the two characters they have to accept:

"ſ".match?(/[^\w]/i)   # CRuby: true    mruby before: false   mruby after: true
"K".match?(/[^\w]/i)   # CRuby: true    mruby before: false   mruby after: true
/[\w]/i =~ "ſ"         # CRuby: nil     mruby before: 0       mruby after: nil
/[\w]/i =~ "K"         # CRuby: nil     mruby before: 0       mruby after: nil
/[[:ascii:]]/i =~ "ſ"  # CRuby: nil     mruby before: 0       mruby after: nil

Each of the three is a set ASCII defines: \w is [a-zA-Z0-9_] and no more, so a fold that leaves ASCII leaves the set. CRuby keeps them out of the class it folds across the boundary from and folds everything else in the class, the other POSIX brackets included; there those brackets hold every Unicode member, so what the fold adds to them is a member already.

Fix

compile_charclass() now holds what \w, \W, [:word:] and [:ascii:] add in a class of its own until the closure under folding has run, and merges its bitmap and utf8_any into the class after. Each of them holds both cases of every letter it holds, so the ASCII part of the closure had nothing to add to them, and nothing beyond the two foldings above folds to what they hold, so holding them apart changes nothing else. posix_class_bits() reports which names the rule covers.

A letter written out beside the shorthand still folds, since the class then holds it by name as well, and the other POSIX brackets fold as before:

/[\ws]/i =~ "ſ"              # CRuby: 0     mruby before: 0     mruby after: 0
/[\wS]/i =~ "ſ"              # CRuby: 0     mruby before: 0     mruby after: 0
/[k\w]/i =~ "K"              # CRuby: 0     mruby before: 0     mruby after: 0
/[\wk]/i =~ "ſ"              # CRuby: nil   mruby before: 0     mruby after: nil
/[[:lower:]]/i =~ "K"        # CRuby: 0     mruby before: 0     mruby after: 0
"ſ".match?(/[^[:alpha:]]/i)  # CRuby: false mruby before: false mruby after: false
/\w/i =~ "ſ"                 # CRuby: nil   mruby before: nil   mruby after: nil

Every build carries the two foldings (U+212A to k, U+017F to s), so every build was affected the same way; the ascii-case build takes the same fix through the #else branch of the closure.

What stays as it was: this gem's POSIX brackets are ASCII sets, so [[:word:]] and [[:alpha:]] do not hold ſ where CRuby's do; that is a separate divergence, and /i no longer changes the answer for [[:word:]] either way.

Testing

New block Regexp - /i keeps the word class inside ASCII in mrbgems/mruby-regexp/test/regexp_syntax.rb, next to the existing block about the two ASCII-result foldings. It runs on every build that reads its strings as characters (the answers do not depend on the Unicode table, so it is not in unicode_case.rb), and covers: [\w], [^\w], [[:ascii:]], [^[:ascii:]], [\W], [^\W], \w, \W against both U+212A and U+017F; [[:word:]] giving the same answer with and without /i; a letter beside the shorthand in either case and either order ([\ws], [\wS], [k\w], [\wa-z], [^\ws]); naming one letter folding only that letter ([\wk], [\ws]); a member above ASCII still reaching the letter's other case through the fold ([\wſ] matching S); and [[:lower:]] / [[:alpha:]] / [^[:alpha:]] still folding. Every assertion in the block was run under CRuby 4.0.6 as well and agrees.

README: one sentence in the case folding section.

Full suite green at every commit (single commit).

Build Total KO Crash
ci/gcc-clang full-debug 2351 0 0
ci/gcc-clang bintest 2351 (+ bintest 123) 0 0
ci/gcc-clang cxx_abi 2351 0 0
ci/gcc-clang byte-string 2281 0 0
ci/gcc-clang ascii-case 2348 0 0
default (rake -m test) 2127 (+ bintest 112) 0 0

Environment

Machine, toolchain, and the compile line of every build
Item Value
OS Ubuntu 24.04.4 LTS
Kernel 7.0.0-29-generic
CPU AMD Ryzen 9 5950X 16-Core Processor
C compiler gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
binutils GNU ld (GNU Binutils) 2.47.20260726
rake rake, version 13.3.1
CRuby (reference) ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]

Actual compile line of src/string.c in each build_config/ci/gcc-clang.rb build (-MMD -c, -I, and -o dropped). full-debug is -O0 because enable_debug appends -g3 -O0 after the toolchain's -g -O3; cxx_abi compiles C as C++ with gcc -x c++ -std=gnu++03, g++ only links.

# full-debug
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c
# bintest
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK src/string.c
# cxx_abi
gcc -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRB_USE_CXX_EXCEPTION -DMRB_USE_CXX_ABI -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c
# byte-string
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c
# ascii-case
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CASE -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

Summary by CodeRabbit

  • Bug Fixes
    • Corrected case-insensitive regular expression behavior for ASCII-only classes such as \w, \W, [:word:], and [:ascii:].
    • Prevented these classes from incorrectly matching non-ASCII Unicode characters while preserving expected folding for explicitly written letters and other POSIX classes.
  • Documentation
    • Clarified the behavior of ASCII-only character classes when using the /i flag.

Closing a character class under /i folded every ASCII bit alike, whichever
form of the class had set it. The bits `\w`, `[:word:]` and `[:ascii:]` set
were folded across the ASCII boundary with the rest, so `k` reached U+212A
and `s` reached U+017F through them, and the negated forms rejected the two:

```ruby
"ſ".match?(/[^\w]/i)   # CRuby: true, mruby: false
"K".match?(/[^\w]/i)   # CRuby: true, mruby: false
/[\w]/i =~ "ſ"         # CRuby: nil, mruby: 0
/[[:ascii:]]/i =~ "ſ"  # CRuby: nil, mruby: 0
```

Each of the three is a set ASCII defines: `\w` is `[a-zA-Z0-9_]` and no
more, so a fold that leaves ASCII leaves the set. CRuby keeps them out of the
class it folds across the boundary from and folds everything else in the
class, the other POSIX brackets included; there those brackets hold every
Unicode member, so what the fold adds to them is a member already.

`compile_charclass()` now holds what `\w`, `\W`, `[:word:]` and `[:ascii:]`
add in a class of its own until the closure has run, and merges its bitmap
and `utf8_any` into the class after. Each of them holds both cases of every
letter it holds, so the ASCII part of the closure had nothing to add to them,
and nothing beyond the two foldings above folds to what they hold, so holding
them apart changes nothing else. `posix_class_bits()` reports which names the
rule covers. A letter written out beside the shorthand still folds, since the
class then holds it by name as well:

```ruby
/[\ws]/i =~ "ſ"   # CRuby: 0, mruby: 0
/[k\w]/i =~ "K"   # CRuby: 0, mruby: 0
```

Outside a class `\w` never folded, and the other POSIX brackets fold as
before: `[[:lower:]]` under /i still reaches U+212A through `k`, and
`[^[:alpha:]]` still rejects U+017F, both as CRuby answers. Every build
carries the two foldings, so every build was affected the same way, and the
test sits with the one about them in `regexp_syntax.rb`, where it runs on
each.
@takumin
takumin requested a review from matz as a code owner August 18, 2026 10:09
@coderabbitai

coderabbitai Bot commented Aug 18, 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: 1b5dbd31-9167-46bb-9778-8e29827f5c74

📥 Commits

Reviewing files that changed from the base of the PR and between 885e215 and 6324006.

📒 Files selected for processing (3)
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp_syntax.rb

Included review availability: Your plan includes up to 8 reviews per rolling hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The regexp compiler now preserves ASCII-defined shorthand and POSIX classes during /i matching while continuing to fold explicit characters and other POSIX classes. Tests and documentation cover the ASCII boundary behavior.

Changes

ASCII-aware regexp case folding

Layer / File(s) Summary
Separate ASCII classes from foldable members
mrbgems/mruby-regexp/src/re_compile.c
The compiler keeps \w, \W, [:word:], and [:ascii:] separate from foldable members, then merges them after case-fold closure.
Validate and document ASCII-boundary behavior
mrbgems/mruby-regexp/test/regexp_syntax.rb, mrbgems/mruby-regexp/README.md
Tests cover shorthand, POSIX, negated, explicit, and range behavior under /i. The README documents the ASCII-defined class rules.

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

Merge Risk: ⚪ Minimal · up to 63240

This PR narrows case-insensitive character-class folding so ASCII-only classes remain within ASCII while preserving existing folding for explicit letters and other classes; the reported test suite passes, and no actionable merge-blocking risk remains.

Possibly related PRs

  • mruby/mruby#7049: Both changes modify /i case folding for regexp character classes.
  • mruby/mruby#7190: Both changes modify which character classes participate in /i folding.
  • mruby/mruby#7207: Both changes modify character-class compilation and case-fold closure.

Suggested labels: mrbgems

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: keeping the word class within ASCII under case-insensitive matching.
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.

@matz
matz merged commit 185001d into mruby:master Aug 18, 2026
21 checks passed
@takumin
takumin deleted the regexp-icase-ctype-class-bits branch August 18, 2026 13:04
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