Skip to content

mruby-regexp: read a digit escape as octal when it names no group - #7268

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-octal-escape-fallback
Aug 18, 2026
Merged

mruby-regexp: read a digit escape as octal when it names no group#7268
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-octal-escape-fallback

Conversation

@takumin

@takumin takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Outside a character class the parser took every \1 to \9 for a backreference and read the digits after it as literals, so an octal escape that does not start with 0 never matched: \101 compiled to a reference to group 1 followed by 01. The README's "\NNN octal, one to three digits" held only inside a class.

/\101/ =~ "A"                                          # CRuby: 0, mruby before: nil, after: 0
/\12/ =~ "\n"                                          # CRuby: 0, mruby before: nil, after: 0
/(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10/ =~ "abcdefghijj"   # CRuby: 0, mruby before: nil, after: 0
/\10(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)/ =~ "\babcdefghij"  # CRuby: 0, mruby before: nil, after: 0
/\81/ =~ "81"                                          # CRuby: 0, mruby before: nil, after: 0
Regexp.new('\400')                                     # CRuby: RegexpError, mruby before: /\400/, after: RegexpError

CRuby (Onigmo's fetch_token) reads the whole digit run as one decimal number first. The number is a backreference when it is at most 9 or at most the number of groups opened before it; otherwise the escape is the digit itself when it starts with 8 or 9, and an octal escape of up to three digits when not. The count is taken where the escape stands, so \10 after ten groups refers back and \10 before them is octal 010. Three octal digits can spell more than a byte, which CRuby refuses.

The fix

  • compile_atom() scans the digit run, compares it with the group count, and hands what is not a backreference to parse_escape(), which already reads \1-\7 as octal for the class path and returns 8 and 9 as themselves.
  • The count is a new num_groups, kept apart from num_captures because a named pattern demotes its plain groups: CRuby demotes them only once the parse is done, so while it reads the pattern they count. (?<n>a)(?<m>b)(c)(d)(e)(f)(g)(h)(i)(j)\10 is thus a numbered backreference, refused as such, where (?<n>a)\10 is octal. The refusal moves behind the comparison for the same reason.
  • parse_escape() refuses an octal value past 0xff with CRuby's message, invalid escape code, inside a class and out.
  • The free-spacing pass used to glue an escape spelled with digits to a digit after removed whitespace, \x1 2 reaching the parser as \x12; with the digit run now deciding between backreference and octal, (a)\1 0 would have turned into octal \10. preprocess_pattern() now writes an empty group (?:) where whitespace went out between such an escape and a hex digit, so the two stay apart as they do in CRuby, whose tokenizer stops at whitespace. A removed comment still joins them, as in CRuby, which strips comments before tokenizing; the line comment now takes its newline with it for that reason.
  • The README bullets for backreferences and octal escapes state the rule.

\1 in a pattern with no group is left as it was, a reference that never matches; CRuby raises for it, which is a separate matter. An octal escape at 0x80 or above names a byte, as \xHH does, where CRuby wants a whole character; that too is unchanged.

Testing

regexp_syntax.rb:

  • new block "a digit escape is a backreference or an octal escape by the group count": \101, \12, \100, \1234, \18, /\101/i, a quantifier on the byte, \303\244 against ä; \81 and \99; the same \10 after ten groups and before them, \11 after ten and after eleven; a named pattern with \101, \10, nine plain groups plus \10, and the two shapes that are refused by number; under /x, \10 1 and (a)\1 0 kept apart, (a)\1#c\n0 and (a)\1(?#c)0 joined
  • "octal and hex escapes": \377 compiles, \400 and [\400] are refused with the message
  • "extended mode (x flag)": \x1 2, \x1 a, \01 2, (a)\1 0 kept apart; the separator is no atom for a quantifier or a lookbehind; the error message still quotes the pattern as written

Full suite green at every commit (MRUBY_CONFIG=ci/gcc-clang rake -m test at each of the three, plus the default configuration at the tip):

build Total KO Crash
full-debug 2351 0 0
bintest (mrbtest) 2351 0 0
bintest (bintest) 123 0 0
cxx_abi 2351 0 0
byte-string 2281 0 0
ascii-case 2348 0 0
default (mrbtest) 2127 0 0
default (bintest) 112 0 0

The values in the Ruby blocks were checked against CRuby 4.0.6 and the full-core UTF-8 build before and after.

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-case
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CASE -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

  • Bug Fixes

    • Numeric escape sequences in regular expressions are now interpreted more consistently as backreferences, octal escapes, or literals based on context.
    • Octal escapes above 0xff are rejected instead of being silently truncated.
    • Extended-mode whitespace and comments now preserve escape-token boundaries correctly.
    • Improved handling of named groups, unmatched groups, quantifiers, and lookbehind widths.
  • Documentation

    • Clarified numeric backreference and octal escape syntax, including ambiguous cases involving \0, \8, and \9.

@takumin
takumin requested a review from matz as a code owner August 18, 2026 10:09
@coderabbitai

coderabbitai Bot commented Aug 18, 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: 94354c1b-e8a5-4ec9-9de2-14effc5c1120

📥 Commits

Reviewing files that changed from the base of the PR and between bacdc42 and b956bca.

📒 Files selected for processing (3)
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp_syntax.rb
🚧 Files skipped from review as they are similar to previous changes (2)
  • mrbgems/mruby-regexp/README.md
  • mrbgems/mruby-regexp/src/re_compile.c

Included review availability: Your plan includes up to 8 reviews per rolling hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The regexp compiler now parses numeric escape runs according to group context, rejects out-of-range octal values, and preserves escape boundaries during /x preprocessing. Documentation and regression tests cover the updated behavior.

Changes

Regexp numeric escape handling

Layer / File(s) Summary
Numeric escape resolution
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/README.md, mrbgems/mruby-regexp/test/regexp_syntax.rb
The compiler tracks opened groups and resolves digit runs as backreferences, octal escapes, or literal digits. Octal values above 0xff raise RegexpError. Documentation and tests cover group-sensitive parsing and fallback rules.
Extended-mode escape boundaries
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp_syntax.rb
The /x preprocessor tracks escape endpoints and removed whitespace or comments. It preserves token boundaries when whitespace would merge escape digits. Tests cover escape, quantifier, lookbehind, whitespace, and comment cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to b956b

The change corrects digit escape parsing and adds targeted regression coverage without any identified merge-blocking risk; it is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant RegexpSource
  participant ExtendedModePreprocessor
  participant RegexpCompiler
  participant CompiledRegexp
  RegexpSource->>ExtendedModePreprocessor: provide /x pattern
  ExtendedModePreprocessor->>RegexpCompiler: preserve escape boundaries
  RegexpCompiler->>RegexpCompiler: resolve digit run
  RegexpCompiler->>CompiledRegexp: emit validated backreference or octal escape
Loading

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 summarizes the main change: parsing digit escapes as octal when they do not reference a group.
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.

@takumin

takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@takumin

takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The free-spacing pass removes whitespace before the parser reads the
pattern, and removing it joined an escape spelled with digits to a digit
that followed: `\x1 2` reached the parser as `\x12`, one byte where CRuby,
whose tokenizer stops at the space, reads two.

```ruby
Regexp.new('\x1 2', Regexp::EXTENDED) =~ "\x012"  # CRuby: 0, mruby: nil
Regexp.new('\x1 2', Regexp::EXTENDED) =~ "\x12"   # CRuby: nil, mruby: 0
Regexp.new('\01 2', Regexp::EXTENDED) =~ "\x012"  # CRuby: 0, mruby: nil
```

`preprocess_pattern()` now remembers where the last `\N`, `\x` or `\u`
escape and the digits it took ended, and when whitespace went out between
that and a hex digit it writes an empty group `(?:)` in its place. The
group emits no instruction, so a quantifier after the digit still repeats
the digit and a lookbehind still measures a fixed width; error messages
quote the pattern as written, so the group never shows. The buffer grows
to twice the source to hold the separators.

A removed comment, `#...` to the end of its line or `(?#...)`, still joins
a `\N` escape to the digit after it, as CRuby does: it strips both kinds
of comment before it tokenizes, and `\1(?#c)0` is `\10` there. The line
comment now takes its newline with it, so that the newline does not count
as a blank between the two.
Outside a character class the parser took every `\1` to `\9` for a
backreference and read the digits after it as literals, so `\101` compiled
to a reference to group 1 followed by `01` and never matched, and `\12`
never matched a newline. The README's "`\NNN` octal, one to three digits"
held only inside a class.

```ruby
/\101/ =~ "A"                                          # CRuby: 0, mruby: nil
/\12/ =~ "\n"                                          # CRuby: 0, mruby: nil
/(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10/ =~ "abcdefghijj"   # CRuby: 0, mruby: nil
```

CRuby (Onigmo's `fetch_token`) reads the whole digit run as one decimal
number first. The number is a backreference when it is at most 9 or at
most the number of groups opened before it; otherwise the escape is the
digit itself when it starts with 8 or 9 (`\81` is `81`), and an octal
escape of up to three digits when not (`\101` is `A`, `\1234` is `S4`).
The count is taken where the escape stands: `\10` after ten groups refers
back, `\10` before them is octal 010.

The dispatcher now scans the run, compares, and hands what is not a
backreference to `parse_escape()`, which already reads `\1`-`\7` as octal
for the class path and returns `8` and `9` as themselves. The count it
compares with is a new `num_groups`, kept apart from `num_captures`
because a named pattern demotes its plain groups: CRuby demotes them only
once the parse is done, so while it reads the pattern they count, and
`(?<n>a)(?<m>b)(c)(d)(e)(f)(g)(h)(i)(j)\10` is a numbered backreference
there, refused as such, where `(?<n>a)\10` is octal. The refusal moves
behind the comparison for the same reason.

`\1` in a pattern with no group is left as it was, a reference that never
matches; CRuby raises for it, which is a separate matter.

The README bullets for backreferences and octal escapes now state the
rule.
Three octal digits spell up to 0777, and `parse_escape()` folded what was
past 0xff to its low byte, so `\400` and `[\400]` compiled to a NUL where
CRuby refuses them.

```ruby
Regexp.new('\400')    # CRuby: RegexpError (invalid escape code: /\400/), mruby: /\400/
Regexp.new('[\400]')  # CRuby: RegexpError (invalid escape code: /[\400]/), mruby: /[\400]/
```

The value is now checked once the digits are read, with CRuby's message,
inside a class and out. `\377` still names the byte 0xff.
@takumin
takumin force-pushed the regexp-octal-escape-fallback branch from bacdc42 to b956bca Compare August 18, 2026 13:08
@matz
matz merged commit 6fc1a69 into mruby:master Aug 18, 2026
21 checks passed
@takumin
takumin deleted the regexp-octal-escape-fallback branch August 18, 2026 14:54
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removed the whitespace of `/x` from the source before the parser read it, so whatever the removal brought together was what the parser read. CRuby does this in the other layer: its tokenizer skips whitespace only where it fetches a token, and reads each token, an escape, a group name, an interval `{n,m}`, a `(?` opener, with the whitespace inside it in place. Removing the bytes instead means every kind of token needs a rule of its own to keep the two apart, the `(?:)` written between a digit escape and a digit (mruby#7268) being one, and the tokens without one still read differently:

```ruby
Regexp.new("( ?i)A", Regexp::EXTENDED) =~ "a"
# CRuby: RegexpError (target of repeat operator is not specified), mruby: 0
Regexp.new("a{1, 2}", Regexp::EXTENDED) =~ "aa"
# CRuby: nil (`{1, 2}` is not an interval), mruby: 0
Regexp.new("(?<a b>x)", Regexp::EXTENDED).names
# CRuby: ["a b"], mruby: ["ab"]
Regexp.new("(?<ab>x)\\k <ab>", Regexp::EXTENDED) =~ "xk<ab>"
# CRuby: 0 (`\k` is the letter and `<ab>` a literal), mruby: nil (it read `\k<ab>`)
Regexp.new("a\vb", Regexp::EXTENDED) =~ "ab"
# CRuby: nil (a vertical tab is not free-spacing whitespace), mruby: 0
```

The parser now skips the whitespace where the tokenizer does. `RE_FLAG_EXTENDED` was already carried in `c->flags` and scoped by the save and restore every group does for `(?i)` and `(?m)`; `skip_extended_space()` reads it at the two places a token can end: at the top of `compile_seq()`, before each atom and before the `|` or `)` that ends the sequence, and in `compile_quantified()` between the atom and its quantifier. Nowhere else, so `{1, 2}` is not an interval, `( ?` is a group and then a `?`, a group name keeps its blanks, and a numeric escape reads what stands after it: `\x6 1` is `\x06` and `1` because the hex digits stop at the space, with nothing written between them. The bytes skipped are the five Onigmo skips; the vertical tab the pass also removed is a literal under `/x` in CRuby and now here.

The pass keeps what CRuby's own pre-pass (`re.c`) removes before its tokenizer runs, `(?#...)` groups under any flags and `#` comments under `/x`, with the scope stack from mruby#7252 saying where `/x` is on. Both stay in the pass rather than moving to the parser because a removed comment does join the bytes on either side of it in CRuby, `\1(?#c)0` and `\1#c`, a newline and `0` both being `\10`; the tests from mruby#7268 that pin this are unchanged. The `(?:)` insertion goes with its `esc_end` and `blank_out` bookkeeping, and with it the reason the rewrite buffer was twice the source. `has_rewritten_group()`, which decided whether the pass runs at all, is replaced by a `memchr()` for `#`: both things the pass removes are spelled with one, and neither the `/x` flag nor a `(?x)` needs the pass any more.

One pattern that compiled on master is refused now. `a* ?` under `/x` was read as the non-greedy `a*?` once the blank was gone; CRuby reads a `?` a blank away from the `*` as a repeat of the repeat, `(?:a*)?`, and this engine refuses a repeat of a repeat wherever it is written, `a**` included, so it refuses this one too rather than give it a meaning CRuby does not.
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removes `(?#...)` groups and, under `/x`, `#` comments, and copies the rest through, stepping over an escape as the backslash and one letter (`\u{...}` apart) so that a comment is not read out of one. Bytes it removed from inside a longer escape left the parser a different, valid escape. CRuby's pre-pass (`re.c`) has read the escape whole, and rejected it or written it at full width, before it reaches the comment:

```ruby
Regexp.new("\\u12(?#c)34") =~ "ሴ"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u1234`)
Regexp.new("\\u#c\n{61}", Regexp::EXTENDED) =~ "a"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u{61}`)
Regexp.new("\\x(?#c)61") =~ "a"
# CRuby: RegexpError (invalid hex escape), mruby: 0 (it read `\x61`)
Regexp.new("\\x6(?#c)1") =~ "a"
# CRuby: nil (`\x06` then `1`), mruby: 0 (it read `\x61`)
Regexp.new("\\0(?#c)61") =~ "1"
# CRuby: nil (`\0` then `61`), mruby: 0 (it read `\061`)
```

`skip_uninterpreted()` now steps over the escape at the width `re.c` reads it: `\u{...}` through its brace as before, `\uXXXX` and the next four bytes whatever they are, `\x` and its hex digits (with none, the one byte after, for the parser to reject), `\0` and its octal digits. What the pass copies is then what the parser reads, and `\u12(?` reaches `unicode_escape_first()` for it to reject. An escape that stops short of its full width (`\x6`, `\0`) is written at full width (`\x06`, `\000`) so that a digit the pass brings next cannot lengthen it; the rewrite buffer is twice the source again for that, the most an escape grows being `\0` to `\000`. `\1`-`\9` stay the backslash and the digit, and the digits after them plain bytes, as they are to `re.c`, so a removed comment joins them as it does there and the tests from mruby#7268 for `\1(?#c)0` still hold. `\p{...}` and `\g<...>` are not implemented by this engine, so there is nothing to protect.

Whitespace inside `\u{...}` (`\u{ 61 }`, `\u{61  62}`) is accepted by CRuby with or without `/x`, and it was already accepted here; the list is still copied through as one escape. Inside a character class the pass removes nothing, so `[\x6(?#c)1]` keeps the bytes of `(?#c)` as members and its escape as written.
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removed the whitespace of `/x` from the source before the parser read it, so whatever the removal brought together was what the parser read. CRuby does this in the other layer: its tokenizer skips whitespace only where it fetches a token, and reads each token, an escape, a group name, an interval `{n,m}`, a `(?` opener, with the whitespace inside it in place. Removing the bytes instead means every kind of token needs a rule of its own to keep the two apart, the `(?:)` written between a digit escape and a digit (mruby#7268) being one, and the tokens without one still read differently:

```ruby
Regexp.new("( ?i)A", Regexp::EXTENDED) =~ "a"
Regexp.new("a{1, 2}", Regexp::EXTENDED) =~ "aa"
Regexp.new("(?<a b>x)", Regexp::EXTENDED).names
Regexp.new("(?<ab>x)\\k <ab>", Regexp::EXTENDED) =~ "xk<ab>"
Regexp.new("a\vb", Regexp::EXTENDED) =~ "ab"
```

The parser now skips the whitespace where the tokenizer does. `RE_FLAG_EXTENDED` was already carried in `c->flags` and scoped by the save and restore every group does for `(?i)` and `(?m)`; `skip_extended_space()` reads it at the two places a token can end: at the top of `compile_seq()`, before each atom and before the `|` or `)` that ends the sequence, and in `compile_quantified()` between the atom and its quantifier. Nowhere else, so `{1, 2}` is not an interval, `( ?` is a group and then a `?`, a group name keeps its blanks, and a numeric escape reads what stands after it: `\x6 1` is `\x06` and `1` because the hex digits stop at the space, with nothing written between them. The bytes skipped are the five Onigmo skips; the vertical tab the pass also removed is a literal under `/x` in CRuby and now here.

The pass keeps what CRuby's own pre-pass (`re.c`) removes before its tokenizer runs, `(?#...)` groups under any flags and `#` comments under `/x`, with the scope stack from mruby#7252 saying where `/x` is on. Both stay in the pass rather than moving to the parser because a removed comment does join the bytes on either side of it in CRuby, `\1(?#c)0` and `\1#c`, a newline and `0` both being `\10`; the tests from mruby#7268 that pin this are unchanged. The `(?:)` insertion goes with its `esc_end` and `blank_out` bookkeeping, and with it the reason the rewrite buffer was twice the source. `has_rewritten_group()`, which decided whether the pass runs at all, is replaced by a `memchr()` for `#`: both things the pass removes are spelled with one, and neither the `/x` flag nor a `(?x)` needs the pass any more.

One shape of pattern that compiled on master is refused now. `a* ?` under `/x` was read as the non-greedy `a*?` once the blank was gone, and `a{2} ?` the same way; CRuby reads a `?` a blank away from the quantifier as a repeat of the repeat, `(?:a*)?`, and this engine refuses a repeat of a repeat wherever it is written, `a**` included, so it refuses these too rather than give them a meaning CRuby does not.
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removes `(?#...)` groups and, under `/x`, `#` comments, and copies the rest through, stepping over an escape as the backslash and one letter (`\u{...}` apart) so that a comment is not read out of one. Bytes it removed from inside a longer escape left the parser a different, valid escape. CRuby's pre-pass (`re.c`) has read the escape whole, and rejected it or written it at full width, before it reaches the comment:

```ruby
Regexp.new("\\u12(?#c)34") =~ "ሴ"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u1234`)
Regexp.new("\\u#c\n{61}", Regexp::EXTENDED) =~ "a"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u{61}`)
Regexp.new("\\x(?#c)61") =~ "a"
# CRuby: RegexpError (invalid hex escape), mruby: 0 (it read `\x61`)
Regexp.new("\\x6(?#c)1") =~ "a"
# CRuby: nil (`\x06` then `1`), mruby: 0 (it read `\x61`)
Regexp.new("\\0(?#c)61") =~ "1"
# CRuby: nil (`\0` then `61`), mruby: 0 (it read `\061`)
```

`skip_uninterpreted()` now steps over the escape at the width `re.c` reads it: `\u{...}` through its brace as before, `\uXXXX` and the next four bytes whatever they are, `\x` and its hex digits (with none, the one byte after, for the parser to reject), `\0` and its octal digits. What the pass copies is then what the parser reads, and `\u12(?` reaches `unicode_escape_first()` for it to reject. An escape that stops short of its full width (`\x6`, `\0`) is written at full width (`\x06`, `\000`) so that a digit the pass brings next cannot lengthen it; the rewrite buffer is twice the source again for that, the most an escape grows being `\0` to `\000`. `\1`-`\9` stay the backslash and the digit, and the digits after them plain bytes, as they are to `re.c`, so a removed comment joins them as it does there and the tests from mruby#7268 for `\1(?#c)0` still hold. `\p{...}` and `\g<...>` are not implemented by this engine, so there is nothing to protect.

Whitespace inside `\u{...}` (`\u{ 61 }`, `\u{61  62}`) is accepted by CRuby with or without `/x`, and it was already accepted here; the list is still copied through as one escape. Inside a character class the pass removes nothing, so `[\x6(?#c)1]` keeps the bytes of `(?#c)` as members and its escape as written.
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removed the whitespace of `/x` from the source before the parser read it, so whatever the removal brought together was what the parser read. CRuby does this in the other layer: its tokenizer skips whitespace only where it fetches a token, and reads each token, an escape, a group name, an interval `{n,m}`, a `(?` opener, with the whitespace inside it in place. Removing the bytes instead means every kind of token needs a rule of its own to keep the two apart, the `(?:)` written between a digit escape and a digit (mruby#7268) being one, and the tokens without one still read differently:

```ruby
Regexp.new("( ?i)A", Regexp::EXTENDED) =~ "a"
Regexp.new("a{1, 2}", Regexp::EXTENDED) =~ "aa"
Regexp.new("(?<a b>x)", Regexp::EXTENDED).names
Regexp.new("(?<ab>x)\\k <ab>", Regexp::EXTENDED) =~ "xk<ab>"
Regexp.new("a\vb", Regexp::EXTENDED) =~ "ab"
```

The parser now skips the whitespace where the tokenizer does. `RE_FLAG_EXTENDED` was already carried in `c->flags` and scoped by the save and restore every group does for `(?i)` and `(?m)`; `skip_extended_space()` reads it at the two places a token can end: at the top of `compile_seq()`, before each atom and before the `|` or `)` that ends the sequence, and in `compile_quantified()` between the atom and its quantifier. Nowhere else, so `{1, 2}` is not an interval, `( ?` is a group and then a `?`, a group name keeps its blanks, and a numeric escape reads what stands after it: `\x6 1` is `\x06` and `1` because the hex digits stop at the space, with nothing written between them. The bytes skipped are the five Onigmo skips; the vertical tab the pass also removed is a literal under `/x` in CRuby and now here.

The pass keeps what CRuby's own pre-pass (`re.c`) removes before its tokenizer runs, `(?#...)` groups under any flags and `#` comments under `/x`, with the scope stack from mruby#7252 saying where `/x` is on. Both stay in the pass rather than moving to the parser because a removed comment does join the bytes on either side of it in CRuby, `\1(?#c)0` and `\1#c`, a newline and `0` both being `\10`; the tests from mruby#7268 that pin this are unchanged. The `(?:)` insertion goes with its `esc_end` and `blank_out` bookkeeping, and with it the reason the rewrite buffer was twice the source. `has_rewritten_group()`, which decided whether the pass runs at all, is replaced by a `memchr()` for `#`: both things the pass removes are spelled with one, and neither the `/x` flag nor a `(?x)` needs the pass any more.

One shape of pattern that compiled on master is refused now. `a* ?` under `/x` was read as the non-greedy `a*?` once the blank was gone, and `a{2} ?` the same way; CRuby reads a `?` a blank away from the quantifier as a repeat of the repeat, `(?:a*)?`, and this engine refuses a repeat of a repeat wherever it is written, `a**` included, so it refuses these too rather than give them a meaning CRuby does not.
takumin added a commit to takumin/mruby that referenced this pull request Aug 19, 2026
`preprocess_pattern()` removes `(?#...)` groups and, under `/x`, `#` comments, and copies the rest through, stepping over an escape as the backslash and one letter (`\u{...}` apart) so that a comment is not read out of one. Bytes it removed from inside a longer escape left the parser a different, valid escape. CRuby's pre-pass (`re.c`) has read the escape whole, and rejected it or written it at full width, before it reaches the comment:

```ruby
Regexp.new("\\u12(?#c)34") =~ "ሴ"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u1234`)
Regexp.new("\\u#c\n{61}", Regexp::EXTENDED) =~ "a"
# CRuby: RegexpError (invalid Unicode escape), mruby: 0 (it read `\u{61}`)
Regexp.new("\\x(?#c)61") =~ "a"
# CRuby: RegexpError (invalid hex escape), mruby: 0 (it read `\x61`)
Regexp.new("\\x6(?#c)1") =~ "a"
# CRuby: nil (`\x06` then `1`), mruby: 0 (it read `\x61`)
Regexp.new("\\0(?#c)61") =~ "1"
# CRuby: nil (`\0` then `61`), mruby: 0 (it read `\061`)
```

`skip_uninterpreted()` now steps over the escape at the width `re.c` reads it: `\u{...}` through its brace as before, `\uXXXX` and the next four bytes whatever they are, `\x` and its hex digits (with none, the one byte after, for the parser to reject), `\0` and its octal digits. What the pass copies is then what the parser reads, and `\u12(?` reaches `unicode_escape_first()` for it to reject. An escape that stops short of its full width (`\x6`, `\0`) is written at full width (`\x06`, `\000`) so that a digit the pass brings next cannot lengthen it; the rewrite buffer is twice the source again for that, the most an escape grows being `\0` to `\000`. `\1`-`\9` stay the backslash and the digit, and the digits after them plain bytes, as they are to `re.c`, so a removed comment joins them as it does there and the tests from mruby#7268 for `\1(?#c)0` still hold. `\p{...}` and `\g<...>` are not implemented by this engine, so there is nothing to protect.

Whitespace inside `\u{...}` (`\u{ 61 }`, `\u{61  62}`) is accepted by CRuby with or without `/x`, and it was already accepted here; the list is still copied through as one escape. Inside a character class the pass removes nothing, so `[\x6(?#c)1]` keeps the bytes of `(?#c)` as members and its escape as written.
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