mruby-regexp: a byte that starts no character is a byte in a class - #7078
Merged
Conversation
📝 WalkthroughWalkthroughRegexp character classes now distinguish raw non-ASCII bytes from decoded Unicode codepoints. Compilation tags byte members, rejects mixed ranges, and excludes bytes from Unicode case folding. Both execution engines preserve this distinction. Tests and documentation cover the behavior. ChangesRegexp byte-oriented character classes
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Pattern
participant RegexpCompiler
participant PikeVM
participant BacktrackingEngine
participant class_match
Pattern->>RegexpCompiler: compile byte and character class members
RegexpCompiler->>class_match: store tagged ranges
PikeVM->>class_match: pass raw-byte classification
BacktrackingEngine->>class_match: pass raw-byte classification
class_match-->>PikeVM: return class membership
class_match-->>BacktrackingEngine: return class membership
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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 |
`read_class_atom()` handed back anything below 0xC0 without consulting the
decoder, so a byte that starts no whole character was the codepoint its number
spells once it was written inside `[...]`. The literal path settles the same
question with the decode itself: `emit_char_folded()` stands aside when the
decode consumed one byte, and `emit_char_bytes()` emits the byte. One pattern
therefore meant two things depending on which side of the brackets it was
written on.
```ruby
mu = "\xC2\xB5" # U+00B5 MICRO SIGN, two bytes
mu =~ Regexp.new("[\xB5]") # mruby: 0, CRuby: RegexpError
mu =~ Regexp.new("\xB5") # mruby: nil, CRuby: RegexpError
mu.gsub(Regexp.new("[\xB5]"), "!") # mruby: "!", CRuby: RegexpError
mu.gsub(Regexp.new("\xB5"), "!") # mruby: "\xC2\xB5", CRuby: RegexpError
```
The class read the pattern's lone `0xB5` as a character the pattern does not
spell, and ate both bytes of one that is not there. The literal read the same
byte as itself and found nothing.
Read the class atom through that same test, and record which of the two the
atom is. Byte members live in the range list the codepoints already use,
tagged with `RE_CLASS_BYTE`, and `class_match()` reads the tagged half when the
subject position holds a byte that starts no whole character, which is every
position of a byte-indexed subject. The tag is what carries the distinction:
over U+0080 to U+00FF the number alone cannot say whether the byte `0xB5` or
the character U+00B5 was written, since both arrive as 0xB5.
Case folding follows the same reading. Folding is for characters and a byte
that starts none has no case, so the closure walks step over the tagged ranges.
That retires a refusal a class could reach while holding no character at all:
the first range in `re_cased.h` is U+00B5 to U+017E, so on a build without
`MRB_REGEXP_UNICODE_CASE` the bytes `0xB5` to `0xBF` were refused inside a
class and none was refused as a literal. A byte range is how a continuation
byte gets spelled, so adding `/i` turned a working scan into a compile error.
```ruby
data = "\xC2\xB5A\xCE\xBC".b
data.scan(Regexp.new("[\x80-\xBF]")).size # 2
data.scan(Regexp.new("[\x80-\xBF]", Regexp::IGNORECASE)).size # was RegexpError
```
CRuby decides all of this with the pattern's encoding and raises
`RegexpError: invalid multibyte character` for a UTF-8 pattern holding such a
byte. This gem has no encoding to consult, so it reads the byte as the byte on
both sides instead. `\u` names a codepoint outright, so it is how a class
spells the character where the byte of the same number will not do:
`[\u{B5}]` holds U+00B5 and `[\xB5]` holds the byte.
Two spellings mean something else as a result. `[\xB5]` no longer matches
`"µ"`, and `[\x80-\xBF]` is a class of bytes rather than of U+0080 to U+00BF,
so it matches a byte-indexed subject where it used to match those characters.
A range whose ends are a byte and a character (`[\x80-µ]`, `[\u{B5}-\xBF]`)
names neither and is refused.
The existing overlong test asserts the same direction and still passes: a class
holding `[\xC0\xBC]` does not match `"<"`. The subject side of the same
collision was asserted by "an attempt in flight opens no match position inside
a character", where `[µ]` matched a lone `0xB5`; it now asks a class that holds
the byte, and asserts that `[µ]` does not.
takumin
force-pushed
the
regexp-class-stray-byte
branch
from
August 10, 2026 15:24
da15738 to
7c9f63b
Compare
This was referenced Aug 10, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A character class read a pattern byte that starts no whole character as the
codepoint its number spells, while the literal path read the same byte as
itself. The two halves of one pattern disagreed about what the pattern holds.
read_class_atom()returned anything below 0xC0 without consulting thedecoder, so a lone continuation byte was a codepoint. The literal path asks the
decoder:
emit_char_folded()stands aside when the decode consumed one byte,and
emit_char_bytes()emits the byte.The same split reached case folding. A class refuses a codepoint the build
cannot fold, and
0xB5was a codepoint to the class path, so the refusal firedon a pattern holding no character at all. The first range in
re_cased.hisU+00B5 to U+017E, so of the 64 continuation bytes,
0xB5to0xBFwererefused inside a class and none was refused as a literal. A byte range is how a
continuation byte gets spelled, so
/iturned a working scan into a compileerror:
With
MRB_REGEXP_UNICODE_CASEthe class folded instead, so the byte0xB5reached the two Greek letters U+00B5 pairs with, and a negated class turned
away a character the pattern says nothing about:
What this does
read_class_atom()now runs the decode the literal path runs and reportswhether the atom is a byte or a codepoint. Byte members live in the range list
the codepoints already use, tagged with
RE_CLASS_BYTE, andclass_match()reads the tagged half when the subject position holds a byte that starts no
whole character, which is every position of a byte-indexed subject. The tag
carries what the number cannot: over U+0080 to U+00FF the byte
0xB5and thecharacter U+00B5 both arrive as 0xB5.
Folding steps over the tagged ranges, because a byte that starts no character
has no case. That is what retires the
/irefusal and the folding above.CRuby settles the whole question with the pattern's encoding and raises
RegexpError: invalid multibyte characterfor a UTF-8 pattern holding such abyte, then compiles and matches bytes for the ASCII-8BIT spelling of it. This
gem has no encoding to consult, so it reads the byte as the byte on both sides,
which gives the two halves of a pattern one rule.
Behaviour changes
[\xB5]no longer matches"µ"(C2 B5), matching what\xB5has alwaysdone outside a class.
[\x80-\xBF]is a class of bytes rather than of U+0080 to U+00BF. It matchesa byte-indexed subject where it used to match those characters.
[\x80-µ],[\u{B5}-\xBF])names neither and raises
RegexpError./ino longer refuses a class of continuation bytes on a build withoutMRB_REGEXP_UNICODE_CASE, and no longer folds one on a build with it.Two existing tests cover this ground. "overlong UTF-8 is not the character it
spells" asserts the same direction and is unchanged:
[\xC0\xBC]does notmatch
"<". "an attempt in flight opens no match position inside a character"asserted the subject side of the same collision, where
[µ]matched a lone0xB5; it now asks a class that holds the byte, and asserts that[µ]doesnot match it.
Rebased onto #7074
This started out beside #7074, which touched the same lines, and is now rebased
onto it.
read_class_atom()reads(c, cc, &is_byte)andclass_add_member()carries the byte tag;
\upassesFALSE, since it names a codepoint outright.That gives the two escapes one member each and a way to ask for either:
A range still has to pick one, so
[\u{B5}-\xBF]is refused with the rest.Testing
rake testis green with and withoutMRB_REGEXP_UNICODE_CASE, onfull-corewith gcc on
x86_64-linux.A sweep over all 128 non-ASCII byte values, with and without
/i, againsttwelve subjects (decoded and byte-indexed, valid and invalid) now finds the
class and the literal spelling of each byte in agreement everywhere. Before
this change the same sweep found seven disagreements and, on a build without
MRB_REGEXP_UNICODE_CASE, refused 75 of the 128 bytes inside a class whilerefusing none as a literal.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation