mruby-regexp: read a digit escape as octal when it names no group - #7268
Conversation
|
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 (3)
🚧 Files skipped from review as they are similar to previous changes (2)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe regexp compiler now parses numeric escape runs according to group context, rejects out-of-range octal values, and preserves escape boundaries during ChangesRegexp numeric escape handling
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
|
@coderabbitai review |
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
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.
bacdc42 to
b956bca
Compare
`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.
`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.
`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.
`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.
Outside a character class the parser took every
\1to\9for a backreference and read the digits after it as literals, so an octal escape that does not start with0never matched:\101compiled to a reference to group 1 followed by01. The README's "\NNNoctal, one to three digits" held only inside a class.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\10after ten groups refers back and\10before 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 toparse_escape(), which already reads\1-\7as octal for the class path and returns8and9as themselves.num_groups, kept apart fromnum_capturesbecause 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)\10is thus a numbered backreference, refused as such, where(?<n>a)\10is 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.\x1 2reaching the parser as\x12; with the digit run now deciding between backreference and octal,(a)\1 0would 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.\1in 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\xHHdoes, where CRuby wants a whole character; that too is unchanged.Testing
regexp_syntax.rb:\101,\12,\100,\1234,\18,/\101/i, a quantifier on the byte,\303\244againstä;\81and\99; the same\10after ten groups and before them,\11after 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 1and(a)\1 0kept apart,(a)\1#c\n0and(a)\1(?#c)0joined\377compiles,\400and[\400]are refused with the message\x1 2,\x1 a,\01 2,(a)\1 0kept apart; the separator is no atom for a quantifier or a lookbehind; the error message still quotes the pattern as writtenFull suite green at every commit (
MRUBY_CONFIG=ci/gcc-clang rake -m testat each of the three, plus the default configuration at the tip):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
Actual compile line of
src/string.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
0xffare rejected instead of being silently truncated.Documentation
\0,\8, and\9.