mruby-regexp: classify a POSIX bracket by Unicode above ASCII - #7278
Conversation
`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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (19)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughAdds Unicode POSIX character classification to ChangesUnicode POSIX ctype support
Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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.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,\wand\sare ASCII in Ruby's syntax and stay so.The types
The types are the ones CRuby's engine gives the brackets:
alpha,upper,lowerDerivedCoreProperties.txtspacePropList.txtworddigit,punct,blank,cntrlUnicodeData.txtgeneral categoriesalnumgraph,printxdigit,asciitools/unicode/ctype_data.rbreads the files and spells each type as the properties it is the union of;mrbgems/mruby-regexp/tools/gen_ctype.rbpacks the answer intomrbgems/mruby-regexp/src/re_ctype.h, andrake unicode:generateruns it beside the case generators.PropList.txtandDerivedCoreProperties.txtjoin the filestools/unicode/ucd.rbnames 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).
cntrlhas 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_STRINGwithoutMRB_USE_ASCII_CTYPE, under aRE_UNICODE_CTYPEdefined besideRE_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; insteadre_charclasskeeps two masks, the types its positive brackets name (ctype_yes) and the types its negated ones name (ctype_no), andclass_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
/ia 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/ioncesis, through the ranges. The reading lives inre_utf8.c(mrb_re_class_ctype_match()), out of line ofclass_match(), so that the four sites the matcher is inlined into do not each carry it.Commits
tools: keep which database the tables read apart from what is read of it:tools/unicode/ucd.rbtakes the release, the files, their digests and the check out ofcase_data.rb, so a second reader can name the same database.rake unicode:verifyfinds the committed tables up to date.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:.textmaster.textthis PR.rodatamaster.rodatathis PRMRB_USE_ASCII_CTYPEmruby-encodingremoved)The read-only data is the 13,872 bytes of the table and 16 of alignment. The
.textof the classifying build ismrb_re_class_ctype_match()(431),mrb_re_ctype()(90), the bracket parserposix_class_bits()(+146), the masks incompile_charclass()(+67, inlined intocompile_seq()), the class matcher's call at its four sites (+256 inexec_range(), +80 inbt_match()) and the first-set walk (+8). TheMRB_USE_ASCII_CTYPEbuild's.textmoves 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 intocompile_charclass()and leaves that out ofcompile_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/iforupper,lower,alpha,alnum,word,punctandgraph: 63 patterns, each run byString#scanover 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 wasword, off by two until Join_Control (U+200C, U+200D) joined it.New test files, selected by
mrbgem.rakeon the same condition as the case pair:mrbgems/mruby-regexp/test/unicode_ctype.rbon 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/iclosure including the title case letter and the two ASCII-reaching foldings, and a byte-indexed subject) andmrbgems/mruby-regexp/test/ascii_ctype.rbon a build without it (every bracket holding nothing above ASCII and its negation everything). Every assertion inunicode_ctype.rbwas run under CRuby 4.0.6 as well and agrees. The[:word:]comments inregexp_syntax.rbsay what now depends on the build. README: the brackets under Pattern Syntax and a paragraph under Configuration;doc/guides/mrbconf.md: whatMRB_UTF8_STRINGandMRB_USE_ASCII_CTYPEnow cover.Full suite green at every commit.
ci/gcc-clangfull-debugci/gcc-clangbintestci/gcc-clangcxx_abici/gcc-clangbyte-stringci/gcc-clangascii-ctyperake -m test)Environment
Machine, toolchain, and the compile line of every build
Actual compile line of
src/string.cin eachbuild_config/ci/gcc-clang.rbbuild (-MMD -c,-I, and-odropped).full-debugis-O0becauseenable_debugappends-g3 -O0after the toolchain's-g -O3;cxx_abicompiles C as C++ withgcc -x c++ -std=gnu++03, g++ only links.Summary by CodeRabbit
New Features
Documentation
Tests