Skip to content

mruby-regexp: rewind a lookbehind by characters - #7087

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:lookbehind-character-rewind
Aug 10, 2026
Merged

mruby-regexp: rewind a lookbehind by characters#7087
matz merged 1 commit into
mruby:masterfrom
takumin:lookbehind-character-rewind

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7069, which stopped compute_fixed_len() from measuring a character
class that can match a non-ASCII codepoint. A lookbehind over one raises RegexpError
instead of rewinding into the middle of a character and answering wrongly. That traded
a wrong answer for an exception; it did not make the pattern work. These all raise
today and answer in CRuby:

"Āx" =~ /(?<=[Ā])x/      # CRuby: 1,   mruby: RegexpError
"Āb" =~ /(?<![Ā])b/      # CRuby: nil, mruby: RegexpError
"あx" =~ /(?<=[^あ])x/    # CRuby: nil, mruby: RegexpError
"aあx" =~ /(?<=a\W)x/     # CRuby: 2,   mruby: RegexpError
"ax" =~ /(?<=.)x/        # CRuby: 1,   mruby: RegexpError

The last row is not a class at all: RE_ANY was rejected by compute_fixed_len() for
the same reason, and the same change lets it through. With this patch every row
answers; the indices differ from CRuby only where this build counts bytes and CRuby
counts characters, so the first row answers 2 here and 1 there for the same position.

Why one count cannot do it

The count in re_inst.a is a byte count, and both lookbehind opcodes subtract it from
sp directly. A class has no fixed byte width, but it consumes exactly one character
whatever its members are, so a character count makes every class measurable. The
subject decides the unit, though, and the compiler does not know the subject:
bt_match() advances a binary subject one byte at a time and hands class_match()
the raw byte as its codepoint, so a byte count is right there, for a literal and for a
class alike. A character count is right for a class against a UTF-8 subject and wrong
for a multibyte literal, which is 2 bytes and 1 character. Storing only one of them
regresses the other:

$ ./build/host/bin/mruby -e 's = "Āx".b; p(s =~ /(?<=Ā)x/)'
2

(?<=Ā)x measures correctly today because 2 is a byte count. Make it a character
count and the same pattern rewinds 1 byte against that subject, landing on \x80,
and stops matching.

Fix

The lookbehind carries both numbers. a keeps the byte count, and a carrier
instruction, RE_LB_WIDTH, emitted right after the lookbehind holds the character
count; the sub-pattern body starts at pc + 2. The stream already holds logical units
spanning several 4-byte words, since a multibyte literal is a run of one-byte
RE_CHAR instructions forming one atom, so the carrier adds no new kind of shape.

  • compute_fixed_len() returns both counts from its one walk. The byte count stays
    one per consuming instruction, because a binary subject advances one byte whatever
    the instruction is. The character count adds one per class or RE_ANY and counts
    only lead bytes across an RE_CHAR run. The class_is_ascii_only() test and the
    RE_NCLASS and RE_ANY rejections all disappear; RE_SPLIT stays rejected, so
    (?<=ab|c) keeps raising as before.
  • The executor keeps sp - a for a binary subject and otherwise steps back
    code[pc + 1].a characters. The backward step is built on
    mrb_re_utf8_interior_p(), whose definition (a continuation byte no lead reaches
    is a character of its own) keeps the walk on the same boundaries the forward decode
    uses, broken input included. Running out of text keeps its current meaning: the
    positive form fails, the negative form succeeds.
  • The landing stays exact without new checks: the rewind walks that many character
    boundaries, and each atom of a fixed-length sub-pattern consumes exactly one whole
    character going forward, so RE_MATCH arrives back at sp by the same argument as
    the byte version.
  • The 255 limit stays a byte limit, and the character count never exceeds the byte
    count, so it fits the carrier's uint8_t.

Tests

The case from #7069 that pinned the raising patterns flips to real match assertions,
covering the class forms, the negated classes, the shorthands and (?<=.); the case
that pinned what must keep working is untouched. A new case pins binary subjects,
where the byte count is the one a character count would silently regress. rake test
is clean.

Summary by CodeRabbit

  • New Features

    • Improved regular-expression lookbehind support for character classes, shorthand classes, and multibyte characters.
    • Lookbehind now correctly handles both UTF-8 text and binary data.
  • Bug Fixes

    • Fixed lookbehind position tracking to rewind by character width for text and by byte count for binary subjects.
    • Improved behavior when lookbehind patterns exceed the available input.

mruby#7069 stopped `compute_fixed_len()` from measuring a character
class that can match a non-ASCII codepoint, so a lookbehind over one
raises `RegexpError` instead of rewinding into the middle of a character
and answering wrongly. That traded a wrong answer for an exception; it
did not make the pattern work. These all raised, and all answer in CRuby:

```ruby
"Āx" =~ /(?<=[Ā])x/      # CRuby: 1,   mruby: RegexpError
"Āb" =~ /(?<![Ā])b/      # CRuby: nil, mruby: RegexpError
"あx" =~ /(?<=[^あ])x/    # CRuby: nil, mruby: RegexpError
"aあx" =~ /(?<=a\W)x/     # CRuby: 2,   mruby: RegexpError
"ax" =~ /(?<=.)x/        # CRuby: 1,   mruby: RegexpError
```

The count in `re_inst.a` is a byte count, and both lookbehind opcodes
subtract it from `sp` directly. A class has no fixed byte width, but it
consumes exactly one character whatever its members are, so a character
count makes every class measurable, and `RE_ANY` with it.

One count cannot serve both subjects, though. `bt_match()` advances a
binary subject one byte at a time and hands `class_match()` the raw byte
as its codepoint, so against `"Āx".b` the stored byte count is exactly
right, and a character count would rewind `(?<=Ā)` one byte instead of
two. The compiler does not know the subject, so the opcode carries both:
`a` keeps the byte count, and a carrier instruction, `RE_LB_WIDTH`,
emitted right after it holds the character count, with the sub-pattern
body starting at `pc + 2`. The instruction stream already holds logical
units spanning several 4-byte words, since a multibyte literal is a run
of one-byte `RE_CHAR` instructions forming one atom.

`compute_fixed_len()` returns both counts from its one walk. The byte
count stays one per consuming instruction, because a binary subject
advances one byte whatever the instruction is. The character count adds
one per class or `RE_ANY` and counts only lead bytes across an `RE_CHAR`
run. The `class_is_ascii_only()` test and the `RE_NCLASS` and `RE_ANY`
rejections all disappear; `RE_SPLIT` stays rejected, so `(?<=ab|c)`
keeps raising as before.

The executor keeps `sp - a` for a binary subject and otherwise steps
back `code[pc + 1].a` characters. The backward step is built on
`mrb_re_utf8_interior_p()`, whose definition (a continuation byte no
lead reaches is a character of its own) keeps the walk on the same
boundaries the forward decode uses, broken input included. Running out
of text keeps its current meaning: the positive form fails, the negative
form succeeds. The 255 limit stays a byte limit, and the character count
never exceeds the byte count, so it fits the carrier's `uint8_t`.
@takumin
takumin requested a review from matz as a code owner August 10, 2026 17:36
@coderabbitai

coderabbitai Bot commented Aug 10, 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: 0d884aad-43de-401f-b8e1-9f09b0471304

📥 Commits

Reviewing files that changed from the base of the PR and between 7184392 and 3df2926.

📒 Files selected for processing (4)
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/re_exec.c
  • mrbgems/mruby-regexp/test/regexp.rb

📝 Walkthrough

Walkthrough

Lookbehind bytecode now stores byte and character widths. Compilation accepts multibyte character classes and wildcards. Execution rewinds by characters for UTF-8 subjects and by bytes for binary subjects. Tests cover both modes.

Changes

Lookbehind width handling

Layer / File(s) Summary
Compile lookbehind widths
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_compile.c
The regexp bytecode adds RE_LB_WIDTH. Compilation records both byte and character widths and accepts character classes and wildcards in lookbehind patterns.
Execute encoding-aware lookbehind
mrbgems/mruby-regexp/src/re_exec.c
Lookbehind execution rewinds by byte count for binary subjects and by character count for UTF-8 subjects. Matching starts after the width instruction.
Validate text and binary behavior
mrbgems/mruby-regexp/test/regexp.rb
Tests cover multibyte literals, classes, shorthands, repetitions, wildcards, and binary subjects.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RegexpCompiler
  participant RegexpBytecode
  participant LookbehindExecutor
  participant Subject
  RegexpCompiler->>RegexpCompiler: compute byte and character widths
  RegexpCompiler->>RegexpBytecode: emit RE_LB_WIDTH and lookbehind body
  LookbehindExecutor->>Subject: inspect binary or UTF-8 subject
  LookbehindExecutor->>LookbehindExecutor: rewind by bytes or characters
  LookbehindExecutor->>RegexpBytecode: execute lookbehind body from pc + 2
Loading

Possibly related PRs

  • mruby/mruby#7056: Both changes update multibyte character width handling in re_compile.c.
  • mruby/mruby#7059: Both changes update UTF-8 character-boundary handling in re_exec.c.
  • mruby/mruby#7069: Both changes update lookbehind width computation, execution, and related tests.

Suggested reviewers: matz, nattzn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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: updating lookbehind handling to rewind by character width.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants