Skip to content

mruby-regexp: turn \1 through \9 off in a replacement that names a group - #7283

Merged
matz merged 4 commits into
mruby:masterfrom
takumin:regexp-replacement-numbered-group
Aug 19, 2026
Merged

mruby-regexp: turn \1 through \9 off in a replacement that names a group#7283
matz merged 4 commits into
mruby:masterfrom
takumin:regexp-replacement-numbered-group

Conversation

@takumin

@takumin takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7282, which is what hands apply_replacement() the pattern. The
last commit is the whole of this change.

A replacement string reads \1 through \9 as whatever group the number
reaches, a group the pattern gave a name to among them. CRuby turns those
escapes off as soon as the pattern declares a named group, so mruby answers
with the group's text where CRuby answers with nothing:

"ab".sub(/(?<x>b)/, '[\1]')           # CRuby: "a[]",    mruby: "a[b]"
"abab".gsub(/(?<x>b)/, '[\1]')        # CRuby: "a[]a[]", mruby: "a[b]a[b]"
"ab".sub(/(?<a>a)(?<b>b)/, '[\1\2]')  # CRuby: "[]",     mruby: "[ab]"
"ab".dup.sub!(/(?<x>b)/, '[\1]')      # CRuby: "a[]",    mruby: "a[b]"

It is the ONIG_OPTION_DONT_CAPTURE_GROUP rule that stops a plain (...) from
taking a number in a pattern that names a group, reaching the replacement:
rb_reg_regsub() reads a digit escape as a group only where
onig_noname_group_capture_is_active() holds, and drops the reference where it
does not, so the escape stands for nothing. \0, \& and \+ sit in a
separate case of the same switch and are not guarded, so the whole match and
the last participating group keep working there.

The replacement string is the one place left that differs. Every other numbered
accessor already agrees, ea0b2f9 having demoted the plain groups:

md = /(?<x>b)/.match("ab")
md.size                              # 2 in both
md[1]                                # "b" in both
"ab" =~ /(?<x>b)/; $1                # "b" in both
"ab".sub(/(?<x>b)/) { "[#{$1}]" }    # "a[b]" in both
"ab".sub(/(?<a>a)(b)/, '[\2]')       # "[]" in both
"ab".sub("b", '[\1]')                # "a[]" in both: a literal has no groups

The \2 row is what that demotion fixed: a plain group in a named pattern has
no number for \2 to reach. What remains is the named group's own number,
which the same rule governs and which no demotion can take away, the group
being still numbered for md[1] and $1.

The fix

apply_replacement() is handed the pattern the match was made with, so the
question is one the pattern answers: a named group registers its name, and
pat->num_named > 0 is what the compiler asked has_named_group() for when it
set dont_capture. A digit above 0 stands for nothing there. \0 is the
whole match, which naming a group does not touch, and neither do \& and \+,
which CRuby keeps outside the guard. A literal String pattern hands in no
pattern and has no group for a number to reach either way.

Size

.text of bin/mruby, build_config/ci/gcc-clang.rb, each side from a clean
build directory at the same path; the #7282 column is the base this stacks
on. regexp.o is the only object that changes.

build master #7282 this PR delta over #7282
bintest 1,284,790 1,285,094 1,285,190 +96
ascii-ctype 1,272,742 1,273,046 1,273,142 +96
byte-string 1,254,262 1,254,614 1,254,710 +96
cxx_abi 1,309,545 1,309,977 1,310,073 +96
full-debug (-O0) 1,888,262 1,888,566 1,888,614 +48

The condition weighs 48 bytes, which full-debug pays once and the optimised
builds twice: apply_replacement() is inlined into a second,
constant-propagated copy for the two literal cores, and both copies carry it.

Verification

The tests go in string_regexp.rb, after the \k<name> blocks the base adds:
the four cases above, in sub, sub! and gsub, over one named group and
two; the number read where it still reaches, md[1] and \k<name>; a plain
group beside a named one, which has no number of its own left either; the
escapes the rule leaves alone, \0, \&, \+, \` and \'; a pattern
that names nothing, which keeps every number it hands out; and a literal String
pattern, which has no group for a number to reach. 5 of the 17 assertions fail
on #7282, 6 on master.

Differential against CRuby 4.0.6, master, #7282 and this branch against the
same cases. 16 patterns (one and two plain groups, one and two named ones, a
named group beside a plain one and beside a non-capturing one, a named group
nested either way, optional and repeated named groups, alternation, a pattern
with no group at all, a backreference) crossed with 11 replacements (\0
through \9, \&, \+, \` and \', two digits at once, an escaped
backslash, \k<name>), 4 subjects and sub and gsub: 1,408 cases. This
branch answers all 1,408 as CRuby does; #7282 differs on 112 of them, master on
202, the other 90 being the \k<name> the base adds.

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

build total KO crash
full-debug 2,383 0 0
bintest 2,383 0 0
bintest (bintest suite) 123 0 0
cxx_abi 2,383 0 0
byte-string 2,312 0 0
ascii-ctype 2,379 0 0

The default configuration: 2,158 total, 0 KO, 0 crash.

Environment

Details
OS Ubuntu 24.04, Linux 7.0.0 x86_64, AMD Ryzen 9 5950X
gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1)
binutils 2.47
CRuby 4.0.6, for the comparison

Compile lines for mrbgems/mruby-regexp/src/regexp.c in the builds quoted
above, paths shortened:

# ci/gcc-clang full-debug
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/full-debug/include" -o "build/full-debug/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang bintest
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/bintest/include" -o "build/bintest/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang cxx_abi
gcc -MMD -c -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/cxx_abi/include" -o "build/cxx_abi/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang byte-string
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -I"include" -I"mrbgems/mruby-regexp/include" -I"build/byte-string/include" -o "build/byte-string/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang ascii-ctype
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CTYPE -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/ascii-ctype/include" -o "build/ascii-ctype/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

Summary by CodeRabbit

  • New Features

    • Added support for named replacement backreferences using \k<name>.
    • Added validation for undefined and malformed named references.
    • Numeric backreferences are suppressed when patterns contain named groups.
  • Bug Fixes

    • Improved replacement handling for unmatched groups, multibyte names, literal escapes, and whole-match references.

`__sub_str` and `__gsub_str` keep their capture offsets in an `mrb_malloc`
buffer that is freed where the call ends, so a path out of the walk that
does not reach that free leaves it behind. The block core beside them
already holds the same offsets in `int captures[RE_MAX_CAPTURES * 2]`, and
so does the `last_captures` copy inside `__gsub_str` itself: compiling a
pattern with more groups than `RE_MAX_CAPTURES` fails, so the buffer is 256
bytes at most whatever the pattern.

Standing it there leaves nothing owned across `apply_replacement()`, which
is where the next commit raises for a replacement naming a group the
pattern does not have.
`\0` to `\9`, `\&` and `\+` each settle on a group and then copy its bytes
out, three times over with the same three lines between them. Saying which
group the escape stands for and appending that one group are two things, and
splitting them leaves each escape with only the first: `\&` is `\0` by
another spelling, so it says group 0 rather than reading the whole match's
offsets its own way, and `\+` says the last group that took part rather than
appending from inside its search. A group the escape reaches past, and one
that took no part in the match, then stand for nothing in one place instead
of in three.
A replacement string reads `\1` through `\9` for a group the pattern
numbered, but nothing read `\k<name>` for one the pattern named: the
reference was copied out as the text it was spelled with, a wrong answer
that nothing reported.

```ruby
"ab".sub(/(?<x>b)/, '\k<x>!')   # CRuby: "ab!", mruby: "a\\k<x>!"
```

The name is the bytes between the angles, with no escape among them: CRuby
ends the name at the first `>` whatever stands before it, and only this
spelling opens a reference, so `\k'name'`, which the pattern side does read
as a backreference, stays the literal it was here.

What the name is asked of is the pattern the match was made with, not the
offsets it left: a name no group carries is a mistake in the replacement,
and raises `IndexError` even where a group of that name would have taken no
part in the match and stood for nothing. A pattern with no named group at
all is asked the same question, and so is a literal String pattern, which
has no group to name. An unclosed `\k<` is a mistake of a different kind and
raises `RuntimeError`, as CRuby has it. Nothing is asked when nothing
matched, since the replacement is expanded once per match.

The lookup itself is the one `MatchData#[]` makes for a String or Symbol
name, extracted so both sides read a name the same way.
…a group

CRuby stops a digit escape in a replacement string from reaching a group as
soon as the pattern declares a named one, which is the same
`ONIG_OPTION_DONT_CAPTURE_GROUP` rule that stops a plain `(...)` from taking
a number: `rb_reg_regsub()` reads the escape as a group only where
`onig_noname_group_capture_is_active()` holds, and drops the reference where
it does not, so the escape stands for nothing. mruby-regexp read every digit
escape as whatever group the number reached, so a replacement that named a
group by number answered with the group's text.

```ruby
"ab".sub(/(?<x>b)/, '[\1]')           # CRuby: "a[]",    mruby: "a[b]"
"abab".gsub(/(?<x>b)/, '[\1]')        # CRuby: "a[]a[]", mruby: "a[b]a[b]"
"ab".sub(/(?<a>a)(?<b>b)/, '[\1\2]')  # CRuby: "[]",     mruby: "[ab]"
```

`apply_replacement()` is handed the pattern the match was made with, so the
question is one the pattern answers: a named group registers its name, and
`pat->num_named > 0` is what the compiler asked `has_named_group()` for when
it demoted the plain groups. A literal String pattern hands in no pattern and
has no group for a number to reach either way.

`\0` still stands for the whole match, and so do `\&` and `\+`, which CRuby
keeps outside the guard. The number a named group answers to is untouched
where it is read, `md[1]` and `$1` alike, and `\k<name>` is what reaches it
from a replacement.
@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: 480b6bf5-2dc6-466f-b154-8db7e51303c2

📥 Commits

Reviewing files that changed from the base of the PR and between 55e7451 and 90aae07.

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

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


📝 Walkthrough

Walkthrough

The regexp implementation adds named replacement backreferences, shared capture-name lookup, validation errors, and named-group numeric-reference rules. Compiled substitutions pass patterns to replacement expansion, while literal substitutions retain literal behavior. Regression tests cover these cases.

Changes

Named replacement backreferences

Layer / File(s) Summary
Shared capture-name lookup
mrbgems/mruby-regexp/src/regexp.c
MatchData and replacement processing use shared named-capture resolution.
Replacement expansion and validation
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/string_regexp.rb
Replacement strings support \k<name>, validate malformed or undefined names, and disable numeric group references when named groups exist.
Substitution path integration
mrbgems/mruby-regexp/src/regexp.c
Compiled sub and gsub pass the pattern and use stack capture buffers. Literal substitutions pass a null pattern and retain literal behavior.

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

Merge Risk: ⚪ Minimal · up to 90aae

The PR changes numbered replacement behavior for named regexp groups and reports matching CRuby behavior with all listed tests passing; no actionable merge-blocking risk remains.

Possibly related PRs

  • mruby/mruby#7282: Directly implements named-capture replacement references and shared lookup logic.
  • mruby/mruby#7057: Modifies named-capture and numeric-backreference semantics in mruby-regexp.
  • mruby/mruby#7048: Adds related shared named-capture lookup logic in regexp.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 main change: disabling numeric replacement backreferences when the pattern contains named groups.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 51b7f94 into mruby:master Aug 19, 2026
21 checks passed
@takumin
takumin deleted the regexp-replacement-numbered-group branch August 19, 2026 14:27
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