Skip to content

string.c: step String#rindex by the characters String#length counts - #7107

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:string-rindex-char-boundary
Aug 12, 2026
Merged

string.c: step String#rindex by the characters String#length counts#7107
matz merged 2 commits into
mruby:masterfrom
takumin:string-rindex-char-boundary

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

String#rindex walks a character-indexed receiver backward, and two things
about 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 pos counts characters back from the end, so minus the length names
the first character. The walk stepped back that far and then read arriving at
the string start as having run out of string, which made -length and one step
past it answer alike:

"あいう".rindex("あ", -3)  #=> nil  (CRuby: 0)
"あいう".rindex("あ", -4)  #=> nil

String#byterindex adds the length to pos rather than walking, so it was
already right: "abc".rindex("a", -3) is 0.

Where a character starts

char_adjust() and char_backtrack() found a character start by looking for a
byte 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 to
check, so the walk and the character count disagreed wherever a lead byte
promises more than it delivers:

"あ\x80x".length            #=> 3
"あ\x80x".chars             #=> ["あ", "\x80", "x"]
"あ\x80x".index("\x80")     #=> 1
"あ\x80x".rindex("\x80")    #=> nil
"あ\x80x".rindex("あ", -2)  #=> nil  (CRuby: 0)

\x80 follows no lead byte that reaches it, so it is a character of its own,
which is what #length and #chars count it as. The walk stepped over it into
instead.

char_adjust() also rounded forward. rindex wants the last start at or
before pos, so a pos that is not a boundary belongs rounded toward the
start; rounding the other way let a backward search answer a position after the
one it was given:

"あ\x80x".rindex("x", 1)  #=> 2  (CRuby: nil)

One helper

str_char_head(beg, p, end) replaces both: the byte the character covering p
starts at, and p itself when p is already a boundary. It reads back at most
three bytes to a lead byte and keeps that lead 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.

The bounded lookback is the forward walk rather than an approximation of it. If
a character covers p and starts back bytes earlier, every byte in between is
a 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(), so
core and the regexp engine now agree on where a character starts.

Against CRuby

Over rindex with eleven subjects holding malformed sequences of each kind,
seven needles that are valid UTF-8, and every position from -length-1 to
length+1, the five differences from CRuby 4.0.6 are the ones above. After this
change there are none.

Differences remain for a needle that is itself malformed: CRuby answers nil
for those regardless of the subject, while mruby searches for the bytes.
String#index answers the same way, so that is a separate question about what a
malformed argument means, and nothing here changes it.

Verified

  • rake test on a full-core build (MRB_UTF8_STRING through mruby-encoding):
    2248 tests, all green
  • rake test on the default gembox (no MRB_UTF8_STRING): 2057 tests, all
    green
  • each commit is green on its own, and the test added by each fails on the
    commit before it
  • prek run --all-files passes, except that markdownlint could not install
    locally (npm engine mismatch); no Markdown is touched here

Summary by CodeRabbit

  • Bug Fixes

    • Improved reverse substring searches for UTF-8 text, including searches starting at negative positions.
    • Corrected character-boundary handling for multibyte and invalid UTF-8 bytes.
    • Enforced position limits more consistently during backward searches.
  • Tests

    • Added coverage for UTF-8 reverse searches at character boundaries and with invalid byte sequences.

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`.
@takumin
takumin requested a review from matz as a code owner August 12, 2026 08:14
@github-actions github-actions Bot added the core label Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 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: f4938486-4557-4b73-8d7b-585fd64a2e68

📥 Commits

Reviewing files that changed from the base of the PR and between 6817857 and dddbab5.

📒 Files selected for processing (2)
  • src/string.c
  • test/t/string.rb

📝 Walkthrough

Walkthrough

UTF-8 reverse substring searches and negative String#rindex positions now use str_char_head for character-boundary traversal. Tests cover character boundaries, invalid UTF-8 bytes, negative positions, and position limits.

Changes

UTF-8 reverse indexing

Layer / File(s) Summary
UTF-8 character-boundary helper
src/string.c
Adds str_char_head, which identifies the character start covering a byte position using mrb_utf8len.
Reverse search integration and validation
src/string.c, test/t/string.rb
Uses str_char_head for reverse substring searches and negative positions. Tests cover invalid bytes, character alignment, negative indexing, and position limits.

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

Possibly related PRs

  • mruby/mruby#7075: Modifies String#rindex reverse-search behavior at the Ruby level.
  • mruby/mruby#7096: Modifies UTF-8 character-boundary and length handling in src/string.c.
  • mruby/mruby#7099: Modifies reverse String#rindex searching and UTF-8 boundary handling.

Suggested reviewers: matz

🚥 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 describes the main change: updating String#rindex to step by the characters counted by String#length.
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.

@matz
matz merged commit b609eb6 into mruby:master Aug 12, 2026
21 checks passed
@takumin
takumin deleted the string-rindex-char-boundary branch August 12, 2026 09:45
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