Skip to content

mruby-regexp: measure a lookbehind in the characters its bytes spell - #7125

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-lookbehind-char-width
Aug 12, 2026
Merged

mruby-regexp: measure a lookbehind in the characters its bytes spell#7125
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-lookbehind-char-width

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Problem

compute_fixed_len() measures a lookbehind twice: in bytes for a byte-indexed
subject, and in characters for a UTF-8 one. The character count came from the
lead bytes of the RE_CHAR run, so a byte matching 10xxxxxx counted as zero.

The executor rewinds by another rule. lookbehind_start() steps back through
mrb_re_utf8_interior_p(), which asks mrb_utf8_char_head(), and there a
continuation byte that no lead byte reaches is a character of its own, the rule
String indexing walks by too. A lookbehind over bytes that spell no character
therefore rewound too little and began the sub-pattern past its own text.

s = "\x80ab"
s =~ /\x80ab/                      # => 0, those bytes do match as a literal
s =~ /(?<=\x80a)b/                 # => nil, expected 2
s =~ /(?<!\x80a)b/                 # => 2, expected nil
"\xE3\x81ab" =~ /(?<=\xE3\x81a)b/  # => nil, expected 3

The negative form fails the other way around: it reports a match where the text
it refuses is present.

The byte count was right all along, so the same compiled pattern answered
differently depending on how the subject is indexed:

"\x80ab".b =~ Regexp.new("(?<=\x80a)b")          # => 2
"\xE3\x81ab".b =~ Regexp.new("(?<=\xE3\x81a)b")  # => 3

What CRuby says

CRuby (4.0.6) never reaches this measurement, because two earlier gates stop it:

Regexp.new("(?<=\x80a)b")   # RegexpError: invalid multibyte character
"\x80ab" =~ /b/             # ArgumentError: invalid byte sequence in UTF-8

The first refuses a pattern holding bytes that spell no character, the second a
subject holding them, whatever the pattern. The one configuration it does run is
binary on both sides, and there it answers exactly what the byte count here
answers:

"\x80ab".b =~ Regexp.new("(?<=\x80a)b".b)          # => 2
"\x80ab".b =~ Regexp.new("(?<!\x80a)b".b)          # => nil
"\xE3\x81ab".b =~ Regexp.new("(?<=\xE3\x81a)b".b)  # => 3

This engine carries no such refusal, so it answers where CRuby raises, and the
two units it measures the same lookbehind with have to agree on that answer.
They now do, on the value CRuby gives in the comparable configuration. The
control with a whole character agrees too: "\u{100}ab" =~ /(?<=\u{100}a)b/ is
2 in CRuby and on an MRB_UTF8_STRING build, and a byte-indexed build reports
its byte offset there as it does everywhere. Whether the refusal itself belongs
in this engine is a separate question, untouched here.

Fix

Gather the run of RE_CHAR instructions into a four byte window and step it
with mrb_utf8len(), which is what the rewind's mrb_utf8_char_head() agrees
with. Four bytes is the longest character there is, and a run never splits one
because emit_char_bytes() emits every byte of a character, so the window
reaches whatever character starts at the instruction. The inline
(inst.a & 0xC0) != 0x80 test goes away with it, and the byte count keeps
coming out of the same walk.

A pattern whose bytes spell whole characters is unaffected: the run still
advances one character per lead byte.

Tests

mrbgems/mruby-regexp/test/regexp_syntax.rb gains nine assertions beside the
other lookbehind width tests: a stray continuation byte, a sequence cut short,
the escaped and the raw spelling of the same pattern, the byte-indexed subject
that already answered correctly, and a whole multibyte character as the control.
Four of them fail before the change.

Fail: Regexp - lookbehind measures bytes that spell no character
 - Assertion[1]  Expected: 2 / Actual: nil
 - Assertion[2]  Expected 2 to be nil.
 - Assertion[4]  Expected: 3 / Actual: nil
 - Assertion[5]  Expected: 2 / Actual: nil

The offsets asserted are the ones both builds agree on, and the case involving a
whole multibyte character asserts the matched text instead of a position, so the
file needs no build guard.

rake test is green on the default build (2062 assertions, 105 bintests) and on
a full-core build, which carries MRB_UTF8_STRING (2255 assertions).

`compute_fixed_len()` counted the characters of a `RE_CHAR` run by the lead
bytes in it, so a byte matching 10xxxxxx was never one of them. The executor
rewinds by another rule: `lookbehind_start()` steps back through
`mrb_re_utf8_interior_p()`, which asks `mrb_utf8_char_head()`, and there a
continuation byte that no lead byte reaches is a character of its own. A
lookbehind holding such a byte rewound too little and began the sub-pattern
past the text it describes.

```ruby
s = "\x80ab"
s =~ /\x80ab/                      # => 0, those bytes do match as a literal
s =~ /(?<=\x80a)b/                 # => nil, expected 2
s =~ /(?<!\x80a)b/                 # => 2, expected nil
"\xE3\x81ab" =~ /(?<=\xE3\x81a)b/  # => nil, expected 3
```

CRuby never measures such a lookbehind, since it refuses both sides of the
case: the pattern with `RegexpError: invalid multibyte character`, and a
subject whose bytes spell no character with `ArgumentError: invalid byte
sequence in UTF-8`. Where it does run the case, binary on both sides, it
answers 2, nil and 3, which is what the byte count here has always answered
for the same bytes. This engine has no such refusal and answers rather than
raising, so its two units have to agree, and the character count is the one
that was wrong.

Gather the run into a four byte window and step it with `mrb_utf8len()`, the
function `mrb_utf8_char_head()` agrees with, rather than read the lead bit.
Four bytes is the longest character there is, and a run never splits one
because `emit_char_bytes()` emits every byte of a character, so the window
reaches whatever character starts at the instruction.
@takumin
takumin requested a review from matz as a code owner August 12, 2026 15:03
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Caution

Review failed

An error occurred during the review process. Please try again later.

✨ 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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants