mruby-regexp: follow a block that changes the receiver in gsub, sub! and scan - #7267
Conversation
|
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 (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughRegexp 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. ChangesRegexp helper contracts and match state
String method dispatch
Regression coverage
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: ⚪ Minimal · up to 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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
This comment was marked as outdated.
This comment was marked as outdated.
✅ Action performedReview finished.
|
|
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. Before I merge I would like to ask about the closing
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 There is a second angle. Every search copies the subject into |
0349f04 to
5ddc10e
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment has been minimized.
This comment has been minimized.
This comment was marked as outdated.
This comment was marked as outdated.
584cd52 to
7a8a865
Compare
This comment was marked as outdated.
This comment was marked as outdated.
gsub block that changes the receivergsub, sub! and scan
7a8a865 to
9775cf0
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
✅ Action performedReview finished.
|
This comment was marked as outdated.
This comment was marked as outdated.
✅ Action performedReviews 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.
9775cf0 to
1a5e26b
Compare
|
Rebased onto master now that #7274 is merged. The branch is the two commits of this PR ( The one conflict was in 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 |
Rebased onto master now that #7274, which moves the block walk of
gsubinto 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 thegsubpart 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!andscanin mruby-regexp did not answer for a block that writes to the receiver the way CRuby'sstr_gsub,rb_str_sub_bangandrb_str_scando. Measured against CRuby 4.0.6:Three things were off in what the loops read and when. The
gsubloop 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 instr_mod_checkright 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 tosub, which builds the answer from the snapshot the MatchData holds, whererb_str_sub_bangsplices the replacement into the receiver as the block left it.Fix
Two commits.
The first takes
gsubandsub!.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 raisesRuntimeErrorwhere it differs (str_mod_checkcompares 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 dotr!andupcase!, which keep the length and change the bytes, and so doess.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 amemcmpin the same function.gsub!inherits all of it. The block form ofsub!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.subis unchanged: CRuby'srb_str_subworks 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, andRegexp.__byte_searchtakes it as a fourth argument and raisesRuntimeErrorbefore it looks where the receiver no longer has it, so the loop pays one argument per search rather than onebytesizecall per match; it keeps the offset each match was found from and the match itself, and ends on a search from that offset unlessMatchData#__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 agsubblock 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 ofgsub/gsub!/sub!/scanunder a block that changes the receiver in place,RuntimeErrorfor 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,subunaffected,FrozenErrorfor 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.clearands.chop!undergsub(/x*/)ands.clearunderscan(/x*/), which raiseRuntimeErroron CRuby. The assertion master gained in ba8a7c8, areplaceof the same length undergsub(/(?=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 aforce_encodingin 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 teston the default configuration at each commit, andMRUBY_CONFIG=build_config/ci/gcc-clang.rb rake -m testandMRUBY_CONFIG=build_config/gcc-asan.rb rake -m testat the tip, no sanitizer report.Cost
Wall clock, the cases of the earlier review plus the String pattern block forms and a
scanover 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.The
gsubrows: 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 tosub, which is the -20% and -37% on its two rows. Thescanrows carry the argument the search now parses and the__republishcall, 1% on the long subject and 5% on the short one, andscanon"abc"without a block,gsub(/o/, "0")andsub!(/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
.textofbin/mruby,build_config/ci/gcc-clang.rb, each side from a cleanbuild directory.
regexp.ois the object that changes, and it accounts for thewhole delta in every build (to within 5 bytes of alignment in
full-debug):the copy test and the closing search in
__gsub_block,__republishand thelength argument of
__byte_search. The mrblib side, the block form ofsub!and the closing search of
scan, is bytecode inmruby-regexp/gem_init.o's.rodata, which grows by 128 (160 infull-debug).bintestascii-ctypebyte-stringcxx_abifull-debug(-O0)On the default configuration, the one the wall clock was measured on,
.textis1,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
Actual compile line of
mrbgems/mruby-regexp/src/regexp.cin each build (-MMD -c,-I, and-odropped).full-debugandgcc-asanare-O0becauseenable_debugappends-g3 -O0after the toolchain's-g -O3;cxx_abicompiles C as C++ withgcc -x c++ -std=gnu++03, g++ only links.Summary by CodeRabbit
String#sub,gsub,scan, and bang variants when the source string is modified during processing.