Skip to content

mruby-regexp: skip free-spacing whitespace in the parser and leave the pre-pass with comments and escape widths - #7272

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-x-whitespace-parser
Aug 19, 2026
Merged

mruby-regexp: skip free-spacing whitespace in the parser and leave the pre-pass with comments and escape widths#7272
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-x-whitespace-parser

Conversation

@takumin

@takumin takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

preprocess_pattern() in mruby-regexp does three things before the parser reads the pattern: it removes (?#...) groups, it removes # comments under /x, and it removes whitespace under /x. CRuby does the first two in a pre-pass (re.c) and the third in the tokenizer, which skips whitespace only where it fetches a token and reads every token, an escape, a group name, an interval {n,m}, a (? opener, with the whitespace inside it in place. Doing all three by deleting bytes from the source means that whatever the deletion brings together is what the parser reads, and each kind of token boundary has needed a rule of its own: #7252 (inline x scoping) and #7268 (the (?:) written between a digit escape and a digit) each added one, and master still reads differently at every boundary without one:

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 reads `\k<ab>`)
Regexp.new("a\vb", Regexp::EXTENDED) =~ "ab"
# CRuby: nil (a vertical tab is not free-spacing whitespace), mruby: 0

The comment rule from #7268, that a removed comment does join the bytes on either side of it, is right for \1-\9 and wrong for the escapes CRuby's pre-pass has already read whole, and rewritten at a fixed width, by the time it reaches the comment:

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

The two layers

Each of mruby's two layers gets one of CRuby's two, so that no rule has to be added per boundary. Two commits, and a third with one test line.

Whitespace moves into the parser. 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 (?:) insertion goes with its esc_end and blank_out bookkeeping (the #7268 tests stay as they are and hold through the parser), and has_rewritten_group(), which decided whether the pass runs at all, is replaced by a memchr() for #: both things the pass still removes are spelled with one, and neither the /x flag nor a (?x) needs the pass any more.

The pre-pass keeps what re.c does, (?#...) under any flags and #... under /x, with the scope stack from #7252 saying where /x is on. Both stay in the pre-pass rather than moving to the parser because CRuby removes both before it reads escapes: \1#c, a newline and 0 is \10 there just as \1(?#c)0 is. skip_uninterpreted() steps over an escape at the width CRuby's pre-pass reads it: \u{...} to its brace, \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, written at full width (\x6 as \x06, \0 as \000) so that a removed comment cannot lengthen them; the rewrite buffer is twice the source for that. \1-\9 are the backslash and one digit, and the digits after them are plain bytes, so a removed comment joins them into \10 as it does in CRuby.

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.

Time

Wall clock, minimum of 9 alternating runs, -O3, default gembox plus mruby-benchmark: the path this PR changes, a Regexp.new under /x with and without a # and a /x literal, which is compiled on every turn.

x = Regexp::EXTENDED
xpat = "(?<n>\\d+) # the number\n\\s* (?<u>\\w+) # the unit\n"
100000.times { Regexp.new("a b c", x) }                 # new_x_nocomment
100000.times { Regexp.new("a # c\nb # d\nc", x) }       # new_x_comment
100000.times { Regexp.new(xpat, x) }                    # new_x_named
100000.times { Regexp.new("abc") }                      # new_plain
100000.times { Regexp.new("a(?#c)bc") }                 # new_comment_group
100000.times { "abc" =~ /a b c/x }                      # match_x_literal
100000.times { "42 px" =~ /(?<n>\d+) # the number
                           \s* (?<u>\w+) # the unit
                          /x }                          # match_x_comment
case master this PR
new_x_nocomment 75ms 63ms (-16%)
new_x_comment 77ms 77ms (+0%)
new_x_named 111ms 113ms (+2%)
new_plain 61ms 61ms (+0%)
new_comment_group 74ms 74ms (+0%)
match_x_literal 144ms 139ms (-3%)
match_x_comment 296ms 305ms (+3%)

A /x pattern without a # no longer runs the pass or takes its allocation, which is the -16%. With a # the pass still runs and now copies the whitespace through for the parser to skip, so the pattern's whitespace is walked twice instead of once; match_x_comment, whose literal carries about a hundred bytes of indentation, pays that second walk, +3%.

Size

.text of bin/mruby, build_config/ci/gcc-clang.rb, each side from a clean build directory at the same path. re_compile.o is the only object that changes and accounts for the whole delta.

build master this PR delta
full-debug (-O0) 1,880,918 1,880,886 -32
bintest 1,280,246 1,280,230 -16
cxx_abi 1,305,897 1,305,753 -144
byte-string 1,249,654 1,249,494 -160
ascii-ctype 1,268,102 1,268,038 -64

Verification

Two new blocks in mrbgems/mruby-regexp/test/regexp_syntax.rb, one per commit. The first covers whitespace between tokens (literals, groups, alternatives, anchors, quantifiers, lookarounds, an atomic group, a named group and its \k, a numbered backreference), the five bytes skipped and the vertical tab that is not, escaped blanks, and whitespace inside a token: ( ?i), (?i ), {1, 2}, { 2}, {2 }, a* ? against a*?, the \u, \x and \0 spellings CRuby rejects or reads short, and group names with a blank inside, declared and referenced, with \k <ab> as the letter and \k#c\n<ab> and \k(?#c)<ab> as references. The second covers a comment removed from inside \uXXXX, \u{...}, \x and \0, with and without /x, the short escapes written at full width (\x6(?#c)1, \0(?#c)61), and a class keeping the bytes of (?#c) as members. The block that checks that the pass and the named-group scan step over the same constructs now names the three readers (the pass, the scan, the parser) and says which rows each reads, with a # added to two of its class rows so that the pass still runs there. Every expected value was checked against CRuby 4.0.6 first. The third commit adds one line to the Regexp#to_s block in regexp.rb: a /x pattern with a # comment printed as (?x-mi:...) and read back through Regexp.new, the one spelling that goes through the comment pass and the inline x scope on a single compile.

Differential against CRuby 4.0.6, bintest build (MRB_UTF8_STRING) of master and of this PR. 100,000 random patterns of one to six tokens drawn from a, b, digits, the five whitespace bytes and a vertical tab, #c with and without a newline, (?#c), \x, \x6, \x61, \0, \06, \1, \10, \u, \u00, \u0061, \u{61}, \u{61 62}, \u{, \u{61, \k, \k<a>, \k<a b>, \k'a', (?<a>, (?<a b>, (?'a', (?'a b', (a), (, ), (?:, (?x), (?-x), (?x:, (?i), (?xi:, (?=, (?<=, (?>, [, ], [a b], [#], [\x6 1], {, }, {2}, {1, 2}, {1,2}, *, +, ?, |, \ , \\, ^, $, ., \b, \d, <, >, -, ', e, f; each compiled with or without Regexp::EXTENDED and matched against one of 35 subjects, compared as MatchData#to_a and Regexp#names. A pattern both sides refuse compares as equal whatever the message.

patterns
same on master and here 97,317
master differs from CRuby, this PR agrees 782
both differ from CRuby 1,889
master agrees with CRuby, this PR differs 12

Of the 782, 481 are patterns master refused as a quantifier with no target, having made an interval of a {1, 2} with nothing before it or removed a vertical tab before a quantifier, 293 are answers that change (a blank inside a name, \k <, a vertical tab, ( ?), and 8 are \u#c and \x#c under /x. Of the 1,889, 1,533 are a \1 that names no group, which this engine accepts and CRuby rejects, 160 are a repeat of a repeat, which this engine refuses and CRuby accepts, 151 are a [ inside a class, and 42 are answers that differ on master as well (an inline toggle before a |, a(?i)|b, which Onigmo scopes over the alternation and this engine does not; \b at a non-ASCII word character; {n}?, which Onigmo reads as an optional repeat); none is new. All 12 where only this PR differs are {1, 2} before a \1 with no group: master stopped at the interval, this PR reads it as CRuby does and reaches the \1.

rake test, build_config/ci/gcc-clang.rb, no compiler warning:

build total KO crash
full-debug 2,363 0 0
bintest 2,363 0 0
bintest (bintest suite) 123 0 0
cxx_abi 2,363 0 0
byte-string 2,292 0 0
ascii-ctype 2,359 0 0

The default configuration: 2,138 total, 0 KO, 0 crash, plus its 112 bintests.

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 mrbgems/mruby-regexp/src/re_compile.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 mrbgems/mruby-regexp/src/re_compile.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 mrbgems/mruby-regexp/src/re_compile.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 mrbgems/mruby-regexp/src/re_compile.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 mrbgems/mruby-regexp/src/re_compile.c
# ascii-ctype
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CTYPE -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 mrbgems/mruby-regexp/src/re_compile.c

Summary by CodeRabbit

  • Bug Fixes

    • Improved extended-mode regular expressions so whitespace is handled correctly between tokens without altering spaces inside intervals, group names, or Unicode codepoint lists.
    • Improved handling of comments, escapes, quantifiers, character classes, named groups, and scoped options.
    • Corrected jump and lookaround positioning after pattern processing.
    • Improved error messages to reference the original regular expression accurately.
    • Preserved source comments and newlines when recompiling extended regular expressions.
  • Tests

    • Added regression coverage for extended-mode parsing, escapes, Unicode patterns, POSIX brackets, character classes, and error reporting.

`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.
`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.
@coderabbitai

coderabbitai Bot commented Aug 19, 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: d224476c-784c-4f67-be25-f1d43b073d32

📥 Commits

Reviewing files that changed from the base of the PR and between 16eeb4e and de0488e.

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/test/regexp.rb

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The regexp compiler now skips /x whitespace during parsing, removes comments during preprocessing, preserves token boundaries, and handles numeric escape widths. Tests cover whitespace, comments, escapes, classes, named groups, POSIX brackets, Unicode lists, round trips, and diagnostics.

Changes

Regexp extended-mode parsing

Layer / File(s) Summary
Extended-mode token parsing
mrbgems/mruby-regexp/src/re_compile.c
The parser skips /x whitespace between tokens, including before quantifiers and sequence terminators. Diagnostics reference the original pattern.
Comment and escape preprocessing
mrbgems/mruby-regexp/src/re_compile.c
Preprocessing removes comments while preserving whitespace and token boundaries. Numeric escape scanning handles parser widths and zero-padding.
Syntax and escape regression coverage
mrbgems/mruby-regexp/test/regexp_syntax.rb, mrbgems/mruby-regexp/test/regexp_utf8.rb, mrbgems/mruby-regexp/test/regexp.rb
Tests cover /x tokenization, comments, escapes, character classes, named groups, POSIX brackets, Unicode lists, round trips, and error messages.

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

Merge Risk: ⚪ Minimal · up to de048

The change revises extended-mode regular-expression parsing and reports broad compatibility and regression testing with all listed suites passing; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

  • mruby/mruby#7213: Related code-index relocation fixes for jumps and lookaround endpoints.
  • mruby/mruby#7268: Related extended-mode escape preprocessing and numeric escape boundary handling.
  • mruby/mruby#7252: Related extended-mode whitespace and comment preprocessing changes.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes to free-spacing parsing, comment handling, and escape-width processing.
✨ 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.

…gexp#to_s`

`Regexp#to_s` prints an extended pattern as `(?x-mi:source)`, and the
`Regexp#to_s` block already checks that the form reads back as free-spacing
over the source it wraps. A `#` comment in the source rides along and is
still a comment inside the wrapper, so it is the one spelling of `/x` that
reaches the parser through both the comment pass and the inline `x` scope on
one compile. Check that `Regexp.new(re.to_s)` of such a pattern still matches
what `re` matched.
@matz
matz merged commit 48b986b into mruby:master Aug 19, 2026
21 checks passed
@takumin
takumin deleted the regexp-x-whitespace-parser branch August 19, 2026 02:44
matz added a commit that referenced this pull request Aug 19, 2026
The compiler applied one quantifier to an atom and left the next for
compile_seq(), whose guard against a quantifier with no atom then refused
the pattern: `a**`, `a+*` and `a{2}{3}` all raised where CRuby reads a
repeat of the repeat before it. Under `/x` the whitespace pass hid this by
gluing `a* ?` into the non-greedy `a*?`, so `\d+ ?` matched "1" of "123"
where CRuby matches "123"; since #7272 reads the whitespace in the parser
that shape reached the guard too, and was refused.

The quantifier is applied in a loop now, with the next one binding
everything emitted so far. Two spellings are not that and are read where
the first quantifier is, as CRuby reads them: a `?` after a greedy `*`,
`+`, `?` or a `{n,m}` written with a comma is the non-greedy marker, while
`{n}` has no non-greedy form and takes its `?` as a quantifier (`a{3}?`
matches empty, the lazy `a{3,3}?` does not); and a `+` after a greedy `*`,
`+` or `?` is possessive, `a*+` being `(?>a*)`, which is why `a?+` takes
one `a` out of "aa" where `(?:a?)+` takes two. After a lazy repeat, a
possessive one or a `{...}` a `+` is a quantifier again.

A repeat of a repeat is an empty-matching loop by construction, which the
recursion limit rather than a null check used to stop under the
backtracker; #7269 gave it Onigmo's, so both engines hold the shape now.

On a corpus of 1008 patterns stacking two quantifiers over six atoms,
master differs from CRuby 3.2.3 on 932 lines and this on none.

Co-authored-by: Claude <noreply@anthropic.com>
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