mruby-regexp: skip free-spacing whitespace in the parser and leave the pre-pass with comments and escape widths - #7272
Conversation
`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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughThe regexp compiler now skips ChangesRegexp extended-mode parsing
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to 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
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 |
…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.
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>
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 (inlinexscoping) and #7268 (the(?:)written between a digit escape and a digit) each added one, and master still reads differently at every boundary without one:The comment rule from #7268, that a removed comment does join the bytes on either side of it, is right for
\1-\9and 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: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_EXTENDEDwas already carried inc->flagsand 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 ofcompile_seq(), before each atom and before the|or)that ends the sequence, and incompile_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 1is\x06and1because 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/xin CRuby and now here. The(?:)insertion goes with itsesc_endandblank_outbookkeeping (the #7268 tests stay as they are and hold through the parser), andhas_rewritten_group(), which decided whether the pass runs at all, is replaced by amemchr()for#: both things the pass still removes are spelled with one, and neither the/xflag nor a(?x)needs the pass any more.The pre-pass keeps what
re.cdoes,(?#...)under any flags and#...under/x, with the scope stack from #7252 saying where/xis 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 and0is\10there just as\1(?#c)0is.skip_uninterpreted()steps over an escape at the width CRuby's pre-pass reads it:\u{...}to its brace,\uXXXXand the next four bytes whatever they are,\xand its hex digits (with none, the one byte after, for the parser to reject),\0and its octal digits, written at full width (\x6as\x06,\0as\000) so that a removed comment cannot lengthen them; the rewrite buffer is twice the source for that.\1-\9are the backslash and one digit, and the digits after them are plain bytes, so a removed comment joins them into\10as it does in CRuby.One shape of pattern that compiled on master is refused now.
a* ?under/xwas read as the non-greedya*?once the blank was gone, anda{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,defaultgembox plusmruby-benchmark: the path this PR changes, aRegexp.newunder/xwith and without a#and a/xliteral, which is compiled on every turn.A
/xpattern 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
.textofbin/mruby,build_config/ci/gcc-clang.rb, each side from a clean build directory at the same path.re_compile.ois the only object that changes and accounts for the whole delta.full-debug(-O0)bintestcxx_abibyte-stringascii-ctypeVerification
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* ?againsta*?, the\u,\xand\0spellings 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{...},\xand\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 theRegexp#to_sblock inregexp.rb: a/xpattern with a#comment printed as(?x-mi:...)and read back throughRegexp.new, the one spelling that goes through the comment pass and the inlinexscope on a single compile.Differential against CRuby 4.0.6,
bintestbuild (MRB_UTF8_STRING) of master and of this PR. 100,000 random patterns of one to six tokens drawn froma,b, digits, the five whitespace bytes and a vertical tab,#cwith 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 withoutRegexp::EXTENDEDand matched against one of 35 subjects, compared asMatchData#to_aandRegexp#names. A pattern both sides refuse compares as equal whatever the message.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#cand\x#cunder/x. Of the 1,889, 1,533 are a\1that 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;\bat 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\1with 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:full-debugbintestbintest(bintest suite)cxx_abibyte-stringascii-ctypeThe default configuration: 2,138 total, 0 KO, 0 crash, plus its 112 bintests.
Environment
Machine, toolchain, and the compile line of every build
Actual compile line of
mrbgems/mruby-regexp/src/re_compile.cin eachbuild_config/ci/gcc-clang.rbbuild (-MMD -c,-I, and-odropped).full-debugis-O0becauseenable_debugappends-g3 -O0after the toolchain's-g -O3;cxx_abicompiles C as C++ withgcc -x c++ -std=gnu++03, g++ only links.Summary by CodeRabbit
Bug Fixes
Tests