string.c: step String#rindex by the characters String#length counts - #7107
Merged
Conversation
A negative `pos` counts characters back from the end, so minus the
length names the first character. The walk that turns it into a byte
offset stepped back that far and then read arriving at the string
start as having run out of string:
```ruby
"あいう".rindex("あ", -3) #=> nil (CRuby: 0)
"あいう".rindex("あ", -4) #=> nil
```
The two are one step apart and only the second is out of range. A
receiver that indexes by byte answers this correctly already, since
`String#rindex` hands it to `String#byterindex`, which adds the length
to `pos` rather than walking: `"abc".rindex("a", -3)` is 0.
Walk the same number of steps and refuse only the one that would leave
the string.
`char_adjust()` and `char_backtrack()` decided where a character
starts by looking for a byte that is not a continuation byte. Whether
the byte they found reaches that far they never asked, and that is the
whole of what `mrb_utf8len()` goes on to check, so the two disagreed
about what a character is wherever a lead byte promises more than it
delivers. `String#rindex` is all that walks by them, and it drifted
away from the count `String#length` and `String#chars` take:
```ruby
"あ\x80x".length #=> 3
"あ\x80x".index("\x80") #=> 1
"あ\x80x".rindex("\x80") #=> nil (CRuby: nil, and 1 for #index)
"あ\x80x".rindex("あ", -2) #=> nil (CRuby: 0)
```
`\x80` follows no lead byte that reaches it, so it is a character of
its own; stepping back from `x` landed on `あ` two characters away.
`char_adjust()` also rounded forward, which let a backward search
answer a position after the one it was given:
```ruby
"あ\x80x".rindex("x", 1) #=> 2 (CRuby: nil)
```
One helper does both jobs: `str_char_head()` answers the byte the
character covering `p` starts at, reading back at most three bytes to
a lead byte and keeping it only when `mrb_utf8len()` says it reaches.
Rounding is then always toward the start, and the character before
`p` is the head of `p-1`.
|
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)
📝 WalkthroughWalkthroughUTF-8 reverse substring searches and negative ChangesUTF-8 reverse indexing
Estimated code review effort: 3 (Moderate) | ~20 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 |
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
String#rindexwalks a character-indexed receiver backward, and two thingsabout that walk were wrong. Both show only there: an ASCII or binary receiver
is handed to
String#byterindex, which does not walk.Reaching the first character
A negative
poscounts characters back from the end, so minus the length namesthe first character. The walk stepped back that far and then read arriving at
the string start as having run out of string, which made
-lengthand one steppast it answer alike:
String#byterindexadds the length toposrather than walking, so it wasalready right:
"abc".rindex("a", -3)is 0.Where a character starts
char_adjust()andchar_backtrack()found a character start by looking for abyte that is not a continuation byte, and never asked whether the byte they
found reaches that far. That is the whole of what
mrb_utf8len()goes on tocheck, so the walk and the character count disagreed wherever a lead byte
promises more than it delivers:
\x80follows no lead byte that reaches it, so it is a character of its own,which is what
#lengthand#charscount it as. The walk stepped over it intoあinstead.char_adjust()also rounded forward.rindexwants the last start at orbefore
pos, so aposthat is not a boundary belongs rounded toward thestart; rounding the other way let a backward search answer a position after the
one it was given:
One helper
str_char_head(beg, p, end)replaces both: the byte the character coveringpstarts at, and
pitself whenpis already a boundary. It reads back at mostthree bytes to a lead byte and keeps that lead only when
mrb_utf8len()says itreaches. Rounding is then always toward the start, and the character before
pis the head of
p-1.The bounded lookback is the forward walk rather than an approximation of it. If
a character covers
pand startsbackbytes earlier, every byte in between isa continuation byte, so the nearest non-continuation byte going back is that
character's lead; and the lead cannot belong to some earlier character in turn,
since the lead byte of a multi-byte sequence is never a continuation byte. Three
bytes is the whole of the range, because nothing longer than four bytes spells a
character.
This is the rule mruby-regexp already applies in
mrb_re_utf8_interior_p(), socore and the regexp engine now agree on where a character starts.
Against CRuby
Over
rindexwith eleven subjects holding malformed sequences of each kind,seven needles that are valid UTF-8, and every position from
-length-1tolength+1, the five differences from CRuby 4.0.6 are the ones above. After thischange there are none.
Differences remain for a needle that is itself malformed: CRuby answers
nilfor those regardless of the subject, while mruby searches for the bytes.
String#indexanswers the same way, so that is a separate question about what amalformed argument means, and nothing here changes it.
Verified
rake teston a full-core build (MRB_UTF8_STRINGthrough mruby-encoding):2248 tests, all green
rake teston the default gembox (noMRB_UTF8_STRING): 2057 tests, allgreen
commit before it
prek run --all-filespasses, except thatmarkdownlintcould not installlocally (npm engine mismatch); no Markdown is touched here
Summary by CodeRabbit
Bug Fixes
Tests