Skip to content

mruby-regexp: classify a POSIX bracket by Unicode above ASCII - #7278

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:ascii-ctype-rename
Aug 19, 2026
Merged

mruby-regexp: classify a POSIX bracket by Unicode above ASCII#7278
matz merged 2 commits into
mruby:masterfrom
takumin:ascii-ctype-rename

Conversation

@takumin

@takumin takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The POSIX brackets held their ASCII members and no character above them, so on any text that is not ASCII both polarities answered wrongly: [[:alpha:]] missed every letter of every other script, and [[:^alpha:]] took them all.

"あ" =~ /[[:alpha:]]/          # CRuby: 0     mruby before: nil   mruby after: 0
"aあx" =~ /[[:^alpha:]]x/       # CRuby: nil   mruby before: 1     mruby after: nil
"123" =~ /[[:digit:]]+/      # CRuby: 0     mruby before: nil   mruby after: 0
"あ" =~ /[[:word:]]/           # CRuby: 0     mruby before: nil   mruby after: 0
"あ" =~ /\w/                   # CRuby: nil   mruby before: nil   mruby after: nil
"ā" =~ /[[:upper:]]/i          # CRuby: 0     mruby before: nil   mruby after: 0

A build reading its strings as characters knows that "あ" is one character; what it lacked was a table saying what kind. This PR carries that table in the gem, generated from the Unicode Character Database the case tables already come from, and reads it wherever a bracket is asked about a character above ASCII. \d, \w and \s are ASCII in Ruby's syntax and stay so.

The types

The types are the ones CRuby's engine gives the brackets:

Bracket Above ASCII Read from
alpha, upper, lower Alphabetic, Uppercase, Lowercase DerivedCoreProperties.txt
space White_Space PropList.txt
word Alphabetic, marks, decimal digits, connector punctuation, Join_Control the three files
digit, punct, blank, cntrl Nd, P*, Zs, Cc UnicodeData.txt general categories
alnum Alphabetic, Nd
graph, print assigned minus White_Space, Cc, Cs; that plus Zs
xdigit, ascii nothing: sets ASCII defines

tools/unicode/ctype_data.rb reads the files and spells each type as the properties it is the union of; mrbgems/mruby-regexp/tools/gen_ctype.rb packs the answer into mrbgems/mruby-regexp/src/re_ctype.h, and rake unicode:generate runs it beside the case generators. PropList.txt and DerivedCoreProperties.txt join the files tools/unicode/ucd.rb names and checksums; the tables are regenerated together and read one release between them.

The table

The types are held together rather than one range list each. Every codepoint has one set of answers, so the codepoint space above ASCII is cut into the 3,468 runs over which the set does not change, and a run is one 32-bit entry: the codepoint it starts at in the high 21 bits and the set in the low 11. One binary search answers all the types at once, and the runs are a fifth of what the types would take as separate lists (the letters alone are 759 ranges). cntrl has no bit: above ASCII it is the C1 controls and nothing else, which two numbers answer, and leaving it out is what lets the set fit beside a codepoint. ASCII is not in the table; the compiler sets those bits from the list it always had.

The table is compiled on the condition the case table is, MRB_UTF8_STRING without MRB_USE_ASCII_CTYPE, under a RE_UNICODE_CTYPE defined beside RE_UNICODE_CASE: a build that asked to leave the case table behind is counting its bytes and wants this one no more. Without it, and where strings are read as bytes, a bracket holds its ASCII and no character above it, and its negation everything above, which is what every build answered before.

In the class

A bracket does not spell its type out as members. [[:alpha:]] would put some 760 ranges into a class and have every character read through them one by one; instead re_charclass keeps two masks, the types its positive brackets name (ctype_yes) and the types its negated ones name (ctype_no), and class_match() reads the type of a character above ASCII once its ranges have said nothing. A character is in through a bit its type has, or a bit it lacks: [[:^alpha:][:^upper:]] holds "ā" and not "Ā". A byte that is no character, from a byte-indexed subject, has no type and is in through a negated bracket alone, which is what CRuby answers for an ASCII-8BIT subject.

Under /i a member the class holds by bit or by range is closed under folding at compile time as before, and a type is closed at match time instead: the type read is that of the character and of every character sharing its folding (mrb_uni_case_unfold()), so [[:upper:]] holds "ā" through "Ā" and "Dž" through "DŽ", and [[:^upper:]] holds "Ā" through "ā". The ASCII counterparts are left out of that reading, because the closure over the bitmap has already reached across the boundary from them: "ſ" is in [[:upper:]] under /i once s is, through the ranges. The reading lives in re_utf8.c (mrb_re_class_ctype_match()), out of line of class_match(), so that the four sites the matcher is inlined into do not each carry it.

Commits

  1. tools: keep which database the tables read apart from what is read of it: tools/unicode/ucd.rb takes the release, the files, their digests and the check out of case_data.rb, so a second reader can name the same database. rake unicode:verify finds the committed tables up to date.
  2. mruby-regexp: classify a POSIX bracket by Unicode above ASCII: the table and everything above.

Size

bin/mruby, full-core, gcc 13.3.0 -g -O3 (the toolchain default), size -A, master and this branch built at the same path:

Build .text master .text this PR .rodata master .rodata this PR
UTF-8, Unicode classification 1,282,294 1,283,382 (+1,088) 235,088 248,976 (+13,888)
UTF-8, MRB_USE_ASCII_CTYPE 1,272,742 1,272,726 (-16) 230,848 230,848 (+0)
bytes (mruby-encoding removed) 1,254,262 1,254,262 (+0) 227,808 227,808 (+0)

The read-only data is the 13,872 bytes of the table and 16 of alignment. The .text of the classifying build is mrb_re_class_ctype_match() (431), mrb_re_ctype() (90), the bracket parser posix_class_bits() (+146), the masks in compile_charclass() (+67, inlined into compile_seq()), the class matcher's call at its four sites (+256 in exec_range(), +80 in bt_match()) and the first-set walk (+8). The MRB_USE_ASCII_CTYPE build's .text moves without a byte of the change being compiled there: posix_class_bits() gained an argument it never writes on that build, and the compiler now inlines it into compile_charclass() and leaves that out of compile_seq(), where it had them the other way round. The byte build's sections are the same size as master's.

Testing

Every codepoint above ASCII against every bracket in both polarities ([[:x:]], [[:^x:]], [^[:x:]]), and under /i for upper, lower, alpha, alnum, word, punct and graph: 63 patterns, each run by String#scan over the whole codepoint space on this build and on CRuby 4.0.6 (Unicode 17.0.0), comparing the count and a checksum of the codepoints matched. All 63 agree. The one disagreement on the way was word, off by two until Join_Control (U+200C, U+200D) joined it.

New test files, selected by mrbgem.rake on the same condition as the case pair: mrbgems/mruby-regexp/test/unicode_ctype.rb on a build with the table (members and non-members of every bracket above ASCII in three polarities, runs, a lookbehind, brackets beside members and ranges, the /i closure including the title case letter and the two ASCII-reaching foldings, and a byte-indexed subject) and mrbgems/mruby-regexp/test/ascii_ctype.rb on a build without it (every bracket holding nothing above ASCII and its negation everything). Every assertion in unicode_ctype.rb was run under CRuby 4.0.6 as well and agrees. The [:word:] comments in regexp_syntax.rb say what now depends on the build. README: the brackets under Pattern Syntax and a paragraph under Configuration; doc/guides/mrbconf.md: what MRB_UTF8_STRING and MRB_USE_ASCII_CTYPE now cover.

Full suite green at every commit.

Build Total KO Crash
ci/gcc-clang full-debug 2383 0 0
ci/gcc-clang bintest 2383 (+ bintest 123) 0 0
ci/gcc-clang cxx_abi 2383 0 0
ci/gcc-clang byte-string 2309 0 0
ci/gcc-clang ascii-ctype 2376 0 0
default (rake -m test) 2155 (+ 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-ctype
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CTYPE -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

  • New Features

    • Regular expressions now support Unicode-aware POSIX character classes, including alpha, digit, word, space, punctuation, and related classes.
    • Case-insensitive matching works with Unicode POSIX classes when enabled.
    • ASCII-only configurations consistently restrict POSIX classes to ASCII characters.
  • Documentation

    • Updated configuration and regexp documentation to explain Unicode and ASCII behavior, supported classes, and build-specific limitations.
  • Tests

    • Added comprehensive coverage for Unicode classification, negation, case folding, indexing, and ASCII fallbacks.

`tools/unicode/case_data.rb` said two things: which release of the Unicode
Character Database the tables are generated from, with the digest of each
file, and what the case mappings are once the files are read. The first is
not about case. A generator reading the database for something else would
have to name the same release and check the same digests, and could only do
so by requiring the case reader.

Move the release, the files, their digests, where they are and how they are
checked into `tools/unicode/ucd.rb`, and leave `case_data.rb` the reading.
Every file is checked whichever table is being generated, since the tables
are regenerated together and are to read one release between them. Nothing
generated changes: `rake unicode:verify` finds the committed tables up to
date.
`[[:alpha:]]` held the ASCII letters and nothing above them, and its
negation held everything above them, so both polarities answered wrongly on
any text that is not ASCII: `"あ" =~ /[[:alpha:]]/` was nil and `"aあx" =~
/[[:^alpha:]]x/` was 1, where CRuby answers 0 and nil. A build reading its
strings as characters knows that "あ" is one character; what it lacked was
a table saying what kind.

Carry that table in the gem, generated from the Unicode Character Database
the case tables already come from, and read it wherever a bracket is asked
about a character above ASCII:

```ruby
"あ" =~ /[[:alpha:]]/          #=> 0
"aあx" =~ /[[:^alpha:]]x/       #=> nil
"123" =~ /[[:digit:]]+/      #=> 0
"あ" =~ /[[:word:]]/           #=> 0, where /\w/ stays nil as in CRuby
```

The types are the ones CRuby's engine gives the brackets: `alpha`, `upper`
and `lower` are the derived properties Alphabetic, Uppercase and Lowercase
of DerivedCoreProperties.txt, `space` is White_Space and the two joiners in
`word` are Join_Control, both of PropList.txt, and `digit`, `punct`, `blank`,
`cntrl`, the marks in `word` and the assigned codepoints under `graph` and
`print` are read off the general categories of UnicodeData.txt. `xdigit`
and `ascii` are sets ASCII defines and hold nothing above it. `\d`, `\w`
and `\s` are ASCII in Ruby's syntax and stay so.

### The table

`tools/unicode/ctype_data.rb` reads the three files and spells each type as
the properties it is the union of; `mrbgems/mruby-regexp/tools/gen_ctype.rb`
packs the answer into `re_ctype.h`, and `rake unicode:generate` runs it with
the others. The types are held together rather than one range list each:
every codepoint has one set of answers, so the codepoint space above ASCII
is cut into the 3,468 runs over which the set does not change, and a run is
one 32-bit entry, the codepoint it starts at in the high 21 bits and the
set in the low 11. One binary search answers all the types at once, and the
runs are a fifth of what the types would take as separate lists. `cntrl`
has no bit: above ASCII it is the C1 controls and nothing else, which two
numbers answer, and leaving it out is what lets the set fit beside a
codepoint. The table is compiled on the condition the case table is,
`MRB_UTF8_STRING` without `MRB_USE_ASCII_CTYPE`, since a build that asked
to leave that one behind is counting its bytes and wants this one no more.

### In the class

A bracket does not spell its type out as members. `[[:alpha:]]` would put
some 760 ranges into a class and have every character read through them one
by one; instead the class keeps two masks, the types its positive brackets
name and the types its negated ones name, and the matcher reads the type of
a character above ASCII once its ranges have said nothing. A character is
in through a bit its type has, or a bit it lacks: `[[:^alpha:][:^upper:]]`
holds "ā" and not "Ā". A byte that is no character, from a byte-indexed
subject, has no type and is in through a negated bracket alone, which is
what CRuby answers for an ASCII-8BIT subject.

Under `/i` a member the class holds by bit or by range is closed under
folding at compile time as before, and a type is closed at match time
instead: the type read is that of the character and of every character
sharing its folding, so `[[:upper:]]` holds "ā" through "Ā" and "Dž" through
"DŽ", and `[[:^upper:]]` holds "Ā" through "ā". The ASCII counterparts are
left out of that reading, because the closure over the bitmap has already
reached across the boundary from them: "ſ" is in `[[:upper:]]` under `/i`
once 's' is, through the ranges.

Without the table, and where strings are read as bytes, a bracket holds
its ASCII and no character above it, and its negation everything above,
which is what every build answered before.

### Size

`bin/mruby`, gcc 13.3.0 -O3, `full-core`, `size -A`, against master:

    UTF-8, Unicode classification    .text +1,088   .rodata +13,888
    UTF-8, MRB_USE_ASCII_CTYPE       .text    -16   .rodata      +0
    bytes                            .text     +0   .rodata      +0

The read-only data is the 13,872 bytes of the table and 16 of alignment. The
ASCII build's `.text` moves without a byte of the change being compiled
there: `posix_class_bits()` gained an argument it never writes on that build,
and the compiler now inlines it into `compile_charclass()` and leaves that
out of `compile_seq()`, where it had them the other way round.

### Verified

Every codepoint above ASCII against every bracket in both polarities, and
under `/i` for the cased ones, on this build and on CRuby 4.0.6 (Unicode
17.0.0): the 63 patterns match the same codepoints. `MRUBY_CONFIG=ci/gcc-clang
rake -m test`, all five builds and the bintests, KO 0 and Crash 0.
@coderabbitai

coderabbitai Bot commented Aug 19, 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: 928f2f21-b60a-49b9-91d6-b386e8c795c8

📥 Commits

Reviewing files that changed from the base of the PR and between addc03b and 55b6dea.

📒 Files selected for processing (19)
  • build_config/ci/gcc-clang.rb
  • doc/guides/mrbconf.md
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/mrbgem.rake
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/re_ctype.h
  • mrbgems/mruby-regexp/src/re_exec.c
  • mrbgems/mruby-regexp/src/re_utf8.c
  • mrbgems/mruby-regexp/test/ascii_ctype.rb
  • mrbgems/mruby-regexp/test/regexp_syntax.rb
  • mrbgems/mruby-regexp/test/unicode_ctype.rb
  • mrbgems/mruby-regexp/tools/gen_cased.rb
  • mrbgems/mruby-regexp/tools/gen_ctype.rb
  • tasks/unicode.rake
  • tools/gen_unicase.rb
  • tools/unicode/case_data.rb
  • tools/unicode/ctype_data.rb
  • tools/unicode/ucd.rb

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Adds Unicode POSIX character classification to mruby-regexp. The change adds Unicode data parsing and ctype table generation, integrates ctype metadata into regexp compilation and execution, updates build-dependent tests, and documents Unicode and ASCII behavior.

Changes

Unicode POSIX ctype support

Layer / File(s) Summary
Unicode data and ctype model
tools/unicode/ucd.rb, tools/unicode/case_data.rb, tools/unicode/ctype_data.rb
Centralizes Unicode data configuration and parsing. Adds POSIX character-type composition and range generation.
Unicode ctype table generation
tasks/unicode.rake, mrbgems/mruby-regexp/tools/gen_ctype.rb, mrbgems/mruby-regexp/tools/gen_cased.rb, tools/gen_unicase.rb
Splits regexp table generation into case and ctype tasks. Adds compressed Unicode ctype table generation.
Regexp ctype compilation and matching
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/src/re_exec.c, mrbgems/mruby-regexp/src/re_utf8.c
Adds ctype metadata, Unicode ctype lookup, negated-class handling, and /i folding during POSIX bracket matching.
Build behavior, tests, and documentation
mrbgems/mruby-regexp/mrbgem.rake, mrbgems/mruby-regexp/test/*, build_config/ci/gcc-clang.rb, doc/guides/mrbconf.md, mrbgems/mruby-regexp/README.md
Updates build-specific test selection, adds Unicode and ASCII ctype coverage, and documents POSIX bracket behavior.
Estimated code review effort: 4 (Complex) ~60 minutes

Merge Risk: ⚪ Minimal · up to 55b6d

The PR adds Unicode-aware POSIX bracket classification while preserving ASCII-only behavior for \d, \w, and \s; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7188: Updates the shared Unicode table-generation infrastructure used by this change.
  • mruby/mruby#7183: Adds related Unicode case-folding configuration and regexp support.
  • mruby/mruby#7265: Modifies related /i handling for regexp character classes.

Suggested reviewers: matz

Sequence Diagram(s)

sequenceDiagram
  participant Pattern as POSIX bracket pattern
  participant Compiler as re_compile.c
  participant CtypeTable as Unicode ctype table
  participant Executor as re_exec.c
  Pattern->>Compiler: Parse POSIX class
  Compiler->>CtypeTable: Store ctype metadata
  Compiler->>Executor: Pass compiled class
  Executor->>CtypeTable: Classify codepoint
  CtypeTable-->>Executor: Return ctype match
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: Unicode classification for POSIX brackets above ASCII 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.

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