mruby-regexp: read \k<name> in a sub or gsub replacement - #7282
Conversation
`__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.
|
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 (2)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughNamed capture lookup is now reusable for replacement expansion. ChangesNamed replacement references
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: ⚪ Minimal · up to 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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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 |
A replacement string reads
\1through\9for a group the pattern numbered,and
\&for the whole match, but nothing read\k<name>for a group thepattern named. The reference is copied out as the text it was spelled with, a
wrong answer that nothing reports:
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:
>, with no escape among them, whichis 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
\kthat no<follows.name no group carries raises
IndexErrorwhere a group of that name wouldonly 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:
\k<is a mistake of a different kind and raisesRuntimeError(
invalid group name reference format), as CRuby has it.expanded once per match:
"zz".sub(/(?<x>b)/, '\k<y>')answers"zz"onboth 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_strand__gsub_strmoves 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 raiseabove leaves nothing behind;
RE_MAX_CAPTURESbounds it at 256 bytes whateverthe pattern. Then the escapes that stand for a group (
\0to\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
.textofbin/mruby,build_config/ci/gcc-clang.rb, each side from a cleanbuild directory at the same path.
regexp.ois the only object that changes.bintestascii-ctypebyte-stringcxx_abifull-debug(-O0)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 thebuffer 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 asecond, 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, insub,sub!andgsub, over two names, arepeated 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
0and the empty name among them; and the unclosed\k<, including theone 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:full-debugbintestbintest(bintest suite)cxx_abibyte-stringascii-ctypeThe default configuration: 2,157 total, 0 KO, 0 crash, plus its 112 bintests.
Environment
Details
Compile lines for
mrbgems/mruby-regexp/src/regexp.cin the builds quotedabove, paths shortened:
Summary by CodeRabbit
New Features
\k<name>withString#sub,String#gsub,String#sub!, andString#gsub!.Bug Fixes