Skip to content

mruby-regexp: share one class per codepoint for /i literals - #7262

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

mruby-regexp: share one class per codepoint for /i literals#7262
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-icase-literal-class-cache

Conversation

@takumin

@takumin takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Under /i a literal with case counterparts compiles to a character class holding it and them, and every occurrence made a class of its own. A class id is a uint8_t, so a pattern holds 256 at most, and a phrase of a few hundred letters ran out of them and was refused. CRuby compiles and matches it.

Regexp.new("д" * 300, Regexp::IGNORECASE) =~ "Д" * 300
# CRuby:        0
# mruby before: RegexpError (too many character classes: /ддд.../)
# mruby after:  0
Regexp.new("a" * 300, Regexp::IGNORECASE) =~ "A" * 300
# CRuby:        0
# mruby before: RegexpError (too many character classes: /aaa.../)
# mruby after:  0

The class such a literal needs holds the codepoint and its case counterparts and nothing else: neither the flags in force nor the pattern around it reach it, and nothing writes to it once emit_char() or emit_cp_folded() has returned. What it holds is a function of the codepoint, so the second occurrence of a codepoint can name the class the first one made rather than make another.

Fix

The compiler records, by class id, the codepoint each /i literal class stands for (literal_cp[RE_MAX_CLASSES] in re_compiler), and literal_class() looks a codepoint up there before adding a class. The record sits in the compiler's frame and dies with it; zero marks a class made by anything else, which U+0000 cannot be confused with, since it has no case. Both literal paths, the ASCII letter in emit_char() and the folded codepoint in emit_cp_folded(), fill the class only when it is new. add_class() is unchanged: the slot is still zero cleared before num_classes counts it.

The cap now counts distinct codepoints. A pattern that folds more than 256 different ones is still refused, which CRuby does not do; that is out of scope here and the test pins the present meaning of the cap. Bracket classes ([k]) and the shorthands (\d) keep taking a class per occurrence, so "\\d" * 300 still hits the cap; that is a separate defect of the same shape and not touched here.

Testing

  • mrbgems/mruby-regexp/test/unicode_case.rb (runs where RE_UNICODE_CASE is on): "д" * 300 under /i compiles and matches both cases; "дД" * 150, "д" * 256 + "a" * 256, and a \u{434} escape mixed with the spelled character all compile; the same codepoint outside (?i:...) still matches its own case alone; 281 distinct cased codepoints (Cyrillic, Latin-1, Greek, Armenian) are still refused, 207 of them compile and match their upcase.
  • mrbgems/mruby-regexp/test/regexp_syntax.rb (every build): "a" * 300 and "aA" * 150 under /i compile and match; (?i:a)a matches "Aa" and not "AA".
  • Both new tests fail on master with RegexpError: too many character classes and pass at the tip.

Full suite green at every commit (single commit).

Build Total KO Crash
ci/gcc-clang full-debug 2352 0 0
ci/gcc-clang bintest 2352 0 0
ci/gcc-clang cxx_abi 2352 0 0
ci/gcc-clang byte-string 2281 0 0
ci/gcc-clang ascii-case 2348 0 0
default (rake -m test) 2127 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
    • Improved case-insensitive regular expressions with many repeated characters.
    • Preserved matching behavior for ASCII and Unicode characters, including scoped /i patterns.
    • Added clear errors when regular expressions exceed the supported character-class limit.
  • Tests
    • Expanded coverage for repeated literals, mixed Unicode spellings, case matching, and class-limit behavior.

Under /i a literal with case counterparts compiles to a character class
holding it and them, and every occurrence made a class of its own. A class
id is a `uint8_t`, so a pattern holds 256 at most, and a phrase of a few
hundred letters ran out of them and was refused:

```ruby
Regexp.new("д" * 300, Regexp::IGNORECASE) =~ "Д" * 300
# CRuby: 0
# mruby: RegexpError (too many character classes: /ддд.../)
Regexp.new("a" * 300, Regexp::IGNORECASE) =~ "A" * 300
# CRuby: 0
# mruby: RegexpError (too many character classes: /aaa.../)
```

The class such a literal needs holds the codepoint and its case
counterparts and nothing else: neither the flags in force nor the pattern
around it reach it, and nothing writes to it once `emit_char()` or
`emit_cp_folded()` has returned. What it holds is a function of the
codepoint, so the second occurrence of a codepoint can name the class the
first one made rather than make another.

The compiler now records, by class id, the codepoint each /i literal class
stands for, and `literal_class()` looks a codepoint up there before adding a
class. The record sits in the compiler's frame and dies with it. Zero marks
a class made by anything else, which U+0000 cannot be confused with, since
it has no case. Both literal paths, the ASCII letter in `emit_char()` and
the folded codepoint in `emit_cp_folded()`, fill the class only when it is
new.

The cap now counts distinct codepoints: a pattern that folds more than 256
different ones is still refused, which CRuby does not do. Bracket classes
and the shorthands keep taking a class per occurrence.
@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: f9e2c85d-73bc-441a-9e36-6a9b99e7875f

📥 Commits

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

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

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


📝 Walkthrough

Walkthrough

Regexp compilation now reuses case-insensitive literal character classes for ASCII and Unicode codepoints. The compiler enforces a 256-class limit. Regression tests cover repeated literals, scoped /i, Unicode folding, and overflow errors.

Changes

Case-insensitive regexp compilation

Layer / File(s) Summary
Compiler class cache and limit
mrbgems/mruby-regexp/src/re_compile.c
The compiler stores case-insensitive literal codepoints, centralizes the 256-class limit, and reuses or allocates literal classes through literal_class.
Literal emission and regression coverage
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp_syntax.rb, mrbgems/mruby-regexp/test/unicode_case.rb
ASCII and non-ASCII /i literals reuse cached classes. Tests cover repeated literals, scoped options, Unicode folding, matching limits, and RegexpError on overflow.

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

Merge Risk: ⚪ Minimal · up to 0665b

This change shares case-insensitive literal classes by codepoint, addressing excessive class allocation while preserving the existing cap behavior; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7183: Both change Unicode /i case folding and character-class reuse in re_compile.c.
  • mruby/mruby#7190: Both modify Unicode /i case-folding compilation and its tests.
  • mruby/mruby#7207: Both modify /i character-class compilation in re_compile.c.

Suggested labels: mrbgems

Suggested reviewers: matz

Sequence Diagram(s)

sequenceDiagram
  participant RegexpCompiler
  participant literal_class
  participant CharacterClassTable
  RegexpCompiler->>literal_class: lookup literal codepoint
  literal_class->>CharacterClassTable: search cached class
  CharacterClassTable-->>literal_class: existing class or no match
  literal_class->>CharacterClassTable: allocate class when absent
  literal_class-->>RegexpCompiler: class ID and reuse status
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: sharing character classes for case-insensitive literals.
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 7c9b238 into mruby:master Aug 18, 2026
21 checks passed
@takumin
takumin deleted the regexp-icase-literal-class-cache 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