Skip to content

mruby-regexp: add the regexp form of String#sub! and String#gsub! - #7061

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-sub-bang-regexp
Aug 10, 2026
Merged

mruby-regexp: add the regexp form of String#sub! and String#gsub!#7061
matz merged 1 commit into
mruby:masterfrom
takumin:string-sub-bang-regexp

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

mruby-regexp overrides String#sub and String#gsub so that they accept a Regexp, but it does not override String#sub! and String#gsub!. Those keep the core definitions in mrblib/string.rb, which decide whether a substitution took place with self.index(args[0]). String#index only takes a String, so a Regexp pattern raises there instead of substituting.

s = "hello world"
s.sub!(/o/, "0")
# CRuby: "hell0 world"
# mruby: TypeError (Regexp cannot be converted to String)

mruby-regexp is part of default.gembox through stdlib.gembox, so a plain rake build reproduces it:

$ ./build/host/bin/mruby -e 's = "hello world"; p s.sub!(/o/, "0")'
-e:1:in index: Regexp cannot be converted to String (TypeError)

Every form that reaches the index call is affected: a replacement string, a block, backreferences. The two that return before it are already right, by accident. "abc".sub!(/b/) raises the ArgumentError CRuby raises, because the String#sub override checks its argument count first, and "abc".gsub!(/a/) hands back an Enumerator that raises only once iterated. String patterns substitute correctly.

Cause

gsub! and sub! in mrblib/string.rb both delegate the substitution to gsub/sub and then ask self.index(args[0]) whether anything matched. index is 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 the sub and gsub they belong with, and decide the same question with a match.

match and not match?: a failed match has to clear $~, which is what CRuby leaves behind for a sub! 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.

s = "aaa"
s.gsub!(/a/, "a")   # self, not nil

The overrides take String patterns over from the core definitions as well, so they resolve the argument exactly 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 "a.c".sub!(".", "X") still replaces the dot and not the a. 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 for sub!, the last one for gsub!, 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:

CRuby this PR
"abc".freeze.sub!(/b/) ArgumentError ArgumentError
"abc".freeze.sub!(:b, "X") TypeError TypeError
"abc".freeze.sub!(/b/, "X") FrozenError FrozenError
"abc".freeze.gsub!(/b/, "X", "Y") FrozenError FrozenError
"abc".freeze.gsub!(:b, "X") FrozenError FrozenError
"abc".freeze.gsub!(/b/) FrozenError FrozenError

So sub! checks the arguments before the receiver, and gsub! checks the receiver first, ahead of the Enumerator its one-argument form otherwise returns. "abc".gsub!(:b) on an unfrozen string still yields an Enumerator that raises on the first iteration, as gsub does.

Matching self and then overwriting it with replace is safe, and no dup of the subject is needed. MatchData used 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.

s = "hello world"
s.sub!(/o/, "0")
$~[0]        # "o"
$~.string    # "hello world"

Behaviour

before after
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") TypeError nil
s = "aaa"; s.gsub!(/a/, "a") TypeError s
"abc".freeze.sub!(/z/) FrozenError ArgumentError
s = "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, nil only for a failed match, String patterns staying literal and non-patterns being rejected, the argument counts and their messages, the Enumerator from gsub!, the frozen receiver in both orders, and the $~ left behind, cleared included.

rake test passes: 1994 asserts, 0 KO.

Summary by CodeRabbit

  • New Features

    • Added String#sub! for replacing the first matching substring.
    • Added String#gsub! for replacing all matching substrings.
    • Supports regular expression and literal string patterns, replacement strings, and blocks.
    • Returns nil when no replacement occurs and preserves frozen-string protections.
  • Tests

    • Added comprehensive coverage for replacements, argument validation, enumerators, frozen strings, return values, and match-state behavior.

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.
@takumin
takumin requested a review from matz as a code owner August 9, 2026 22:54
@coderabbitai

coderabbitai Bot commented Aug 9, 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: f6709901-7c50-4c84-82e5-018959d71b6d

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f28b and b4f41f8.

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

📝 Walkthrough

Walkthrough

Added String#sub! and String#gsub! with destructive substitution, argument validation, literal pattern support, frozen receiver checks, enumerator behavior, and match-global handling. Added tests for normal, error, frozen, enumerator, and match-state cases.

Changes

Destructive substitution

Layer / File(s) Summary
Implement destructive substitution methods
mrbgems/mruby-regexp/mrblib/string_regexp.rb
Added String#sub! and String#gsub!. The methods validate arguments, support literal string patterns, mutate the receiver, return nil when no match occurs, and enforce frozen receiver behavior.
Validate substitution behavior
mrbgems/mruby-regexp/test/regexp.rb
Added tests for replacements, blocks, backreferences, enumerators, argument errors, frozen receivers, receiver identity, and match globals.

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

Possibly related PRs

  • mruby/mruby#6989: Extends related argument-validation and enumerator behavior from sub and gsub to their bang variants.
  • mruby/mruby#7006: Adjusts related gsub behavior used by gsub!.
  • mruby/mruby#7025: Updates regexp match globals used by destructive substitutions.

Suggested reviewers: matz, nattzn

🚥 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: adding Regexp support to String#sub! and String#gsub!.
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.

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