Skip to content

mruby-regexp: stop capturing plain groups once a pattern has a named group - #7057

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-named-group-dont-capture
Aug 10, 2026
Merged

mruby-regexp: stop capturing plain groups once a pattern has a named group#7057
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-named-group-dont-capture

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

In CRuby, a single named group turns every plain (...) in the same pattern into a
non-capturing group and makes a numbered backreference a compile error. mruby-regexp
captures both kinds side by side, so a pattern that mixes them has a different number of
groups in the two implementations, and every numbered accessor disagrees.

md = /(?<a>a)(b)/.match("ab")
md.size          # CRuby: 2,            mruby: 3
md.to_a          # CRuby: ["ab", "a"],  mruby: ["ab", "a", "b"]
md.captures      # CRuby: ["a"],        mruby: ["a", "b"]
md[2]            # CRuby: nil,          mruby: "b"
md.begin(2)      # CRuby: IndexError (index 2 out of matches),  mruby: 1

"ab" =~ /(?<a>a)(b)/
$2               # CRuby: nil,  mruby: "b"
$+               # CRuby: "a",  mruby: "b"

"ab".sub(/(?<a>a)(b)/, '[\2]')   # CRuby: "[]",  mruby: "[b]"

Regexp.new("(a)(?<b>b)\\1")
# CRuby: RegexpError (numbered backref/call is not allowed. (use name))
# mruby: compiles, and \1 refers to (a)

Named access agrees in every case already: md[:a], md["a"], Regexp#named_captures,
Regexp#names and MatchData#names all report the same thing. Only the numbered side
differs.

The rule is Onigmo's ONIG_OPTION_DONT_CAPTURE_GROUP, which CRuby enables for a pattern
that declares at least one named group. It is not a corner case: (...) used purely for
grouping or alternation is common, and code carried over from CRuby silently sees its
capture numbers shift by however many plain groups the pattern has.

What this changes

Whether a plain group captures depends on a named group that may appear later in the
pattern, so mrb_re_compile() pre-scans for (?< before it starts allocating group
numbers. The scan reads the same bytes the parser will read, after the /x strip, so
free-spacing and comments are already gone. Three details it has to get right, each
checked against the parser and pinned by a test:

  • It skips escape pairs and character-class bodies, so /\(?/ and /[(?<]/ are not false
    positives. That skipping is shared with strip_extended() for the [:name:] case,
    which the first commit lifts into skip_posix_bracket().
  • It excludes (?<= and (?<!, which open a lookbehind rather than define a group.
    \k<name> is a reference, not a definition. (?'name'...) is not a spelling this gem
    accepts at all, so (?< is the only form to look for.
  • A truncated (?< still raises from the parser, as Regexp.new("(?<") asserts. The
    pre-scan counts those bytes as a named group, which is harmless, but it is not the
    thing that decides the error.

compile_atom() then demotes a plain group to non-capturing, and rejects a numbered
backreference in both its \1 and its \k<1> / \k<-1> spellings. The \k branch
matters as much as the \1 one: once plain groups stop consuming numbers, its absolute
bound and its relative num_captures - n would resolve to a different group instead of
erroring.

The only representation that changes is pat->num_captures, which shrinks for an
affected pattern. Every numbered accessor already derives from it, so regexp.c and
re_exec.c need no edit, and that is what makes the $2, $+, sub and md.begin(2)
rows above agree, down to the IndexError message.

The gem README gains a Named Captures section stating the rule, and the
\k<name> row its Pattern Syntax list was missing.

Alternative considered

Documenting the difference instead. mruby's behaviour was a superset: every pattern CRuby
accepts still matched the same text, and the only patterns that behaved differently were
the ones CRuby refuses to compile or whose numbered groups it discards. Keeping it costs
nothing at compile time. There is a precedent for pinning a deliberate gap as a test
rather than closing it (599856d, for the to_str pattern of String#match). What
argues the other way here is that this difference is silent and changes results rather
than raising, so I went with following CRuby. Happy to turn it into a documented
difference instead if you prefer.

Out of scope

Two adjacent gaps on the same patterns are left alone, since neither follows from the
capture-numbering rule:

  • CRuby also blanks a numbered \1 in a replacement string when the pattern has named
    captures, even though md[1] still answers. After this change "ab".sub(/(?<a>a)b/, '[\1]')
    is "[a]" here against "[]" in CRuby.
  • \k<name> in a replacement string is not supported by this gem; it stays literal.

Testing

rake test passes. No existing test mixed a named group with a plain capturing group, so
nothing in the suite had to change. New coverage in mrbgems/mruby-regexp/test/regexp.rb
takes the table above row by row, plus the lookbehind, escaped, character-class,
[:alpha:], /x and truncated (?< cases, and the three rejected backreference
spellings with their messages.

Summary by CodeRabbit

  • Bug Fixes

    • Improved regular expression handling for patterns containing named groups.
    • Unnamed groups are now treated as non-capturing when named groups are present.
    • Numeric backreferences are correctly rejected in these patterns.
    • Improved safety when parsing incomplete POSIX character classes in extended mode.
  • Documentation

    • Added guidance and examples for named captures and named backreferences, including numbering rules and error behavior.
  • Tests

    • Added regression coverage for named-group semantics, backreferences, capture behavior, and truncated syntax.

@takumin
takumin requested a review from matz as a code owner August 9, 2026 15:52
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The regexp compiler detects named groups before parsing, makes unnamed groups non-capturing in named-group patterns, rejects numeric backreferences, and shares POSIX bracket parsing. Tests and documentation cover these semantics.

Changes

Named group semantics

Layer / File(s) Summary
Pattern preprocessing and named-group detection
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
Shared POSIX bracket parsing supports extended-mode preprocessing and named-group detection. The scanner ignores escaped text, character classes, POSIX brackets, and lookbehinds.
Capture and backreference rules
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
The compiler initializes named-group mode before parsing. Unnamed groups become non-capturing, named groups remain numbered, and numeric backreferences are rejected.
Named-group validation and documentation
mrbgems/mruby-regexp/test/regexp.rb, mrbgems/mruby-regexp/README.md
Tests cover capture APIs, match globals, malformed patterns, truncated POSIX classes, and numeric backreference errors. The README documents named captures and backreferences.

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

Sequence Diagram(s)

sequenceDiagram
  participant Pattern
  participant ExtendedModePreprocessor
  participant NamedGroupScanner
  participant RegexpCompiler
  participant BackreferenceParser
  Pattern->>ExtendedModePreprocessor: preprocess extended-mode syntax
  ExtendedModePreprocessor->>NamedGroupScanner: provide preprocessed pattern
  NamedGroupScanner->>RegexpCompiler: report named-group mode
  RegexpCompiler->>RegexpCompiler: demote unnamed groups
  RegexpCompiler->>BackreferenceParser: compile backreferences
  BackreferenceParser-->>RegexpCompiler: reject numeric forms in named-group patterns
Loading

Possibly related PRs

  • mruby/mruby#7019: Both modify numeric backreference parsing and validation.
  • mruby/mruby#7021: Both modify named-group and named-backreference parsing and add regression tests.
  • mruby/mruby#7041: Both modify POSIX bracket parsing and bounds handling in re_compile.c.

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 primary change: plain groups stop capturing when the pattern contains a named group.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
mrbgems/mruby-regexp/test/regexp.rb (1)

1132-1166: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test a plain group before the named group.

The pre-scan is required when a plain group appears before the named-group declaration. The current mixed-group case puts the named group first. Add a reverse-order case to protect the pre-scan behavior.

Proposed test
   assert_equal ["ab", "a"], md.to_a
   assert_equal ["a"], md.captures
   assert_nil md[2]
   assert_raise_with_message(IndexError, "index 2 out of matches") { md.begin(2) }
   assert_equal "a", md[:a]
+
+  md = /(a)(?<b>b)/.match("ab")
+  assert_equal 2, md.size
+  assert_equal ["ab", "b"], md.to_a
+  assert_equal "b", md[:b]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mrbgems/mruby-regexp/test/regexp.rb` around lines 1132 - 1166, Add a
reverse-order mixed-group assertion to the Regexp test: match a pattern where
the plain capturing group appears before the named group, such as `(b)(?<a>a)`,
and verify the plain group is demoted while the named capture remains available.
Place it alongside the existing mixed-group case in the `Regexp - a named group
makes plain groups non-capturing` test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@mrbgems/mruby-regexp/test/regexp.rb`:
- Around line 1132-1166: Add a reverse-order mixed-group assertion to the Regexp
test: match a pattern where the plain capturing group appears before the named
group, such as `(b)(?<a>a)`, and verify the plain group is demoted while the
named capture remains available. Place it alongside the existing mixed-group
case in the `Regexp - a named group makes plain groups non-capturing` test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d4304cc2-fef7-4093-97ed-0b3038f44135

📥 Commits

Reviewing files that changed from the base of the PR and between 9233195 and 3b927ff.

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

@takumin
takumin force-pushed the regexp-named-group-dont-capture branch 3 times, most recently from 2baa944 to 36349ba Compare August 9, 2026 16:00
@matz

matz commented Aug 9, 2026

Copy link
Copy Markdown
Member

This needs a rebase. Six of the regexp changes landed together just now, up to 1f3f28b, and they touch test/regexp.rb and src/re_compile.c heavily enough that anything queued behind them collides.

For the record on what went in, so you can see what your branch is landing on top of:

#7051 mrb_str_cat() compares the append source through uintptr_t
#7052 a character class range split at the ASCII boundary
#7053 create_matchdata() snapshots the subject
#7054 the regexp form of String#[] and #slice
#7055 (?#...) comment groups
#7056 a multibyte literal as one atom

I verified the six together against CRuby before merging: sixteen rows, all agreeing, and the suite clean under ASan and UBSan.

Nothing in that set is aimed at what this pull request changes, so I expect the rebase to be mechanical, mostly in the test file. If it turns out not to be, say so and I will look at the interaction rather than have you work around it.

@takumin
takumin force-pushed the regexp-named-group-dont-capture branch from 36349ba to 49e7896 Compare August 9, 2026 22:21
@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 1f3f28b, now at 49e7896.

One conflict, and not in the test file: re_compile.c. #7055 renamed strip_extended() to preprocess_pattern(), and this branch adds a dont_capture field to the same struct, so the two edits landed on adjacent lines. Mechanical, as you expected.

Three things followed from that rename rather than from the conflict itself:

  • The first commit named strip_extended() in its subject and its body; both now say preprocess_pattern().
  • skip_posix_bracket() had been inserted between preprocess_pattern()'s doc comment and its definition, so the comment read as if it described the helper. Moved the helper above the comment.
  • The pre-scan's own comment claimed only the /x strip had run before it. It is now free-spacing, # comments and (?#...) groups.

The one interaction worth checking was #7055 against the pre-scan: a (?< written inside a comment group is not a declaration, and has_named_group() reads the buffer preprocess_pattern() returns, so the group is already gone by the time the scan runs. Pinned it:

/(?# (?<a>x )(b)/.match("b").to_a   # => ["b", "b"], as in CRuby

I also took CodeRabbit's suggestion and added the reverse order, where the plain group precedes the declaration that demotes it. That is the case the pre-scan exists for, and the branch had only been covering the other order:

md = /(a)(?<b>b)/.match("ab")
md.size       # => 2
md.captures   # => ["b"]
md[1]         # => "b"

rake test is clean, and so is MRUBY_CONFIG=build_config/asan.rb rake test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mrbgems/mruby-regexp/src/re_compile.c`:
- Around line 1203-1209: Update skip_posix_bracket to validate the remaining
distance from q to end before evaluating the second-character terminator check;
only read q[1] when at least two characters remain, while preserving the
existing successful `:]` return and NULL behavior for truncated brackets.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b4cea8b-f621-4f09-98d3-5c222e787c4c

📥 Commits

Reviewing files that changed from the base of the PR and between 36349ba and 49e7896.

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

Comment thread mrbgems/mruby-regexp/src/re_compile.c
The scan that copies `[:name:]` through `preprocess_pattern()` walks `q` to the
bracket's `:` and then tests `q + 1 < end` before reading `q[1]`. For a bracket
the pattern truncates, as in `/[[:alpha/x`, the walk stops with `q == end`, and
`q + 1` then forms a pointer past the one-past-the-end position. ISO C leaves
that undefined whether or not anything reads through it, and here nothing does:
the `&&` stops at the comparison.

Compare `end - q >= 2` instead, which is how the `(?#` test a few lines below
already spells the same question.

No behaviour change. A truncated bracket still falls through to the parser,
which reports the unterminated class, and the two spellings that reach the
stopped-at-`end` case are pinned as tests.
The preprocessing pass has to know that a POSIX bracket's `]` does not end a
character class, because `compile_charclass()` consumes `[:name:]` as a unit.
A second scan over the same bytes is about to need the same test, so lift it
into a helper rather than write it twice.

No behaviour change. The helper returns the position just past the bracket's
closing `]`, or `NULL` when the bracket is malformed, which is the same
fall-through the inline code had.
…group

CRuby turns on Onigmo's `ONIG_OPTION_DONT_CAPTURE_GROUP` for any pattern that
declares at least one named group: a plain `(...)` then groups without
capturing, and a numbered backreference is a compile error. This gem captured
both kinds side by side, so a pattern that mixes them had a different number
of groups in the two implementations and every numbered accessor disagreed.

```ruby
md = /(?<a>a)(b)/.match("ab")
md.size          # CRuby: 2,            mruby: 3
md.to_a          # CRuby: ["ab", "a"],  mruby: ["ab", "a", "b"]
md.captures      # CRuby: ["a"],        mruby: ["a", "b"]
md[2]            # CRuby: nil,          mruby: "b"
md.begin(2)      # CRuby: IndexError (index 2 out of matches),  mruby: 1

"ab" =~ /(?<a>a)(b)/
$2               # CRuby: nil,  mruby: "b"
$+               # CRuby: "a",  mruby: "b"

"ab".sub(/(?<a>a)(b)/, '[\2]')   # CRuby: "[]",  mruby: "[b]"

Regexp.new("(a)(?<b>b)\\1")
```

Named access already agreed in every case. `md[:a]`, `Regexp#named_captures`,
`Regexp#names` and `MatchData#names` were never affected; only the numbered
side differed.

Whether a plain group captures depends on a named group that may appear later
in the pattern, so `mrb_re_compile()` now pre-scans for `(?<` before it starts
allocating group numbers. The scan runs on the same bytes the parser will
read, after `preprocess_pattern()` has run, so free-spacing, `#` comments and
`(?#...)` comment groups are already gone. It skips escape pairs and character
classes, which keeps `/\(?/` and `/[(?<]/` from being false positives, and it
excludes `(?<=` and `(?<!`, which open a lookbehind rather than define a
group. `(?'name'...)` is not a spelling this
gem accepts, so `(?<` is the only form to look for. A truncated `(?<` still
raises from the parser, as `Regexp.new("(?<")` asserts.

`compile_atom()` then demotes a plain group to non-capturing and rejects a
numbered backreference in both its `\1` and its `\k<1>` / `\k<-1>` spellings.
The `\k` branch matters as much as the `\1` one: once plain groups stop
consuming numbers, its absolute bound and its relative `num_captures - n`
would resolve to a different group instead of erroring.

Everything downstream already derives from `pat->num_captures`, which is now
smaller for an affected pattern, so no accessor needed an edit.

The README gains a Named Captures section stating the rule, and the `\k<name>`
row its Pattern Syntax list was missing, since that is what a named pattern
has to use in place of a numbered backreference.
@takumin
takumin force-pushed the regexp-named-group-dont-capture branch from 49e7896 to fb83b7f Compare August 9, 2026 22:34
@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mrbgems/mruby-regexp/test/regexp.rb`:
- Around line 1315-1320: Add a narrowly scoped RuboCop disable directive around
the assert_nil $2 assertion in the regexp test, specifically suppressing
Lint/OutOfRangeRegexpRef while preserving the intentional nil assertion and
keeping lint checks enabled for surrounding code.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 445da97d-4289-407d-b9ed-0200525d51a7

📥 Commits

Reviewing files that changed from the base of the PR and between 49e7896 and fb83b7f.

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

Comment thread mrbgems/mruby-regexp/test/regexp.rb
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