Skip to content

mruby-regexp: follow a block that changes the receiver in gsub, sub! and scan - #7267

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:regexp-gsub-block-mutation
Aug 19, 2026
Merged

mruby-regexp: follow a block that changes the receiver in gsub, sub! and scan#7267
matz merged 2 commits into
mruby:masterfrom
takumin:regexp-gsub-block-mutation

Conversation

@takumin

@takumin takumin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master now that #7274, which moves the block walk of gsub into C, is merged: the two commits here are all this PR is. The semantics, the tests and the differential run are the ones reviewed above, with the gsub part living in the C loop, where the cost question of that review does not arise. Every figure below is measured against master as it stands after that merge.

The block forms of String#gsub, gsub!, sub! and scan in mruby-regexp did not answer for a block that writes to the receiver the way CRuby's str_gsub, rb_str_sub_bang and rb_str_scan do. Measured against CRuby 4.0.6:

s = "hello"; s.gsub(/l/) { s.tr!("h", "H"); "X" }
# CRuby: "HeXXo"   mruby before: "heXXo"   mruby after: "HeXXo"

s = "abc"; s.gsub(/b/) { s.replace("xyz"); "!" }
# CRuby: "x!z"     mruby before: "a!z"     mruby after: "x!z"

t = "hello"; n = 0
t.gsub(/l/) { n += 1; t.upcase! if n == 2; "X" }
[$&, $`, $']
# CRuby: [nil, nil, nil]   mruby before: ["l", "hel", "o"]   mruby after: [nil, nil, nil]

s = "abc"; s.gsub(/b/) { s << "zz"; "!" }
# CRuby: RuntimeError (string modified)   mruby before: "a!czz"   mruby after: RuntimeError (string modified)

s = "hello"; s.sub!(/l/) { s.upcase!; "X" }
# CRuby: "HEXLO"   mruby before: "heXlo"   mruby after: "HEXLO"

s = "hello"; s.scan(/l/) { s.upcase! }; $~ && $~[0]
# CRuby: nil       mruby before: "l"       mruby after: nil

Three things were off in what the loops read and when. The gsub loop copied the stretch before each match before the block ran, so a change the block made there was lost from the answer while the next match was already searched for in the changed string. A block that changed the length was let through in every loop, and the offsets of the match then named other bytes than the ones it matched; CRuby refuses one in str_mod_check right after the block returns. And the match left in $~ was the MatchData of the last match the loop had, published again; CRuby searches once more when the loop is over, from the offset the last match was found from and on the receiver as it stands then, which is nil where the block wrote the match away and a fresh match on the changed string otherwise. sub! went down to sub, which builds the answer from the snapshot the MatchData holds, where rb_str_sub_bang splices the replacement into the receiver as the block left it.

Fix

Two commits.

The first takes gsub and sub!. Regexp.__gsub_block, the C loop #7274 put on master, calls the block first and copies the stretch before the match afterwards, compares the receiver's byte length after each call against what it was when the loop began and raises RuntimeError where it differs (str_mod_check compares the buffer pointer too, which is no test here: mruby answers a write into a shared string with a buffer of its own, which is also why the bytes, their reading and the encoding check are taken afresh from the receiver the block left rather than through the pointer the search was given), keeps the offset each match was found from and the match itself, and ends on a search from that offset where the receiver no longer reads as it did when that match was made. Where it still does, the search could only find that match again, so the loop publishes it again: a MatchData holds a frozen copy of its subject, and what a search reads of a subject is its bytes and whether they are read by byte, so the receiver is compared against that copy on both counts. A change of length fails the comparison, so do tr! and upcase!, which keep the length and change the bytes, and so does s.replace(s.b), which keeps every byte and changes the reading; where the receiver still shares its buffer with the copy, the pointer settles it. In C the length check is one comparison per turn and the copy test a pointer compare or a memcmp in the same function. gsub! inherits all of it. The block form of sub! runs the block itself in mrblib, applies the same length check, and splices the replacement into the receiver by the byte offsets of the match. sub is unchanged: CRuby's rb_str_sub works on a copy, so the block reaches nothing it reads.

The second takes scan, whose block loop stays in mrblib. It hands the length it began with to every search it makes after the block, and Regexp.__byte_search takes it as a fourth argument and raises RuntimeError before it looks where the receiver no longer has it, so the loop pays one argument per search rather than one bytesize call per match; it keeps the offset each match was found from and the match itself, and ends on a search from that offset unless MatchData#__republish(str), the copy test above asked from mrblib, publishes the match again. MatchData#__set_globals, which republished the last match whatever the block had done to the receiver, loses its last caller there and goes.

Testing

  • mrbgems/mruby-regexp/test/match_data.rb: the test that pinned the republished MatchData after a gsub block mutated the subject now pins CRuby's answer ($~ nil where the match was written away, a match on the changed string where it was left in place, and the closing search running from the offset the last match was found from).
  • mrbgems/mruby-regexp/test/string_regexp.rb: return values of gsub / gsub! / sub! / scan under a block that changes the receiver in place, RuntimeError for a change of length (in bytes, and against the length at the start of the loop, so a change undone inside the block passes), the receiver keeping what the block did to it, sub unaffected, FrozenError for a receiver the block freezes, a receiver long enough that the write moves it off the buffer the match was made on, and, for the empty-match step, s.clear and s.chop! under gsub(/x*/) and s.clear under scan(/x*/), which raise RuntimeError on CRuby. The assertion master gained in ba8a7c8, a replace of the same length under gsub(/(?=a)/) that tells reading the receiver's bytes again from reading the pointer the search was made with, stands inside this rewritten test as it was written.
  • mrbgems/mruby-regexp/test/regexp_utf8.rb: the match left behind after a block that changed the reading of the receiver without changing a byte (s.replace(s.b)) counts its offsets in bytes, as CRuby's does after a force_encoding in the block; a comparison of bytes alone would take that receiver for unchanged.

Every value in the tests was measured on CRuby 4.0.6 first. The differential run from the earlier review, the four block forms over 7 subjects, 8 patterns and 16 kinds of in-block change, 3584 cases printed the same way on both sides and diffed against CRuby 4.0.6: master differs on 992, the first commit here on 223 (all of them scan), the tip on 0.

Full suite green at every commit: rake -m test on the default configuration at each commit, and MRUBY_CONFIG=build_config/ci/gcc-clang.rb rake -m test and MRUBY_CONFIG=build_config/gcc-asan.rb rake -m test at the tip, no sanitizer report.

Build Total KO Crash
full-debug 2377 0 0
bintest 2377 (+122 bintest) 0 0
cxx_abi 2377 0 0
byte-string 2306 0 0
ascii-ctype 2373 0 0
gcc-asan 2377 (+84 bintest) 0 0
default 2152 0 0

Cost

Wall clock, the cases of the earlier review plus the String pattern block forms and a scan over the long subject, minimum of 5 alternating runs, -O3, default configuration. master has the C walk of #7274, so the block rows start from where that PR left them; the percentages are against master.

s480 = "hello world foo bar " * 24
30000.times { s480.gsub(/o/) { } }        # gsub480_blk
100000.times { "abc".gsub(/b/) { } }      # gsub_abc_blk
100000.times { "abc".scan(/b/) { } }      # scan_abc_blk
100000.times { "abc".sub!(/b/) { } }      # sub!_abc_blk
30000.times { s480.gsub(/o/, "0") }       # gsub480_str
100000.times { "abc".scan(/b/) }          # scan_abc
100000.times { "abc".sub!(/b/, "0") }     # sub!_abc_str
30000.times { s480.gsub(/z/) { } }        # gsub480_none
30000.times { s480.scan(/o/) { } }        # scan480_blk
30000.times { s480.gsub("o") { } }        # lit_gsub480_blk
100000.times { "abc".gsub("b") { } }      # lit_gsub_abc_blk
100000.times { "abc".sub!("b") { } }      # lit_sub!_abc_blk
100000.times { s = "hello"; s.gsub(/l/) { s.tr!("h", "H"); "X" } }        # gsub_tr
30000.times { s = s480.dup; s.gsub(/o/) { s.tr!("h", "H"); "0" } }        # gsub480_tr
100000.times { s = "hello"; s.scan(/l/) { s.tr!("h", "H") } }             # scan_tr
case master commit 1 this PR
gsub480_blk 1207ms 1231ms (+2%) 1207ms
gsub_abc_blk 160ms 164ms (+3%) 164ms (+3%)
scan_abc_blk 174ms 172ms (-1%) 182ms (+5%)
sub!_abc_blk 212ms 172ms (-19%) 170ms (-20%)
gsub480_str 118ms 118ms 118ms
scan_abc 117ms 118ms (+1%) 117ms
sub!_abc_str 193ms 190ms (-2%) 192ms (-1%)
gsub480_none 37ms 38ms (+3%) 38ms (+3%)
scan480_blk 1592ms 1580ms (-1%) 1612ms (+1%)
lit_gsub480_blk 1189ms 1216ms (+2%) 1201ms (+1%)
lit_gsub_abc_blk 157ms 158ms (+1%) 157ms
lit_sub!_abc_blk 266ms 168ms (-37%) 167ms (-37%)
gsub_tr 222ms 221ms 223ms
gsub480_tr 4286ms 4293ms 4318ms (+1%)
scan_tr 260ms 257ms (-1%) 266ms (+2%)

The gsub rows: the length comparison, the reading and the encoding flag read per turn cost about 2% on the 480 byte subject in the first commit (24ms over 2.16 million turns), a spread the tip's run of the same code lands inside, and 3% at most on the short ones; the closing test is the pointer alone there, since the receiver still shares its buffer with the snapshot. sub! with a block no longer goes down to sub, which is the -20% and -37% on its two rows. The scan rows carry the argument the search now parses and the __republish call, 1% on the long subject and 5% on the short one, and scan on "abc" without a block, gsub(/o/, "0") and sub!(/b/, "0") are unchanged. The mutating rows (gsub_tr, gsub480_tr, scan_tr) are where the closing search runs, and they sit on master to within 2%.

Size

.text of bin/mruby, build_config/ci/gcc-clang.rb, each side from a clean
build directory. regexp.o is the object that changes, and it accounts for the
whole delta in every build (to within 5 bytes of alignment in full-debug):
the copy test and the closing search in __gsub_block, __republish and the
length argument of __byte_search. The mrblib side, the block form of sub!
and the closing search of scan, is bytecode in mruby-regexp/gem_init.o's
.rodata, which grows by 128 (160 in full-debug).

build master commit 1 this PR delta
bintest 1,287,414 1,287,718 1,288,134 +720
ascii-ctype 1,275,302 1,275,606 1,276,022 +720
byte-string 1,257,014 1,257,318 1,257,734 +720
cxx_abi 1,313,017 1,313,353 1,313,593 +576
full-debug (-O0) 1,886,710 1,887,062 1,887,318 +608

On the default configuration, the one the wall clock was measured on, .text is
1,204,174 on master, 1,204,478 after the first commit and 1,204,894 at the tip
(+720).

Environment

Machine, toolchain, and the compile line of every build
Item Value
OS Ubuntu 24.04.4 LTS
Kernel 7.0.0-29-generic
CPU AMD Ryzen 9 5950X 16-Core Processor
C compiler gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
binutils GNU ld (GNU Binutils) 2.47.20260726
CRuby (reference) ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]

Actual compile line of mrbgems/mruby-regexp/src/regexp.c in each build (-MMD -c, -I, and -o dropped). full-debug and gcc-asan are -O0 because enable_debug appends -g3 -O0 after the toolchain's -g -O3; cxx_abi compiles C as C++ with gcc -x c++ -std=gnu++03, g++ only links.

# full-debug
gcc -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 mrbgems/mruby-regexp/src/regexp.c
# bintest
gcc -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 mrbgems/mruby-regexp/src/regexp.c
# cxx_abi
gcc -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 mrbgems/mruby-regexp/src/regexp.c
# byte-string
gcc -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 mrbgems/mruby-regexp/src/regexp.c
# ascii-ctype
gcc -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 mrbgems/mruby-regexp/src/regexp.c
# gcc-asan
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -fsanitize=address,undefined -g3 -O0 -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 mrbgems/mruby-regexp/src/regexp.c
# default (no MRUBY_CONFIG), the build every figure above was measured on
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_COMPLEX -DMRB_USE_BIGINT -DMRB_USE_DEBUG_HOOK mrbgems/mruby-regexp/src/regexp.c

Summary by CodeRabbit

  • Bug Fixes
    • Improved String#sub, gsub, scan, and bang variants when the source string is modified during processing.
    • Added validation to detect source-length changes and prevent inconsistent results.
    • Preserved match information more reliably after substitutions and callbacks.
    • Improved handling of literal string patterns, replacement expansions, empty patterns, frozen strings, and invalid byte sequences.
    • Corrected UTF-8 and byte-based match indexing after block substitutions.

@takumin
takumin requested a review from matz as a code owner August 18, 2026 10:09
@coderabbitai

coderabbitai Bot commented Aug 18, 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: 43f16fd2-54ab-45c6-ab85-fc5e07488463

📥 Commits

Reviewing files that changed from the base of the PR and between 9775cf0 and 1a5e26b.

📒 Files selected for processing (1)
  • 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

Regexp substitutions and scans now use dedicated C helpers. The helpers support literal patterns, block traversal, receiver mutation checks, encoding validation, replacement expansion, and match-global republishing. Tests cover mutation, length changes, match state, literal patterns, and UTF-8 indexing.

Changes

Regexp helper contracts and match state

Layer / File(s) Summary
Search and match-state contracts
mrbgems/mruby-regexp/src/regexp.c
Byte searches accept an original length and validate encoding. MatchData#__republish validates subject equivalence. Literal matches lazily cache quoted regexps.
Literal and block substitution helpers
mrbgems/mruby-regexp/src/regexp.c
C helpers implement literal and compiled substitutions, block gsub, replacement expansion, mutation checks, binary handling, match publication, and updated registrations.

String method dispatch

Layer / File(s) Summary
Substitution dispatch and guarded scanning
mrbgems/mruby-regexp/mrblib/string_regexp.rb
sub, sub!, gsub, and gsub! dispatch to dedicated helpers. scan tracks subject length and republishes final match state after callbacks.

Regression coverage

Layer / File(s) Summary
Mutation and match-state tests
mrbgems/mruby-regexp/test/match_data.rb, mrbgems/mruby-regexp/test/string_regexp.rb, mrbgems/mruby-regexp/test/regexp_utf8.rb
Tests cover changed contents, length changes, zero-width matches, frozen receivers, literal patterns, final match globals, and UTF-8 indexing.

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

Merge Risk: ⚪ Minimal · up to 1a5e2

The PR aligns block mutations in regexp-based string operations with the documented behavior and reports comprehensive validation; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant StringRegexp
  participant RegexpC
  participant ReplacementBlock
  participant MatchData
  StringRegexp->>RegexpC: dispatch sub, sub!, gsub, or gsub!
  RegexpC->>ReplacementBlock: yield block match when applicable
  ReplacementBlock-->>RegexpC: return replacement or mutate receiver
  RegexpC->>MatchData: validate and republish final match
  RegexpC-->>StringRegexp: return substituted value or bang result
Loading

Possibly related PRs

  • mruby/mruby#7025: Updates related regexp match-global publication behavior.
  • mruby/mruby#7061: Revises the same String#sub! and String#gsub! implementation paths.
  • mruby/mruby#7274: Further refines the same substitution helpers, block gsub, and match-state handling.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: updating block forms of gsub, sub!, and scan when the receiver changes.
✨ 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.

@takumin

This comment was marked as outdated.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@matz

matz commented Aug 18, 2026

Copy link
Copy Markdown
Member

The behavior is right and I reproduced all of it. A random comparison of 500 cases (the five methods over 14 kinds of in-block mutation) against CRuby 3.2.3 has master differing on 70 and this branch on 0. I also planted each of the five changes back one at a time, and the new tests caught every one. host-debug, the default configuration, all six ci/gcc-clang builds and clang-asan are green.

Before I merge I would like to ask about the closing Regexp.__byte_search. It is one extra search per call that matched, and it lands only on the block forms. Minimum of three runs, -O3, default configuration:

case master this branch
s.gsub(/o/) { }, 480 byte subject, 30k calls 1748ms 1826ms (+4%)
"abc".gsub(/b/) { }, 100k calls 193ms 220ms (+14%)
"abc".scan(/b/) { }, 100k calls 151ms 175ms (+16%)
"abc".sub!(/b/) { }, 100k calls 194ms 148ms (-24%)

gsub(/o/, "0"), scan without a block, sub!(pat, str) and a gsub that matched nothing all stay within noise, so sub! with a block gets faster by not going down to sub, and the cost is confined to the two loops, which is where the semantics changed.

I am not asking you to trade the semantics away. My question is whether the search can be skipped where it cannot tell us anything new: if the receiver is what it was when the loop started, a search from last finds the match the loop already had, and republishing that one would do. Comparing bytesize does not establish that, since tr! and upcase! keep the length, so I have no test for it I trust. If you see one, I would take it.

There is a second angle. Every search copies the subject into $` and $' eagerly, which is why the long subject pays 4% even though it has many matches, and making those lazy is already on my list from #7148 and #7149. If you think that work would absorb this cost, say so and I will merge as it stands and leave the search alone.

@takumin
takumin force-pushed the regexp-gsub-block-mutation branch from 0349f04 to 5ddc10e Compare August 18, 2026 15:11
@takumin

This comment was marked as outdated.

@takumin

This comment was marked as outdated.

@takumin

This comment has been minimized.

@takumin

This comment was marked as outdated.

@takumin
takumin force-pushed the regexp-gsub-block-mutation branch from 584cd52 to 7a8a865 Compare August 19, 2026 03:23
@takumin
takumin marked this pull request as ready for review August 19, 2026 03:23
@takumin

This comment was marked as outdated.

@takumin takumin changed the title mruby-regexp: follow a gsub block that changes the receiver mruby-regexp: follow a block that changes the receiver in gsub, sub! and scan Aug 19, 2026
@takumin
takumin force-pushed the regexp-gsub-block-mutation branch from 7a8a865 to 9775cf0 Compare August 19, 2026 03:42
@takumin

This comment was marked as outdated.

@takumin

This comment was marked as outdated.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@takumin

This comment was marked as outdated.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews resumed.

The block form of `String#gsub` did not answer for a block that writes to
the receiver the way CRuby's `str_gsub` does, in the mrblib loop it used
to be and in the C loop of `Regexp.__gsub_block` that took its place.
Three things were off, all in what the loop read and when.

The stretch before each match was copied before the block ran, so a change
the block made there was lost from the answer, while the next match was
already searched for in the changed string:

```ruby
s = "hello"; s.gsub(/l/) { s.tr!("h", "H"); "X" }
s = "abc"; s.gsub(/b/) { s.replace("xyz"); "!" }
```

A block that changed the length was let through, and the offsets of the
match then named other bytes than the ones it matched. CRuby refuses one
in `str_mod_check`, right after the block returns:

```ruby
s = "abc"; s.gsub(/b/) { s << "zz"; "!" }
```

And the match left in `$~` was the MatchData of the last match the loop
had, published again. CRuby searches once more when the loop is over,
from the offset the last match was found from and on the receiver as it
stands then, and leaves that behind, which is nil where the block wrote
the match away:

```ruby
t = "hello"; n = 0
t.gsub(/l/) { n += 1; t.upcase! if n == 2; "X" }
[$&, $`, $']
```

The loop now calls the block first and copies the stretch before the match
afterwards, compares the byte length against what it was when the loop
began and raises `RuntimeError` where it differs (`str_mod_check` compares
the buffer pointer too, which is no test here: mruby answers a write into
a shared string with a buffer of its own, which is also why the bytes are
read from where they are now and not through the pointer the search was
given), takes the reading and the encoding check afresh from the receiver
the block left, keeps the offset each match was found from, and ends on a
search from that offset instead of publishing the last match again.
`gsub!` inherits all of it.

That closing search is one more search per call that matched, and on a
receiver the block left alone it can only find the match the loop already
holds. The loop keeps that match and asks first whether the receiver still
reads as the frozen copy of the subject that match holds: what a search
reads of a subject is its bytes and whether they are read by byte, so the
copy against the receiver is the whole of the test, where `str_mod_check`
in CRuby has the buffer pointer and the length. Where the receiver still
reads as the copy does, the match is published again and the search is
skipped; where it does not, whether the block changed a byte, the length
or the reading, the search runs. A change of length alone, or `tr!` and
`upcase!` that keep it, all fail the test, so `bytesize` is not what it
rests on. A receiver that still shares its buffer with the copy is told
apart by the pointer. regexp_utf8.rb pins the reading: `s.replace(s.b)`
keeps every byte of the receiver and makes them byte-read, and the match
left behind counts its offsets in bytes, as CRuby's does after a
`force_encoding` in the block. In C the length check is one comparison
per turn and the copy test a pointer compare or a `memcmp` in the same
function, which is what a loop in mrblib could not have.

`sub!` had the same shape of difference on its own path. Its block form
went down to `sub`, which builds the answer from the snapshot the MatchData
holds, where `rb_str_sub_bang` splices the replacement into the receiver
as the block left it and refuses a change of length the same way:

```ruby
s = "hello"; s.sub!(/l/) { s.upcase!; "X" }
s = "abc"; s.sub!(/b/) { s << "zz"; "!" }
```

The block form of `sub!` now runs the block itself and splices into the
receiver by the byte offsets of the match. `sub` is unchanged: CRuby's
`rb_str_sub` works on a copy, so the block reaches nothing it reads.

The test in match_data.rb that pinned the republished MatchData now pins
CRuby's answer, and string_regexp.rb gains the return values and the
`RuntimeError`. The differential run of 3584 cases against CRuby 4.0.6
(the four block forms over 7 subjects, 8 patterns and 16 kinds of in-block
change) differs on 223 after this commit, all of them `scan`.
The block form of `String#scan` is a loop in mrblib of the shape `gsub`'s
used to be, and had the same two differences from CRuby's `rb_str_scan`
for a block that writes to the receiver: a change of length was let
through, where `str_mod_check` refuses one after the block returns, and
the match left in `$~` was the MatchData of the last match the loop had,
where CRuby searches once more from the offset that match was found from,
on the receiver as it stands when the loop is over:

```ruby
s = "hello"; s.scan(/l/) { s.upcase! }; $~ && $~[0]
# CRuby: nil, mruby: "l"
s = "hello"; s.scan(/l/) { s << "z" }
# CRuby: RuntimeError (string modified), mruby: "hellozz"
```

The loop now hands the length it began with to every search it makes
after the block, the next one and the closing one, and `Regexp.__byte_search`
takes it as a fourth argument and raises `RuntimeError` before it looks
where the receiver no longer has it, so the loop pays one argument per
search rather than one `bytesize` call per match. Nothing between the block
and that search reads a byte the offsets of the match could misname, so a
change of length is refused before it can reach the answer, and refused as
CRuby refuses it. The loop keeps the offset each match was found from and
the match itself, and ends on a search from that offset on the receiver as
the block left it, unless the receiver still reads as the copy that match
holds, in which case that search could only find the match again:
`MatchData#__republish(str)` is that test asked from mrblib, the one the C
loop of `gsub` makes for itself, and it publishes the match again and
answers true where the receiver reads as its subject, false where the loop
has to search.

`MatchData#__set_globals` goes with this: the `scan` loop was its last
caller, republishing the last match whatever the block had done to the
receiver, and `__republish` is what asks that question now.

The differential run of 3584 cases against CRuby 4.0.6 (the four block
forms over 7 subjects, 8 patterns and 16 kinds of in-block change) is at 0
after this commit, from 992 on master.
@takumin
takumin force-pushed the regexp-gsub-block-mutation branch from 9775cf0 to 1a5e26b Compare August 19, 2026 07:41
@takumin

takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master now that #7274 is merged. The branch is the two commits of this PR (8445f1cc6 for gsub and sub!, 1a5e26b46 for scan); the code is unchanged.

The one conflict was in test/string_regexp.rb: ba8a7c8 added an assertion to the test block this PR rewrites, the same-length replace under gsub(/(?=a)/) that tells reading the receiver's bytes again from reading the pointer the search was made with. That assertion and its comment now stand inside the rewritten test, as written there.

The body is updated to measure against master as it stands: the differential run (master 992, first commit 223, tip 0 of 3584), the test totals, the wall clock (now three columns, master / commit 1 / this PR) and the .text sizes (+720 on bintest, ascii-ctype and byte-string, +576 on cxx_abi, +608 on full-debug, the same deltas as before).

@matz
matz merged commit e63adb0 into mruby:master Aug 19, 2026
21 checks passed
@takumin
takumin deleted the regexp-gsub-block-mutation branch August 19, 2026 09:37
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