Skip to content

mruby-regexp: Unicode simple case folding for /i behind an option - #7058

Merged
matz merged 7 commits into
mruby:masterfrom
takumin:prototype-regexp-unicode-casefold
Aug 10, 2026
Merged

mruby-regexp: Unicode simple case folding for /i behind an option#7058
matz merged 7 commits into
mruby:masterfrom
takumin:prototype-regexp-unicode-casefold

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

This started as a question rather than a patch: should /i stay ASCII only?
It was answered in #7058 (comment).
/i may widen behind the option, and a build that does not define the option
should raise rather than fold ASCII and answer anyway. This is that
decision implemented.

The CI question is answered too, in
#7058 (comment), so the draft
is lifted.

Since the last revision

  • The CI job is gone. build_config/ci/unicode-case.rb and the Unicode-case
    job are deleted, and build_config/ci/gcc-clang.rb sets the define on its
    full-debug build instead, as asked. No new runner, and no change to
    .github/workflows/build.yml at all.
  • Putting it on one of the three builds in that file rather than a fourth
    keeps what you were willing to give up: full-debug runs
    test/unicode_case.rb, the other two builds keep the default and run
    test/ascii_case.rb, so both sides are covered by the one job.
  • Rebased onto master. mruby-regexp: make an escaped multibyte literal one atom #7066 landed while this sat, which made one atom
    rather than a lead byte followed by continuation bytes. That path reached
    neither the folding nor the refusal, so /\Ā/i quietly missed "ā" with the
    option and quietly compiled without it. Both spellings now go through one
    helper, emit_char_folded(), and both test files carry the escaped form.
  • The tables and the sweep below are measured again on the rebased branch, so
    they do not line up with the numbers in the previous revision.

What the two builds do

With MRB_REGEXP_UNICODE_CASE, /i folds the Unicode pairings:

/Ā/i.match?("ā")      # true
//i.match?("ā")     # true
/[Ā]/i.match?("ā")    # true
/[^Ā]/i.match?("ā")   # false
/(Ā)\1/i.match?("Āā") # true
/Σ/i.match?("σ")      # true
/[Σ]/i.match?("ς")    # true
/ß/i.match?("ẞ")      # true

Without it, a pattern that needs one of those does not compile:

/Ā/i    # RegexpError: /i needs MRB_REGEXP_UNICODE_CASE for this character
//i   # RegexpError: /i needs MRB_REGEXP_UNICODE_CASE for this character
/[^Ā]/i # RegexpError: /i needs MRB_REGEXP_UNICODE_CASE for this character class

Between the two builds the answer changes from correct to explicitly refused,
never from correct to wrong. Before this, [Ā] under /i missed "ā" and
[^Ā] accepted it, which is the same missing data with its sign flipped.

The test is whether a codepoint has a case folding, not whether it is
non-ASCII. A script without case has nothing to fold, so ASCII folding is the
whole of the right answer for it and both builds agree:

/日本/i.match?("日本")        # true
/です/i.match?("です")        # true
/العربية/i.match?("العربية")  # true
/😀/i.match?("😀")            # true

Two foldings are carried by every build rather than refused: U+017F folds to
"s" and U+212A to "k", the only two whose result is an ASCII letter.
Refusing them would mean refusing /k/i and /s/i, and answering without them
leaves the same false accept the refusal exists to prevent, so both builds
carry the two codepoints:

/k/i.match?("K")    # true in both builds
/[^k]/i.match?("K") # false in both builds

Folding "ASCII only" therefore covers the whole of the equivalence class an
ASCII letter belongs to rather than the part of it that happens to be ASCII.

What it costs

Unicode 17.0.0 has 1585 codepoints whose simple fold differs from themselves.
1483 pair with a single other codepoint, 26 of which are the ASCII letters
handled inline. Run-length encoding the 1483 sources by stride and delta
collapses them to 205 runs, 2460 bytes. The other 76 have no single
counterpart at all (U+FB00 to "ff") and are out of scope; CRuby is unsettled
around those, see https://bugs.ruby-lang.org/issues/17989 and
https://bugs.ruby-lang.org/issues/17990.

re_cased.h, the table a build without the option reads to decide what to
refuse, is 2982 cased codepoints as 32 coarse ranges, 256 bytes. Both headers
come from one pass over the same data, so they cannot drift into letting one
build refuse what the other would not have folded.

Measured on x86_64-linux, full-core, gcc, text plus rodata. What the option
costs, same configuration with and without the define:

object off on delta
re_compile.o 22167 23039 +872
re_exec.o 15650 15650 0
re_utf8.o 1206 4408 +3202
regexp.o 19639 19639 0
gem total 58662 62736 +4074
bin/mruby 1474278 1478338 +4060

What a build without the option pays, against master:

object master this delta
re_compile.o 20879 22167 +1288
re_exec.o 15474 15650 +176
re_utf8.o 792 1206 +414
gem total 56784 58662 +1878
bin/mruby 1472382 1474278 +1896

I want to be plain about the second table. Of the 414 bytes in re_utf8.o,
342 are the range table and the test that reads it, which is the refusal
itself. The rest is the two foldings that reach ASCII: 1288 bytes in
re_compile.o for reaching them from the class and the literal paths, and 176
in re_exec.o for memcmp_ci() comparing codepoints rather than bytes so a
backreference folds them too. Drop those two foldings and the cost falls back
to a few hundred bytes, at the price of [^k]/i accepting U+212A again. That
piece is isolated and I will take it out if that trade is preferred.

How it works

No new opcode and no change to any data structure.

emit_char_folded() emits a non-ASCII literal as a class rather than a run of
RE_CHAR bytes when /i is on and the character has a counterpart. This is
what lets a counterpart of a different width work at all: RE_CLASS decodes
one codepoint and compares that, so U+212A against /k/i is one comparison
rather than three bytes against one. A character with no counterpart, which is
most of the non-ASCII range, still emits as bytes and costs /i nothing. Both
spellings of a literal go through it, since a backslash before a multibyte
character has no escape meaning and /\Ā/i is /Ā/i.

compile_charclass() closes the class under folding: x belongs to it whenever
some written member folds the same way x does. That takes two rounds, the folds
of the members and then the sources of those, because a fold can have more than
one source (U+03A3 and U+03C2 both fold to U+03C3) and a class written with one
of them reaches the others only through the fold they share. Ranges are walked
run by run, so a wide range costs 205 intersections rather than its own length.

memcmp_ci() compares codepoints instead of bytes and reports how many bytes
it consumed, since a folded comparison need not consume as many as the captured
text holds.

Without the option, the same closure is restricted to the two foldings the
build has, and anything else in the codepoint list is refused before it is
reached. mrb_re_needs_case_data() compiles away entirely in the build that
has the data.

tools/gen_casefold.rb generates both headers from the host CRuby's Unicode
data, so the tables can be regenerated rather than hand-maintained.

Testing

test/unicode_case.rb and test/ascii_case.rb assert opposite things about
the same patterns, so mrbgem.rake gives each build only the one that belongs
to it. What /i does the same way in both builds is in test/regexp.rb and
always runs.

Verified on x86_64-linux:

  • rake test, the default build with the option off: 2002 OK, 0 KO, 0 crash,
    and bintest 105 OK.
  • The full-debug build of ci/gcc-clang with the define, which is the
    configuration this PR adds the option to and which carries MRB_GC_STRESS:
    2194 OK, 0 KO, 0 crash.
  • MRB_INT32 with clang and -Wall -Wextra, option off: 2079 OK, 0 KO, 0
    crash. Option on: 2078 OK, 0 KO, 0 crash. No warning from any file this
    branch touches either way; the four -Wunused-parameter warnings under the
    gem are in regexp.c, which this branch does not change.
  • Each of the four commits builds with and without the define.
  • Swept all 1483 pairs against CRuby 4.0.6 in seven forms each: literal in
    both directions, class, negated class, backreference, escaped literal, and
    the same pattern without /i. With the 76 sources that have no single
    counterpart and 18 uncased codepoints spanning CJK, kana, Arabic, Hangul,
    combining marks and emoji, that is 10757 answers per build.

Without the option, 9190 of those answers are refusals and nothing answers
differently from CRuby
except two: 1481 of the 1483 pairs are refused in
every /i form, and the two that answer, U+017F and U+212A, differ only in
the backreference form, where mruby matches a superset. Every form of the 76
sources with no single counterpart is refused. The uncased codepoints are
refused in no form and agree with CRuby everywhere.

With the option, nothing is refused and 36 of the 10757 answers differ, every
one of them mruby matching where CRuby does not: 34 in the backreference form,
where Onigmo declines to fold across a width change, and 2 in one literal
direction. Adding the escaped form to the sweep turned up no difference of its
own in either build, which is the point of routing both spellings through one
helper.

Left standing

With the option, /ß/i still does not match "ss", nor /ff/i match "ff".
Those need a fold that expands one character into several, which nothing here
has a place for. It is a missed match in every form, negated class included,
so no pattern answers the opposite of what it says. Without the option all 76
are refused. The README carries the error next to the option and the
backreference superset next to the other limitations.

Base

Rebased onto master at 0db635c. The branch carries only its own four
commits.

Summary by CodeRabbit

  • New Features

    • Added optional Unicode-aware case-insensitive regular expression matching with /i.
    • Unicode folding now works across literals, escaped characters, character classes, ranges, quantifiers, and backreferences.
    • Supports special case relationships such as Kelvin sign and long s.
    • Preserves ASCII-only behavior when Unicode case support is disabled.
  • Bug Fixes

    • Improved matching for characters with differing UTF-8 byte lengths.
  • Documentation

    • Documented configuration, supported behavior, limitations, and build requirements.
  • Tests

    • Expanded coverage for Unicode and ASCII-only regexp behavior.

@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: 98231f94-468d-46c7-b27c-aefdbb4fb668

📥 Commits

Reviewing files that changed from the base of the PR and between 1ff3550 and 70cfe2a.

📒 Files selected for processing (4)
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp.rb
  • mrbgems/mruby-regexp/tools/gen_casefold.rb
🚧 Files skipped from review as they are similar to previous changes (3)
  • mrbgems/mruby-regexp/tools/gen_casefold.rb
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c

📝 Walkthrough

Walkthrough

Adds optional Unicode case folding for mruby-regexp. The change adds generated Unicode tables, UTF-8 folding APIs, folded literal and class compilation, variable-width backreference matching, build-specific tests, CI configuration, and documentation.

Changes

Unicode regexp case folding

Layer / File(s) Summary
Folding data and internal contracts
mrbgems/mruby-regexp/tools/gen_casefold.rb, mrbgems/mruby-regexp/src/re_casefold.h, mrbgems/mruby-regexp/src/re_cased.h, mrbgems/mruby-regexp/include/re_internal.h
The generator creates compressed Unicode folding data and cased-codepoint ranges. Internal declarations expose folding, unfolding, range processing, and build-dependent case-data checks.
UTF-8 folding runtime
mrbgems/mruby-regexp/src/re_utf8.c
UTF-8 helpers perform Unicode folding, unfolding, binary-search lookup, and range processing.
Case-insensitive regexp compilation
mrbgems/mruby-regexp/src/re_compile.c
Character classes, ranges, literals, and escaped literals include supported Unicode fold counterparts. Unsupported folds remain rejected in ASCII-only builds.
Folded matching and build validation
mrbgems/mruby-regexp/src/re_exec.c, mrbgems/mruby-regexp/mrbgem.rake, mrbgems/mruby-regexp/test/*, build_config/ci/gcc-clang.rb, mrbgems/mruby-regexp/README.md
Backreferences compare folded characters with differing UTF-8 widths. Tests, build selection, CI configuration, and documentation cover both case-folding modes.

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

Sequence Diagram(s)

sequenceDiagram
  participant RegexpCompiler
  participant UTF8Folding
  participant UnicodeTables
  participant Matcher
  RegexpCompiler->>UTF8Folding: Fold literals and character-class ranges
  UTF8Folding->>UnicodeTables: Look up Unicode fold runs
  UnicodeTables-->>UTF8Folding: Return fold mappings
  UTF8Folding-->>RegexpCompiler: Return folded classes and literals
  Matcher->>UTF8Folding: Fold decoded backreference characters
  UTF8Folding-->>Matcher: Return folded value and consumed byte length
Loading

Possibly related PRs

  • mruby/mruby#7046: Extends related regexp backreference /i handling.
  • mruby/mruby#7049: Extends related character-class folding changes in re_compile.c.
  • mruby/mruby#7052: Covers related character-class range splitting across ASCII and non-ASCII boundaries.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.16% 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 identifies the main change: optional Unicode simple case folding for /i in mruby-regexp.
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.

@takumin
takumin force-pushed the prototype-regexp-unicode-casefold branch 2 times, most recently from 9a09a20 to af2c4b1 Compare August 9, 2026 16:54
@matz

matz commented Aug 9, 2026

Copy link
Copy Markdown
Member

Thank you for asking the question before the patch, and for saying plainly that you would rather be told to drop it. That framing is what made it easy to answer.

The answer

/i may widen, behind the option, and the default build should raise rather than fold ASCII-only.

So: your MRB_REGEXP_UNICODE_CASE stays as you built it, and when it is not defined, a pattern that would need a folding the build cannot do fails to compile.

The reason is the case you singled out. You wrote that the negated class is the one you found hardest to file under "ASCII only" rather than under "wrong", and I agree, but I would go further: it is not a separate defect. [^Ā] accepts ā because [Ā] never folded ā in, which is the same missing data as the four rows above it. The only difference is that the sign flips there, so the same gap surfaces as a false accept instead of a missed match. That means there is no version of this where the negated class is fixed and the rest is left as a documented limitation. Either the data is there or the answers are wrong, and a documented limitation should not be a licence to answer wrongly.

Raising is what makes the two builds differ in a way I can defend. Between them the answer changes from "correct" to "explicitly refused", never from "correct" to "wrong". A /i pattern that fails to compile is found the moment it is run; one that quietly reports the opposite of what was written is found much later, by someone who is not looking for it.

One thing the rejection must not be

Do not reject on "the pattern contains a byte above 127". That would break a large body of patterns that are correct today. I checked on current master:

/日本/i.match?("日本")        # true
/です/i.match?("です")        # true
/العربية/i.match?("العربية")  # true
/😀/i.match?("😀")            # true

Those are right, and they are right for a reason that survives the change: there is nothing to fold, so folding ASCII only is the whole of the correct answer. A script without case is not a limitation of the build.

The test is therefore "does this codepoint have a case folding", not "is this codepoint non-ASCII". You need less data for it than for the mapping: a list of the ranges that contain cased characters, not the foldings themselves. Latin supplements and extended, Greek, Cyrillic, Armenian, Georgian, the Latin and Greek extended additional blocks, letterlike symbols so that K is caught, fullwidth Latin, and the handful of cased blocks above the BMP. Twenty or thirty ranges, a few hundred bytes, against the 23 KB the full table costs. How coarse to make it is yours to choose; coarser means a few patterns are refused that ASCII-only folding would have handled correctly, which is a much better failure than the current one.

What I measured

Built your branch both ways, full-core, host debug build, so the absolute numbers carry symbols:

bin/mruby
default 10392560
MRB_REGEXP_UNICODE_CASE 10415736

23 KB when it is on and nothing when it is off, and the test file excludes itself from a build that does not define it. The run-length encoding is doing real work there; a flat mapping table would have been several times that.

On the cost to existing code

Patterns like /Ā/i that compile today will start raising. I do not think that is a reason to hold back: they were returning wrong answers, so this converts a silent wrong answer into a loud one, which is the direction I want. Worth a line in the README next to the option, so that someone who hits it knows immediately that the option is what they want.

@takumin

takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Done, and thank you for the answer. It is on the branch as three commits on top
of the one you measured. The branch is rebased onto master, which now carries
the character class boundary split, so that commit dropped out of it.

The refusal

re_cased.h holds 2982 cased codepoints as 32 coarse ranges, 256 bytes against
the 2460 the mapping costs. The blocks are the ones you listed. gen_casefold.rb
emits it in the same pass over the same data that emits the mapping, so the two
cannot drift into letting one build refuse what the other would not have folded.

The merge distance is the only knob, and I set it at 16. It pulls 309 uncased
codepoints into the ranges, which are refused along with the rest. Coarser was
on the table: 24 ranges cost 545. I stopped where I did because 16 keeps the
combining marks out. Only U+0345 among them is cased, and e followed by
U+0301 is a pattern someone writes.

The test is on the folding rather than the byte, so nothing above is what
decides these:

/日本/i.match?("日本")        # true
/です/i.match?("です")        # true
/العربية/i.match?("العربية")  # true
/😀/i.match?("😀")            # true

The one place I did not do it your way

Two foldings are carried by every build rather than refused: U+017F folds to
"s" and U+212A to "k", the only two whose result is an ASCII letter.

Refusing them was not open to me. A fold that reaches ASCII cannot be refused
from one side only, and the other side is /k/i and /s/i, which is the body
of correct patterns you told me not to break. Leaving them unfolded was not
open either, for the reason the refusal exists at all:

/k/i.match?("K")    # CRuby: true,  mruby before: false
/[^k]/i.match?("K") # CRuby: false, mruby before: true

That is the negated class again, in the build that has no data, on a pattern
made of nothing but ASCII. So folding "ASCII only" now covers the whole of the
equivalence class an ASCII letter belongs to rather than the part of it that
happens to be ASCII. The data for it is two codepoints.

It is also most of what a build without the option now pays. Of the 1855 bytes
on the gem, the range table and the test that reads it are 342; the rest is
reaching those two foldings from the class and the literal paths, and
memcmp_ci() comparing codepoints rather than bytes so a backreference folds
them too. If you would rather have the few hundred bytes and leave [^k]/i
accepting U+212A, that piece is isolated and I will take it out.

Two things the refusal turned up

Both are in the build that defines the option, both are the shape you refused
to leave standing, and each is its own commit.

A character class closed one hop from what was written rather than under
folding. A fold can have more than one source, and the class never reached the
sibling it shares that fold with:

/[Σ]/i.match?("ς")  # CRuby: true,  mruby before: false
/[^Σ]/i.match?("ς") # CRuby: false, mruby before: true

U+03A3 and U+03C2 both fold to U+03C3. Closing it properly takes two rounds,
the folds of the members and then the sources of those, so
mrb_re_case_unfold_range() splits into the two directions. The same commit
fixes the ordering half of it, which made [U+212A] under /i miss ASCII "K".

The table took a source only when its full fold was one codepoint, which threw
away sources whose fold is longer but which still have one codepoint to pair
with:

/ß/i.match?("ẞ")    # CRuby: true,  mruby before: false
/[^ß]/i.match?("ẞ") # CRuby: false, mruby before: true

U+1E9E folds to "ss" and lower cases to U+00DF, and the two fold alike, so
pairing them needs nothing the 1:1 machinery does not already do. That and the
27 Greek capitals with prosgegrammeni make 28 more pairs: 1483 sources in 205
runs, up from 1455 in 198.

What I measured

x86_64-linux, full-core, gcc, text plus rodata. What the option costs, same
configuration with and without the define:

object off on delta
re_compile.o 20457 21376 +919
re_exec.o 14535 14535 0
re_utf8.o 1126 4496 +3370
regexp.o 21985 21985 0
gem total 58103 62392 +4289
bin/mruby 1792389 1796689 +4300

Down from +5955 on the gem, because the compile side of the fold now serves
both builds rather than only the one that asks for the option.

What a build without the option pays, against master:

object master this delta
re_compile.o 19360 20457 +1097
re_exec.o 14231 14535 +304
re_utf8.o 672 1126 +454
gem total 56248 58103 +1855
bin/mruby 1790869 1792389 +1520

Not the few hundred bytes you sized the table at, and I want to be plain about
where the difference goes: it is the two ASCII reaching foldings, not the
refusal.

On your configuration, full-core host debug, bin/mruby goes 8045704 to
8052608 with the define, 6904 bytes.

What the sweep says

Every folding against CRuby 4.0.6 in six forms each: literal in both
directions, class, negated class, backreference, and the same pattern without
/i.

Without the option, 1481 of the 1483 pairs are refused in every form and two
answer, U+017F and U+212A. Nothing answers differently from CRuby except
those two in the backreference form, where mruby matches a superset. The 76
sources with no single counterpart are refused in every form. Eighteen uncased
codepoints spanning CJK, kana, Arabic, Hangul, combining marks and emoji are
refused in no form and agree with CRuby everywhere. So the change of answer is
correct to refused, never correct to wrong, which is what you asked for.

With the option, 36 of the 8898 answers differ, every one of them mruby
matching where CRuby does not: two in one literal direction and 34 in the
backreference form, where Onigmo declines to fold across a width change.

Left standing

With the option, /ß/i still does not match "ss", nor /ff/i match "ff". Those
need a fold that expands one character into several, which nothing here has a
place for and which CRuby is itself unsettled about, see
https://bugs.ruby-lang.org/issues/17989 and
https://bugs.ruby-lang.org/issues/17990. It is a missed match in every form,
negated class included, so no pattern answers the opposite of what it says.
Without the option all 76 are refused.

The README carries the error next to the option, as you asked, and the
backreference superset next to the other limitations.

Still open

The CI job is the question you did not answer, and it is the part of this that
reaches outside the gem: build_config/ci/unicode-case.rb and a job in
.github/workflows/build.yml. Without it the option ships untested, since
mrbgem.rake drops test/unicode_case.rb from a build that does not define it
and test/ascii_case.rb from one that does. Say the word and I will drop both
and leave the option to whoever turns it on.

@matz

matz commented Aug 10, 2026

Copy link
Copy Markdown
Member

Sorry for leaving the CI question unanswered. You were right to hold the draft on it rather than guess.

Keep the coverage, but put the option in an existing config rather than in a job of its own. CI should build with MRB_REGEXP_UNICODE_CASE set; the default build is what I have in front of me locally, so that is the side that does not need a runner.

Concretely: drop build_config/ci/unicode-case.rb and the Unicode-case job, and set the define on one of the builds already in build_config/ci/gcc-clang.rb. That file runs three MRuby::Builds in one job today, so a fourth costs no runner, or you can add the define to one of the existing three if that reads better to you. Either way test/unicode_case.rb gets compiled in and run on every push, which was the thing worth having.

What that gives up is test/ascii_case.rb, since mrbgem.rake drops it from a build that defines the option. I am content with that. The refusal path is a compile-time check against a static table, it fails loudly when it is wrong, and it is the configuration I build locally by default, so it gets exercised constantly on this machine. The folding path is the one with the generated table, the one that changes when Unicode moves, and the one nobody would notice going stale.

Everything else in your reply I am glad to take as written:

  • 256 bytes against 2460 for the mapping, one pass of gen_casefold.rb emitting both, so the refusal and the folding cannot drift apart. That last property is worth more than the size.
  • Merge distance 16, 309 uncased codepoints pulled in and refused. That is the trade I asked you to make, made explicitly and written down where the next reader will find it.
  • U+017F and U+212A left out because they fold into ASCII and every build can answer them. I went looking for exactly this as a gap before I read your header comment, so it is doing its job.
  • /ß/i and /ff/i unresolved because the fold expands, missing in every form including the negated class, and refused outright without the option. No pattern answers the opposite of what it says, which is the line I care about.
  • 36 differences with the option on, all mruby matching where CRuby does not, 34 of them the backreference width change. Recorded next to the other limitations is the right place for that.

I verified both builds here before answering: 2186 OK / 0 KO default, 2177 OK / 0 KO with the option, ASan and UBSan clean in both, and the thirteen rows I compared against CRuby agree in the configuration that claims to.

Mark it ready once the CI change is in and I will merge it.

The gem README documents ASCII-only folding as a limitation, and mruby core
carries no Unicode case data at all, so this is a scope change rather than a
bug fix. It is a draft for that reason: the question is whether the limitation
should stand, not whether this patch is ready.

Covers the 1:1 foldings. A source whose fold is several codepoints (U+00DF to
"ss", 104 sources) is left alone; CRuby itself is unsettled there.

`tools/gen_casefold.rb` generates `re_casefold.h` from the host CRuby's
Unicode data. Run-length encoding the 1455 non-ASCII sources by stride and
delta collapses them to 198 runs.

Three changes, no new opcode and no change to any data structure:

`emit_char_folded()` emits a non-ASCII literal as a class rather than as a run
of `RE_CHAR` bytes when `/i` is on. This is what lets a counterpart of a
different width work: `RE_CLASS` decodes one codepoint and compares that, so
U+212A against `/k/i` is one comparison rather than three bytes against one.
Both spellings of a literal go through it, since a backslash before a
multibyte character has no escape meaning and `/\Ā/i` is `/Ā/i`.

`compile_charclass()` folds the codepoint list the way it already folds the
bitmap. Ranges are walked run by run, so a wide range costs 198 intersections
rather than its own length.

`memcmp_ci()` compares codepoints instead of bytes and reports how many bytes
it consumed, since a folded comparison need not consume as many bytes as the
captured text holds.

Everything above sits behind `MRB_REGEXP_UNICODE_CASE`. The `full-debug` build
in `build_config/ci/gcc-clang.rb` defines it, because without a build that
does the tests never execute: the gem's `mrbgem.rake` drops
`test/unicode_case.rb` from a build that does not ask for the option, and
every assertion in it would fail otherwise. That build already runs in the
`gcc-clang` job, so the coverage costs no runner, and the other two builds in
the file keep the default.

Measured on x86_64-linux, text segment. What the option costs, same
configuration with and without the define:

    re_compile.o    18789 ->  20821   +2032
    re_exec.o       14383 ->  14567    +184
    re_utf8.o         528 ->   4299   +3771
    regexp.o        21969 ->  21969       0
    gem total       55669 ->  61656   +5987
    bin/mruby     1789525 -> 1795565   +6040

Of the 3771 bytes in `re_utf8.o`, 2376 are the table and 1619 the three lookup
functions. Packing the run struct from 12 bytes to 8 would take the table to
1584.

What a build without the option pays, against the parent commit:

    re_exec.o       14231 ->  14383    +152
    gem total       51549 ->  51701    +152
    bin/mruby     1698039 -> 1698199    +160

Those 152 bytes are `memcmp_ci()` keeping one signature across both builds
rather than two. Splitting the call site under `#ifdef` too would take it to
zero, at the cost of a third conditional in the middle of `RE_BACKREF`.

Swept all 1455 pairs against CRuby 4.0.6 in six forms each: literal in both
directions, class, negated class, backreference, and the same pattern without
`/i`. 1420 pairs agree exactly. The 35 that differ are exactly the pairs whose
UTF-8 length changes, and in every one mruby matches a superset of what CRuby
matches, never less: 33 differ only in the backreference form and 2 in one
literal direction, where Onigmo declines to fold across a width change. No
negated class matched in either implementation, and no pattern without `/i`
matched in either, so the fold leaks into neither.
A class under `/i` took the counterparts of what was written and stopped
there. A fold can have more than one source, so the class missed whatever it
could only have reached through the fold it shares with them:

```ruby
/[Σ]/i.match?("ς")  # CRuby: true,  mruby: false
/[^Σ]/i.match?("ς") # CRuby: false, mruby: true
```

U+03A3 and U+03C2 both fold to U+03C3. The class held U+03A3, the one hop
reported U+03C3, and U+03C2 was never asked for. The negated form is the same
gap with its sign flipped, so it accepts what it was written to reject.

Closing the class properly means: x belongs to it whenever some written member
folds the same way x does. That takes two rounds. The first adds the fold of
every member, the second adds every source of a member, and the members the
second round reads include what the first round put there. A third round finds
nothing, since whatever the second adds folds to something the first already
added.

`mrb_re_case_unfold_range()` walked both directions at once, which cannot
serve two rounds, so it splits into `mrb_re_case_fold_range()` for the folds
of the sources in a span and `mrb_re_case_unfold_range()` for the sources of
the folds in it. Neither is more work than the single walk was: each still
reads the table once, run by run.

The ordering half of the same defect was the ASCII closure running before the
Unicode pass rather than after it, so a counterpart landing in the bitmap
arrived too late for it:

```ruby
/[K]/i.match?("K")  # CRuby: true, mruby: false; pattern holds U+212A
```

The class held U+212A, the pass folded it to "k", and nothing then asked for
the other case of "k". Round two now walks the bitmap upwards and adds the
upper case letter as it goes, which is behind its own cursor and so is never
asked for sources of its own. That is correct rather than lucky: nothing folds
to an upper case letter.

Both are defects of the build that defines `MRB_REGEXP_UNICODE_CASE`. Without
it the class holds no non-ASCII member to walk and nothing changes, which is
also why the assertions here are in `test/unicode_case.rb`. The ordering half
has no home there yet: asserting it means writing U+212A against an ASCII
class, and that is a pattern both builds should agree on rather than one, so
it waits for the commit that gives the ASCII-only build the same folding and
lands in `test/regexp.rb` beside it.
The table took a source only when its full fold was one codepoint, which threw
away sources whose fold is longer but which still have one codepoint to pair
with. U+1E9E is the one that matters: it folds to "ss", so it was skipped, and
it lower cases to U+00DF, so pairing it needs nothing the 1:1 machinery does
not already do.

```ruby
/ß/i.match?("ẞ")    # CRuby: true,  mruby: false
/[^ß]/i.match?("ẞ") # CRuby: false, mruby: true
```

The negated form is the same gap with its sign flipped, which is the shape
that answers the opposite of what was written rather than merely missing.

`tools/gen_casefold.rb` now falls back to the simple lower case mapping when
the fold is longer than one codepoint, and takes it when it is a single
codepoint that folds the same way the source does. That adds 28 pairs: U+1E9E,
and the 27 Greek capitals with prosgegrammeni (U+1F88 to U+1FFC) that pair
with their small forms. The table goes from 1455 sources in 198 runs to 1483
in 205.

What stays out is a source with no single counterpart at all, 76 of them,
U+00DF and U+FB00 to "ff" among them. Matching those means expanding one
character into several, which no structure here has a place for and which
CRuby is itself unsettled about, see
https://bugs.ruby-lang.org/issues/17989 and
https://bugs.ruby-lang.org/issues/17990. They are missed matches in every
form, negated class included, so none of them answers the opposite of what it
says.

Only the build that defines `MRB_REGEXP_UNICODE_CASE` reads the table, so
nothing else changes.
A build without `MRB_REGEXP_UNICODE_CASE` used to fold ASCII and answer
anyway, so `/Ā/i` missed "ā" and, the same gap with its sign flipped, `[^Ā]/i`
accepted it. It raises `RegexpError` at compile time now instead. Between the
two builds the answer changes from correct to explicitly refused, never from
correct to wrong.

The test is whether a codepoint has a case folding, not whether it is
non-ASCII. A script without case has nothing to fold, so folding ASCII is the
whole of the right answer for it and these go on working:

```ruby
/日本/i.match?("日本")        # true
/です/i.match?("です")        # true
/العربية/i.match?("العربية")  # true
/😀/i.match?("😀")            # true
```

`re_cased.h` carries what the test needs: 2982 cased codepoints as 32 coarse
ranges, 256 bytes against the 2460 the mapping costs. Merging neighbours less
than 16 apart pulls 309 uncased codepoints into those ranges, and they are
refused as well. That costs a pattern ASCII folding would have answered
correctly, which is the better failure of the two. The generator emits it in
the same pass that emits the mapping, so the two cannot drift into letting
through a codepoint the other would have folded.

Two foldings are carried by every build rather than refused: U+017F folds to
"s" and U+212A to "k", the only two whose result is an ASCII letter. Refusing
a fold that reaches ASCII would mean refusing `/k/i` and `/s/i`, which breaks
a large body of patterns that are correct today. Answering without them is no
better, for the same reason the refusal exists at all:

```ruby
/k/i.match?("K")    # CRuby: true,  mruby before: false
/[^k]/i.match?("K") # CRuby: false, mruby before: true
```

So "ASCII case folding" now means the whole of the equivalence class an ASCII
letter belongs to rather than the part of it that happens to be ASCII.
`mrb_re_case_fold()` and `memcmp_ci()` are shared by both builds for that
reason, which is also what makes a backreference under `/i` fold. It is what
lets the class form of that pattern be asserted for both builds at once, and
with it the ordering the previous commit but one fixed.

`test/ascii_case.rb` holds the refusals and runs only in a build without the
option, mirroring `test/unicode_case.rb`. What `/i` does the same way in both
builds is in `test/regexp.rb` and always runs. The straddling range there
moves its upper bound to an uncased codepoint, since what `/i` does with a
range whose non-ASCII half has case is now one of the things the two builds
answer differently. The README documents the error next to the option, and the
backreference superset next to the other limitations.

Measured on x86_64-linux with full-core, gcc, text plus rodata.

What the option costs, same configuration with and without the define:

    re_compile.o    20457 ->  21376    +919
    re_exec.o       14535 ->  14535       0
    re_utf8.o        1126 ->   4496   +3370
    regexp.o        21985 ->  21985       0
    gem total       58103 ->  62392   +4289
    bin/mruby     1792389 -> 1796689   +4300

Down from +5955 on the gem, because the compile side of the fold now serves
both builds rather than only the one that asks for the option.

What a build without the option pays, against master:

    re_compile.o    19360 ->  20457   +1097
    re_exec.o       14231 ->  14535    +304
    re_utf8.o         672 ->   1126    +454
    gem total       56248 ->  58103   +1855
    bin/mruby     1790869 -> 1792389   +1520

Of the 454 bytes in `re_utf8.o`, 342 are the range table and the test that
reads it. The rest is the two foldings that reach ASCII: 1097 bytes in
`re_compile.o` for the refusal and for reaching them from the class and the
literal paths, and 304 in `re_exec.o` for `memcmp_ci()` comparing codepoints
rather than bytes, 152 of which the option already cost a build without it.

Swept every folding against CRuby 4.0.6 in six forms each: literal in both
directions, class, negated class, backreference, and the same pattern without
`/i`.

Without the option, 1481 of the 1483 pairs are refused in every form and two
answer, U+017F and U+212A. Nothing answers differently from CRuby except those
two in the backreference form, where mruby matches a superset. The 76 sources
with no single counterpart are refused in every form. Eighteen uncased
codepoints spanning CJK, kana, Arabic, Hangul, combining marks and emoji are
refused in no form and agree with CRuby everywhere.

With the option, 36 of the 8898 answers differ from CRuby, every one of them
mruby matching where CRuby does not: two in one literal direction and 34 in
the backreference form, where Onigmo declines to fold across a width change.
@takumin
takumin force-pushed the prototype-regexp-unicode-casefold branch from 844fae7 to 1ff3550 Compare August 10, 2026 12:43
@github-actions github-actions Bot removed the github label Aug 10, 2026
@takumin
takumin marked this pull request as ready for review August 10, 2026 12:43
@takumin
takumin requested a review from matz as a code owner August 10, 2026 12:43
@takumin

takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Thank you. The CI change is in and this is ready.

build_config/ci/unicode-case.rb and the Unicode-case job are gone, and the
full-debug build in build_config/ci/gcc-clang.rb defines
MRB_REGEXP_UNICODE_CASE instead. No new runner, and .github/workflows/build.yml
is untouched by this branch now.

One thing you offered to give up you do not have to. Putting the define on one
of the three builds rather than a fourth leaves the other two on the default,
so test/ascii_case.rb still runs in the same job that runs
test/unicode_case.rb. Both sides stay covered. I put it on full-debug
because that build also carries MRB_GC_STRESS, and the fold builds character
classes at compile time, which is the part worth stressing.

What the rebase turned up

#7066 landed while this sat, and it made one atom rather than a lead byte
followed by continuation bytes. That path reached neither the folding nor the
refusal:

//i.match?("ā")  # with the option: false, where /Ā/i answers true
//i              # without the option: compiled, where /Ā/i raises

Neither answers the opposite of what it says, but the second one is exactly
the silent narrowing the refusal exists to prevent, so it is not something to
leave standing. Both spellings now go through one helper,
emit_char_folded(), and compile_atom() calls it from the plain and the
escaped path alike. This is the only code in the branch you have not seen, so
it is the part worth a look.

Measured again

The size tables and the CRuby sweep in the description are re-run on the
rebased branch, so they no longer line up with the numbers you read before.
The sweep now has a seventh form, the escaped literal, which brings it to
10757 answers per build.

Without the option, 9190 of those are refusals, 1481 of the 1483 pairs are
refused in every /i form, and nothing answers differently from CRuby except
U+017F and U+212A in the backreference form, where mruby matches a superset.
With the option, nothing is refused and 36 answers differ, all of them mruby
matching where CRuby does not: 34 backreference and 2 in one literal
direction. The same 36 as before. Adding the escaped form turned up no
difference of its own in either build, which is what routing both spellings
through one helper is for.

rake test on the default build: 2002 OK, 0 KO, 0 crash, bintest 105 OK. The
full-debug build with the define: 2194 OK, 0 KO, 0 crash. MRB_INT32 with
clang and -Wall -Wextra: 2079 OK with the option off, 2078 with it on, 0 KO
either way. Each of the four commits builds with and without the define.

@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: 2

🧹 Nitpick comments (1)
mrbgems/mruby-regexp/tools/gen_casefold.rb (1)

83-84: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Require rbconfig before using RbConfig::CONFIG.

With ruby --disable-gems, RbConfig is undefined, so the generator raises NameError when it builds the generated header. Add require 'rbconfig' next to require 'set'.

🤖 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/tools/gen_casefold.rb` around lines 83 - 84, Add require
'rbconfig' alongside the existing require 'set' in gen_casefold.rb before the
generated-header code references RbConfig::CONFIG, ensuring the generator also
works with ruby --disable-gems.
🤖 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/include/re_internal.h`:
- Around line 180-186: Correct the contract described near the no-fold codepoint
check: it must not claim that every listed codepoint could be folded by a full
Unicode build, since multi-codepoint sources such as U+00DF and U+FB00–U+FB17
are only present as case-folding data. Either revise the comment to describe the
check as detecting available case-folding data, or update gen_casefold.rb so
no-counterpart sources are excluded from cased and both builds handle them
consistently.

In `@mrbgems/mruby-regexp/src/re_compile.c`:
- Around line 804-812: Pass the pattern’s binary state through mrb_re_compile
into emit_char_folded, and bypass Unicode folding for binary patterns or when
mrb_re_utf8_decode reports len == 1 for invalid/truncated UTF-8. In both cases,
emit the original byte via emit_char_bytes; retain existing folding behavior for
valid non-binary UTF-8 characters.

---

Nitpick comments:
In `@mrbgems/mruby-regexp/tools/gen_casefold.rb`:
- Around line 83-84: Add require 'rbconfig' alongside the existing require 'set'
in gen_casefold.rb before the generated-header code references RbConfig::CONFIG,
ensuring the generator also works with ruby --disable-gems.
🪄 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: 752784ee-0df0-4bca-9cef-bed6327dd34b

📥 Commits

Reviewing files that changed from the base of the PR and between b7e292e and 1ff3550.

📒 Files selected for processing (13)
  • build_config/ci/gcc-clang.rb
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/mrbgem.rake
  • mrbgems/mruby-regexp/src/re_cased.h
  • mrbgems/mruby-regexp/src/re_casefold.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/re_exec.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
  • mrbgems/mruby-regexp/tools/gen_casefold.rb

Comment thread mrbgems/mruby-regexp/include/re_internal.h Outdated
Comment thread mrbgems/mruby-regexp/src/re_compile.c
`emit_char_folded()` decoded every byte above 127 as UTF-8, and
`mrb_re_utf8_decode()` answers an invalid or truncated sequence with one byte
consumed and that byte's own value for the codepoint. A lone 0xB5 therefore
reached the folding path as U+00B5, a character the pattern does not hold:

```ruby
# with MRB_REGEXP_UNICODE_CASE
Regexp.new("\xB5", Regexp::IGNORECASE) =~ "µ"  # was 0, master: nil
# without it
Regexp.new("\xB5", Regexp::IGNORECASE)         # was RegexpError, master: compiles
```

The refusal is the same mistake read the other way round. U+00B5 is one of the
codepoints a build without the option cannot fold, so a pattern holding a byte
that is not that character at all failed to compile.

A byte that starts no whole character is not a character to fold. The literal
path emits those as bytes everywhere else, which is what `emit_char_bytes()`
is for, so a decode that consumed one byte falls back to it and both builds
answer what they answered before this branch.

The class path is left as it was. It reads such a byte as a codepoint, which
it did before this branch too: a class compares the decoded codepoint and a
literal compares bytes, so `[\xB5]` has matched "µ" for as long as classes
have decoded, and a build without the option refuses `[\xB5]` under `/i` for
the same reason it refuses `[µ]`.

The test is in `test/regexp.rb`, since the two builds agree on all of it.
The comment on `mrb_re_needs_case_data()` said it answers TRUE for a codepoint
this build cannot fold but a build with the table could. Two kinds inside it
fold in no build at all.

`gen_casefold.rb` records a codepoint as cased as soon as it has a folding,
before it knows whether that folding pairs it with a single counterpart, so
the 76 sources whose fold expands into several codepoints are in `re_cased.h`
as well. The coarse ranges then close over 309 uncased neighbours, and those
fold nowhere either.

Both kinds are refused by a build without the option and compiled by a build
with it, which matches them literally:

```ruby
/ff/i.match?("ff")  # RegexpError without the option, false with it
/ff/i.match?("ff")   # RegexpError without the option, true with it
```

So the two builds differ there in what they refuse rather than in what they
answer, which is the direction the refusal runs in everywhere else. The
generator keeps recording them and the comment says what the test is: having
the data, not being foldable. `re_cased.h` already describes itself that way.
`gen_casefold.rb` reads `RbConfig::CONFIG['UNICODE_VERSION']` for the header
comment it writes. `ruby --disable-gems` leaves `RbConfig` undefined, so the
generator raised `NameError` there, after it had opened `re_casefold.h` and
before it had written anything to either file.

Both headers regenerate byte for byte identical under `--disable-gems` with
the require in place.
@takumin

takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

The gen_casefold.rb nitpick from that review has no inline thread to answer in, so: fixed in 70cfe2a.

Reproduced first. ruby --disable-gems mrbgems/mruby-regexp/tools/gen_casefold.rb mrbgems/mruby-regexp/src raised uninitialized constant RbConfig (NameError) at the point where the header comment is built, after re_casefold.h had been opened for writing and before a byte had reached either file. With require 'rbconfig' in place both headers regenerate byte for byte identical under --disable-gems.

@matz
matz merged commit 3bc4b88 into mruby:master Aug 10, 2026
21 checks passed
@takumin
takumin deleted the prototype-regexp-unicode-casefold branch August 10, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants