Skip to content

string.c: read String#chop! back from the last byte - #7122

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

string.c: read String#chop! back from the last byte#7122
matz merged 2 commits into
mruby:masterfrom
takumin:string-chop-char-head

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Problem

String#chop! finds the start of the last character by walking the whole string
from the front, one character at a time. That is O(n) for a single call and
quadratic for a loop that chops a string down. An ASCII receiver pays it too,
since the walk is not skipped for one.

const char* t = RSTR_PTR(s), *p = t;
const char* e = p + RSTR_LEN(s);
while (p<e) {
  mrb_int clen = mrb_utf8len(p, e);
  if (p + clen>=e) break;
  p += clen;
}
len = p - t;

Change

mrb_utf8_char_head() answers the same question from the other end, reading back
at most three bytes, since nothing longer than four bytes spells a character.

const char* t = RSTR_PTR(s);
const char* e = t + RSTR_LEN(s);
len = mrb_utf8_char_head(t, e-1, e) - t;

str_char_rindex() and mrb_str_rindex_m() already read a boundary this way.
The byte-indexed branch and the \r\n pair are untouched.

Why the two agree

Every byte after a lead byte inside a multi-byte character is a continuation
byte, since that is what mrb_utf8len() requires before it answers more than 1.
So the front walk can step over neither a lead byte nor a stray continuation
byte: it lands on each of them, and those are exactly the boundaries
mrb_utf8_char_head() reads back to. Whether a lead byte reaches a given
continuation byte is mrb_utf8len()'s answer on both sides, so an overlong
sequence, a surrogate, and a lead byte the string end cuts short are read the
same way by either.

Checked as well as argued. With both functions lifted into a standalone harness,
the old and new cuts agree on all 137,560 strings of length 1 to 4 over 19 bytes
chosen at the interesting boundaries (0x80, 0x8F, 0x90, 0x9F, 0xA0, 0xBF, 0xC0,
0xC1, 0xE0, 0xED, 0xF0, 0xF4, 0xF5, 0xFF among them) and on 2,000,000 random
byte strings of length 1 to 12. The same cases were then run through builds of
bin/mruby from before and after, byte for byte.

Measurements

gcc 13.3.0, -O3, x86_64, full-core gembox with MRB_UTF8_STRING.

before after
s.chop 1000 times, s = 100k characters (300 KB) 0.410 s 0.135 s
s.chop! 20,000 times down a 20k-character string 0.538 s 0.001 s
s.chop 1000 times, s = 90 KB of ASCII 0.237 s 0.009 s
s.chop! 20,000 times down 20 KB of ASCII 0.508 s 0.001 s

The first row still carries the copy chop makes, which is most of what is
left of it.

Tests

String#chop! had no case ending in a byte that spells no character, so one is
added in the commit before the change and passes on both sides of it: a stray
continuation byte, a string of nothing else, an overlong sequence and a
surrogate RFC 3629 forbids, and a truncated lead byte, next to a four-byte
character and the \r\n pair.

rake test with the config above: 2255 examples, 0 failures, 0 crashes. The
default host config passes its unit tests too (2061 examples, 0 failures); the
bintest failure it shows there (uninitialized constant digits::other_excl,
from mruby-range-ext) is present on an untouched tree and unrelated.

Summary by CodeRabbit

  • Bug Fixes

    • Improved String#chop! handling for UTF-8 strings, including invalid, truncated, and multibyte character sequences.
    • Preserved correct byte-based behavior for binary strings and non-UTF-8 configurations.
    • Ensured CRLF endings continue to be handled correctly.
  • Tests

    • Added coverage for UTF-8 edge cases and standalone continuation bytes.

`chop!` cuts at the last character, and every case that asks it does so of a
string ending in a character a decoder has a rule for. A byte that no lead byte
reaches spells no character with its neighbours, so it stands as a character of
its own, which is what `String#length` counts it as, and nothing asked whether
`chop!` cuts there too.

So ask it, of a stray continuation byte after a whole character, of a string
that holds nothing else, of an overlong sequence and a surrogate that RFC 3629
forbids, and of a lead byte the string end cuts short. The cases that already
held are asked alongside them: a four-byte character still goes at once, and
the `\r\n` pair is still taken together after one.
Finding the last character meant walking the whole string from the front, one
character at a time, which is O(n) for a single `chop!` and quadratic for a
loop that chops a string down. An ASCII receiver paid it too, since the walk is
not skipped for one. `mrb_utf8_char_head()` answers the same question from the
other end, reading back at most three bytes, since nothing longer than four
bytes spells a character.

The two agree by construction. Every byte after a lead byte inside a multi-byte
character is a continuation byte, since that is what `mrb_utf8len()` requires
before it answers more than 1. So the walk can step over neither a lead byte nor
a stray continuation byte: it lands on each of them, and those are the
boundaries `mrb_utf8_char_head()` reads back to. Whether a lead byte reaches a
given continuation byte is `mrb_utf8len()`'s answer on both sides, so an
overlong sequence, a surrogate, and a lead byte the string end cuts short are
read the same way by either. The test before this one asks for them.

`str_char_rindex()` and `mrb_str_rindex_m()` already read a boundary this way.
The comment on the byte-indexed `chop!` test described the front walk, and now
names the cut instead.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

String#chop! now finds the final UTF-8 character by scanning backward. New tests cover invalid, truncated, multibyte, continuation-byte, and CRLF cases. Binary-string test comments now describe byte-based behavior.

Changes

UTF-8 chop behavior

Layer / File(s) Summary
Backward UTF-8 boundary lookup
src/string.c
mrb_str_chop_bang uses mrb_utf8_char_head to find the final UTF-8 character boundary. Byte-indexed and non-UTF-8 behavior remains byte-based.
Regression tests and clarified comments
test/t/string.rb, mrbgems/mruby-string-ext/test/string.rb
UTF-8 tests cover invalid, truncated, continuation-byte, multibyte, and CRLF inputs. Binary-string comments describe byte-based behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • mruby/mruby#7084: Directly modifies mrb_str_chop_bang and related tests for UTF-8 final-character 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 identifies the main change: updating String#chop! to scan backward from the last byte.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/t/string.rb (1)

411-413: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for the invalid F4 boundary.

The test covers overlong, surrogate, and truncated sequences. It does not cover "\xF4\x90\x80\x80", where the first continuation byte exceeds the RFC 3629 limit. Add an assertion that "\xF4\x90\x80\x80".chop returns "\xF4\x90\x80".

Based on learnings, "\xF5\x80\x80\x80" is rejected before the 0xF4 first-continuation-byte check. Use "\xF4\x90\x80\x80" to exercise that branch.

Proposed regression assertion
   assert_equal "\xC0", "\xC0\x80".chop
   assert_equal "\xED\xA0", "\xED\xA0\x80".chop
+  assert_equal "\xF4\x90\x80", "\xF4\x90\x80\x80".chop
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/t/string.rb` around lines 411 - 413, Add an assertion alongside the
existing invalid UTF-8 cases in the string chopping tests to verify that
chopping "\xF4\x90\x80\x80" returns "\xF4\x90\x80". Use this exact F4 boundary
sequence to exercise the first-continuation-byte limit branch.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/t/string.rb`:
- Around line 411-413: Add an assertion alongside the existing invalid UTF-8
cases in the string chopping tests to verify that chopping "\xF4\x90\x80\x80"
returns "\xF4\x90\x80". Use this exact F4 boundary sequence to exercise the
first-continuation-byte limit branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dfadf90c-56b0-42a5-8414-cfc8b86b81bb

📥 Commits

Reviewing files that changed from the base of the PR and between 8d5ae77 and d1433cd.

📒 Files selected for processing (3)
  • mrbgems/mruby-string-ext/test/string.rb
  • src/string.c
  • test/t/string.rb

@matz
matz merged commit a9ff651 into mruby:master Aug 12, 2026
20 of 21 checks passed
@takumin
takumin deleted the string-chop-char-head branch August 12, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants