Skip to content

mruby-regexp: read \k<name> in a sub or gsub replacement - #7282

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

mruby-regexp: read \k<name> in a sub or gsub replacement#7282
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-replacement-named-group

Conversation

@takumin

@takumin takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

A replacement string reads \1 through \9 for a group the pattern numbered,
and \& for the whole match, but nothing read \k<name> for a group the
pattern named. The reference is copied out as the text it was spelled with, a
wrong answer that nothing reports:

"ab".sub(/(?<x>b)/, '\k<x>!')               # CRuby: "ab!",       mruby: "a\\k<x>!"
"abc".sub(/(?<x>b)(?<y>c)/, '\k<y>\k<x>')   # CRuby: "acb",       mruby: "a\\k<y>\\k<x>"
"abab".gsub(/(?<x>b)/, '<\k<x>>')           # CRuby: "a<b>a<b>",  mruby: "a<\\k<x>>a<\\k<x>>"
"ab".sub(/(?<x>b)/, '\k<y>')                # CRuby: IndexError,  mruby: "a\\k<y>"
"ab".sub(/(?<x>b)/, '\k<x')                 # CRuby: RuntimeError, mruby: "a\\k<x"

The pattern side reads the same spelling as a backreference, so a pattern can
name a group and refer to it within itself; the replacement is the one place
where the name says nothing.

The fix

apply_replacement() is handed the pattern the match was made with, and \k<
opens a reference to a group that pattern names:

  • The name is the bytes up to the first >, with no escape among them, which
    is where CRuby ends it. Only this spelling opens a reference, so \k'name',
    which the pattern side does read as a backreference, stays the literal it was
    here as it does in CRuby, and so does a \k that no < follows.
  • The group is asked of the pattern, not of the offsets the match left, so a
    name no group carries raises IndexError where a group of that name would
    only have stood for nothing. A pattern that names no group at all is asked
    the same question, and so is a literal String pattern, which has no group to
    name:
"ab".sub(/(?<x>c)?b/, '[\k<x>]')   # "a[]": the group took no part in the match
"ab".sub(/b/, '\k<y>')             # IndexError: undefined group name reference: y
"ab".sub("b", '\k<y>')             # IndexError: undefined group name reference: y
  • An unclosed \k< is a mistake of a different kind and raises RuntimeError
    (invalid group name reference format), as CRuby has it.
  • Nothing is asked of the pattern when nothing matched, a replacement being
    expanded once per match: "zz".sub(/(?<x>b)/, '\k<y>') answers "zz" on
    both sides.

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

Two commits come before it. The capture buffer of __sub_str and __gsub_str
moves to the stack, where the block core beside them already keeps its own and
where __gsub_str's copy of the last match already stands, so that the raise
above leaves nothing behind; RE_MAX_CAPTURES bounds it at 256 bytes whatever
the pattern. Then the escapes that stand for a group (\0 to \9, \&, \+)
fold onto a single append, so that the new reference is one more way to say
which group is meant rather than a fourth copy of the copy-out.

Size

.text of bin/mruby, build_config/ci/gcc-clang.rb, each side from a clean
build directory at the same path. regexp.o is the only object that changes.

build master this PR delta
bintest 1,284,790 1,285,094 +304
ascii-ctype 1,272,742 1,273,046 +304
byte-string 1,254,262 1,254,614 +352
cxx_abi 1,309,545 1,309,977 +432
full-debug (-O0) 1,888,262 1,888,566 +304

The two commits ahead of the reference are worth -80 to -384 of that on their
own: the fold is -48 in every build but full-debug, where it is -144, and the
buffer the rest. The reference costs +416 to +688 on top of them, and +560 to
+848 without the fold ahead of it, apply_replacement() being inlined into a
second, constant-propagated copy for the two literal cores, so that both copies
carry whatever the branch weighs.

Verification

The tests go in string_regexp.rb, beside the block for \&, \` and
\': what a name reaches, in sub, sub! and gsub, over two names, a
repeated name, a group that took no part in the match, and a multibyte name;
the four spellings that are not a reference; the names that reach no group,
through a named pattern, an unnamed one and a literal String pattern, with
group 0 and the empty name among them; and the unclosed \k<, including the
one at the very end of the replacement. Each case that raises has a companion
that matched nothing and so does not raise. 19 of the 26 assertions fail on
master.

Every case above was run against CRuby 4.0.6 first, and this branch answers all
26 as CRuby does.

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

build total KO crash
full-debug 2,382 0 0
bintest 2,382 0 0
bintest (bintest suite) 123 0 0
cxx_abi 2,382 0 0
byte-string 2,311 0 0
ascii-ctype 2,378 0 0

The default configuration: 2,157 total, 0 KO, 0 crash, plus its 112 bintests.

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

    • Replacement strings now support named capture references using \k<name> with String#sub, String#gsub, String#sub!, and String#gsub!.
    • Named references expand to the corresponding captured text, including multibyte names and repeated references.
  • Bug Fixes

    • Invalid or undefined named references now produce clear errors.
    • Unmatched patterns leave replacement text unexpanded without raising errors.
    • Literal string substitutions correctly reject named capture 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.
@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: f95b5d84-39b0-4aff-91b3-866e7d37a632

📥 Commits

Reviewing files that changed from the base of the PR and between addc03b and a97d960.

📒 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; 7 remain after this review.


📝 Walkthrough

Walkthrough

Named capture lookup is now reusable for replacement expansion. String#sub, gsub, sub!, and gsub! support \k<name>, validate malformed or undefined references, and use stack-allocated capture buffers.

Changes

Named replacement references

Layer / File(s) Summary
Name lookup and replacement expansion
mrbgems/mruby-regexp/src/regexp.c
Named capture lookup is shared with MatchData. Replacement expansion supports \k<name> and validates malformed or undefined references.
Substitution path integration
mrbgems/mruby-regexp/src/regexp.c
Compiled substitutions pass the pattern and stack capture buffers to replacement expansion. Literal substitutions pass no pattern. Heap-capture cleanup is removed.
Replacement behavior validation
mrbgems/mruby-regexp/test/string_regexp.rb
Tests cover named expansion, repeated and non-participating captures, multibyte names, malformed references, undefined names, literal patterns, and non-matching cases.

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

Merge Risk: ⚪ Minimal · up to a97d9

This change adds named-group expansion to regexp replacements with matching error behavior and tests; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant String
  participant RegexpSubstitution
  participant CompiledPattern
  String->>RegexpSubstitution: sub or gsub with replacement
  RegexpSubstitution->>CompiledPattern: match and resolve capture name
  CompiledPattern-->>RegexpSubstitution: capture text or validation error
  RegexpSubstitution-->>String: substituted result
Loading

Possibly related PRs

  • mruby/mruby#7007: Both changes modify named-capture lookup and name-length handling in regexp.c.
  • mruby/mruby#7228: Both changes validate \k<...> group-reference syntax and errors.
  • mruby/mruby#7274: This change extends the same substitution helpers with named-capture replacement expansion.

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: support for named capture references in substitution replacements.
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 1e6646c into mruby:master Aug 19, 2026
21 checks passed
@takumin
takumin deleted the regexp-replacement-named-group branch August 19, 2026 14:24
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