mruby-regexp: add the regexp form of String#sub! and String#gsub! - #7061
Conversation
The gem overrides `sub` and `gsub` for Regexp patterns, but leaves `sub!` and `gsub!` on the core definitions in `mrblib/string.rb`, which decide whether a substitution took place with `self.index(args[0])`. `index` only takes a String, so a Regexp pattern raises there instead of substituting. ```ruby s = "hello world" s.sub!(/o/, "0") # CRuby: "hell0 world" # mruby: TypeError (Regexp cannot be converted to String) ``` Override both in the gem and answer that question with a match instead. `match` and not `match?`: a failed match has to clear `$~`, as CRuby does. The match is also what decides the return value, so `"aaa".gsub!(/a/, "a")` still returns `self`, where comparing the result against the receiver would answer nil. The overrides take String patterns over as well, so they resolve the argument the way `sub` and `gsub` do: `Regexp.__check_pattern` rejects anything that is neither a Regexp nor a String, and an accepted String is quoted with `Regexp.new(Regexp.escape(pattern))` rather than compiled, so its metacharacters stay literal. The resolved pattern is what goes on to `sub` and `gsub`, which republish `$~` over the match made here: `sub!` leaves the single match behind and `gsub!` the last one, as CRuby does. The order the arguments are read in follows CRuby, where the two methods differ. `sub!` reads them before the receiver, so `"abc".freeze.sub!(/b/)` raises `ArgumentError` and `"abc".freeze.sub!(:b, "X")` `TypeError`. `gsub!` rejects a frozen receiver first, ahead of the `Enumerator` that its one-argument form otherwise returns. Matching `self` and then overwriting it with `replace` is safe since `create_matchdata()` began snapshotting its subject: the `$~` left behind describes the string as it was matched, not the replaced one.
|
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)
📝 WalkthroughWalkthroughAdded ChangesDestructive substitution
Estimated code review effort: 3 (Moderate) | ~25 minutes 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 |
mruby-regexpoverridesString#subandString#gsubso that they accept a Regexp, but it does not overrideString#sub!andString#gsub!. Those keep the core definitions inmrblib/string.rb, which decide whether a substitution took place withself.index(args[0]).String#indexonly takes a String, so a Regexp pattern raises there instead of substituting.mruby-regexpis part ofdefault.gemboxthroughstdlib.gembox, so a plainrakebuild reproduces it:Every form that reaches the
indexcall is affected: a replacement string, a block, backreferences. The two that return before it are already right, by accident."abc".sub!(/b/)raises theArgumentErrorCRuby raises, because theString#suboverride checks its argument count first, and"abc".gsub!(/a/)hands back anEnumeratorthat raises only once iterated. String patterns substitute correctly.Cause
gsub!andsub!inmrblib/string.rbboth delegate the substitution togsub/suband then askself.index(args[0])whether anything matched.indexis a String search, so the question cannot be asked about a Regexp.Fix
Override both in
mrbgems/mruby-regexp/mrblib/string_regexp.rb, next to thesubandgsubthey belong with, and decide the same question with a match.matchand notmatch?: a failed match has to clear$~, which is what CRuby leaves behind for asub!that substituted nothing. The match is also what decides the return value, so a substitution that leaves the string as it was is still a substitution.The overrides take String patterns over from the core definitions as well, so they resolve the argument exactly the way
subandgsubdo:Regexp.__check_patternrejects anything that is neither a Regexp nor a String, and an accepted String is quoted withRegexp.new(Regexp.escape(pattern))rather than compiled, so"a.c".sub!(".", "X")still replaces the dot and not thea. Skipping the resolver would reopen, one method over, the hole #7001 closed.The resolved pattern is then what goes on to
sub/gsub, so a String is quoted and compiled once rather than twice. They match again and republish$~over the match made here, which leaves the caller what CRuby leaves: the single match forsub!, the last one forgsub!, and a block's own matches where the block made any.The order the arguments are read in follows CRuby, where the two methods differ:
"abc".freeze.sub!(/b/)ArgumentErrorArgumentError"abc".freeze.sub!(:b, "X")TypeErrorTypeError"abc".freeze.sub!(/b/, "X")FrozenErrorFrozenError"abc".freeze.gsub!(/b/, "X", "Y")FrozenErrorFrozenError"abc".freeze.gsub!(:b, "X")FrozenErrorFrozenError"abc".freeze.gsub!(/b/)FrozenErrorFrozenErrorSo
sub!checks the arguments before the receiver, andgsub!checks the receiver first, ahead of theEnumeratorits one-argument form otherwise returns."abc".gsub!(:b)on an unfrozen string still yields anEnumeratorthat raises on the first iteration, asgsubdoes.Matching
selfand then overwriting it withreplaceis safe, and nodupof the subject is needed.MatchDataused to hold the string it matched by reference, which would have made the$~left behind describe the replaced string;create_matchdata()has snapshotted the subject since #7053.Behaviour
s = "hello world"; s.sub!(/o/, "0")TypeError"hell0 world"s = "hello world"; s.gsub!(/o/, "0")TypeError"hell0 w0rld"s = "hello"; s.sub!(/l/) { |m| m.upcase }TypeError"heLlo"s = "John Smith"; s.sub!(/(\w+) (\w+)/, '\2 \1')TypeError"Smith John"s = "abc"; s.sub!(/z/, "X")TypeErrornils = "aaa"; s.gsub!(/a/, "a")TypeErrors"abc".freeze.sub!(/z/)FrozenErrorArgumentErrors = "a.c"; s.sub!(".", "X")"aXc""aXc"Every cell in the "after" column is what CRuby 4.0.6 answers.
Tests
Seven asserts in
mrbgems/mruby-regexp/test/regexp.rb, covering the substitution itself in both the replacement-string and the block form, backreferences, the receiver coming back rather than a copy,nilonly for a failed match, String patterns staying literal and non-patterns being rejected, the argument counts and their messages, theEnumeratorfromgsub!, the frozen receiver in both orders, and the$~left behind, cleared included.rake testpasses: 1994 asserts, 0 KO.Summary by CodeRabbit
New Features
String#sub!for replacing the first matching substring.String#gsub!for replacing all matching substrings.nilwhen no replacement occurs and preserves frozen-string protections.Tests