Skip to content

mruby-regexp: share the pattern-skipping rules between the two prescans - #7165

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-share-pattern-skip-scan
Aug 14, 2026
Merged

mruby-regexp: share the pattern-skipping rules between the two prescans#7165
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-share-pattern-skip-scan

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Two scans read a regexp pattern before the parser does:

  • preprocess_pattern() removes (?#...) comment groups, and under /x also whitespace and # line comments;
  • has_named_group() answers whether the pattern declares a (?<name>...) group, which is what lets compile_atom() demote a plain (...) written before the declaration that demotes it.

Both are hunting for a (? opener, so both have to agree with the parser about when a ( is really an opener and not a byte hiding behind an escape or inside a character class. Each carried its own copy of those rules, and the copies had drifted apart.

Three rules matched: the escape pair, the class from [ through ] (with ^ and a leading literal ] handled as compile_charclass() handles them), and the POSIX bracket, whose ] must not close the class. The fourth did not. preprocess_pattern() treats \u{...} as one escape and copies the brace group whole, because the free-spacing pass would otherwise strip the spaces separating the list's codepoints and collapse \u{61 62} into the single codepoint \u{6162}. has_named_group() skipped \u as a plain two-byte escape and then walked into the braces.

This is observable, in diagnostics

The divergence cannot change whether a pattern compiles: a well-formed \u{...} list contains only hex digits and separator whitespace, so it can hide neither a class opener nor a (?<, and every pattern that exposes the difference is malformed and rejected either way. But it changes which error is reported. dont_capture is computed from has_named_group() before parsing begins, while the Unicode list is validated during parsing, so a (?< mistaken for a declaration switches on the demotion that rejects a numbered backreference earlier in the pattern, before the parser ever reaches the malformed list:

Regexp.new("\\1\\u{(?<a>")

before: numbered backref/call is not allowed. (use name): /\1\u{(?<a>/
after:  invalid Unicode list: /\1\u{(?<a>/
CRuby:  invalid Unicode list: /\1\u{(?<a>/

Measured by differential testing between a build of this branch and a build of its base, over patterns assembled from the tokens that exercise these rules (\u{, }, [, ], ^, (?<, (, ), a, space, \1, [:alpha:]), every sequence of one to four of them, each also with a \1 prefix, 43,356 distinct patterns:

  • 0 patterns change whether they compile.
  • 643 change which error is reported, in three message pairs, and in both directions.
  • Of those 643, the old code's message matched CRuby 4.0.6's on 1; the new one matches on 604. So unifying the walks moves the error reporting toward CRuby rather than away from it.

Every one of those patterns was already an error; no valid pattern is affected.

The free-spacing pass is untouched

preprocess_pattern() now copies verbatim the span the shared helper returns, which is by construction the same span the inline code copied. Differentially: over 6,990 distinct patterns built the same way under Regexp::EXTENDED from a token set extended with 61 62, # and a newline, and matching every pattern that compiled against twelve subjects, no row where either side compiled differs at all. The 39 rows that do differ are error-message changes of the kind above.

The change

This moves the rules into one helper, skip_uninterpreted(), which steps over whichever construct starts at the current byte and reports the class state back through an in_class flag the caller owns. preprocess_pattern() copies the returned span verbatim; has_named_group() jumps over it, gaining the \u{...} rule it had been missing.

has_comment_group() is deliberately left alone. It is a third walk, but a naive memchr prefilter that visits every ( and so returns TRUE iff the bytes (?# occur anywhere; its only possible error is a false positive costing one unnecessary preprocess_pattern() call. It is not a copy of these rules, and folding it in would only make the fast path slower.

Tests

The new rows are read by both walks at once: each places a (?< and a following plain group behind a construct the rules must cross. A rule dropped from the free-spacing pass strips a space it should have kept inside a class; the same rule dropped from the named-group scan turns the bracketed (?< into a phantom named group and demotes the plain group. A final row pins the diagnostic above, which is the one behaviour this change deliberately alters.

rake -m test: mrbtest Total: 2076, OK: 2047, KO: 0, Crash: 0, Warning: 0, Skip: 29 (2075 without this branch, so the block is the +1); bintest Total: 105, OK: 105, KO: 0. With re_compile.c alone reverted and the tests in place, that block fails, so it does test the change.

Summary by CodeRabbit

  • Bug Fixes

    • Improved regular expression parsing for escaped characters, character classes, POSIX bracket expressions, and Unicode escape lists.
    • Fixed extended-mode (/x) handling so spaces and patterns are interpreted consistently.
    • Improved detection of named capture groups in complex expressions.
    • Malformed Unicode escape lists now report errors more reliably.
  • Tests

    • Added regression coverage for these parsing scenarios and their error handling.

preprocess_pattern() and has_named_group() each walk the pattern before
the parser does, and each has to agree with the parser on where an escape
ends and where a character class ends, so that a '(' hidden behind one is
not read as a group opener. Both carried their own copy of those rules.

The copies had already drifted. preprocess_pattern() treats `\u{...}` as
a single escape, because the free-spacing pass would otherwise remove the
spaces separating the list's codepoints and join `\u{61 62}` into the one
codepoint `\u{6162}`; has_named_group() stepped over the two bytes of
`\u` and then read the brace group as ordinary pattern syntax.

That drift is observable. It cannot change whether a pattern compiles: a
well-formed list holds nothing but hex digits and separators, so it can
hide neither a class opener nor a "(?<", and every pattern that exposes
the difference is malformed and rejected on both sides. What it changes
is which error is reported. dont_capture is decided before parsing
starts, while the list is validated during it, so a "(?<" mistaken for a
declaration switches on the demotion that rejects a numbered
backreference earlier in the pattern, before the parser ever reaches the
malformed list:

    /\1\u{(?<a>/  was: numbered backref/call is not allowed. (use name)
                  now: invalid Unicode list, which is what CRuby reports

The message moves in both directions across the affected patterns, and
on balance toward CRuby rather than away from it.

Move the rules into skip_uninterpreted(), which steps over whichever of
them begins at the current byte and hands the class state back to its
caller. preprocess_pattern() copies the span it returns verbatim, which
leaves that walk a byte-for-byte no-op; has_named_group() jumps over the
span and so gains the `\u{...}` rule it had been missing.

The new test rows are read by both walks at once: each puts a "(?<" and
a plain group behind a construct the rules have to cross, so a rule lost
from the free-spacing pass strips a space it should have kept, and the
same rule lost from the named-group scan demotes the plain group. A last
row pins the diagnostic above, the one behaviour this commit changes.
@takumin
takumin requested a review from matz as a code owner August 14, 2026 08:43
@coderabbitai

coderabbitai Bot commented Aug 14, 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: c0231b16-f8bb-44fb-b878-31631f67cfc0

📥 Commits

Reviewing files that changed from the base of the PR and between 783e3b2 and d3354aa.

📒 Files selected for processing (2)
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp_syntax.rb

📝 Walkthrough

Walkthrough

The regexp compiler now shares scanning logic between /x preprocessing and named-group detection. The scanner handles escapes, character classes, POSIX brackets, leading literal ], and Unicode escape lists. Regression tests cover matching, captures, and malformed Unicode lists.

Changes

Regexp scanner synchronization

Layer / File(s) Summary
Shared uninterpreted scanning
mrbgems/mruby-regexp/src/re_compile.c
Added skip_uninterpreted() for escapes, character classes, POSIX brackets, leading literal ], and \u{...} lists. preprocess_pattern() now uses the shared scanner.
Named-group scan integration and tests
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp_syntax.rb
has_named_group() now uses the shared scanner. Tests compare extended and non-extended patterns, verify capture numbering, and check malformed Unicode-list errors.

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

Merge Risk: ⚪ Minimal · up to d3354

This change consolidates regexp pattern-skipping logic and adds focused coverage; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

  • mruby/mruby#7041: Provides related character-class scanning changes generalized by this PR.
  • mruby/mruby#7055: Modifies the same preprocess_pattern() scanning logic.
  • mruby/mruby#7057: Modifies related named-group scanning and character-class handling.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: sharing pattern-skipping rules between the two prescans.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matz
matz merged commit 04acb93 into mruby:master Aug 14, 2026
21 checks passed
@takumin
takumin deleted the regexp-share-pattern-skip-scan branch August 14, 2026 10:05
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