string.c: read String#chop! back from the last byte - #7122
Conversation
`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.
📝 WalkthroughWalkthrough
ChangesUTF-8 chop behavior
Estimated code review effort: 2 (Simple) | ~10 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 |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
test/t/string.rb (1)
411-413: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for the invalid
F4boundary.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".chopreturns"\xF4\x90\x80".Based on learnings,
"\xF5\x80\x80\x80"is rejected before the0xF4first-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
📒 Files selected for processing (3)
mrbgems/mruby-string-ext/test/string.rbsrc/string.ctest/t/string.rb
Problem
String#chop!finds the start of the last character by walking the whole stringfrom 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.
Change
mrb_utf8_char_head()answers the same question from the other end, reading backat most three bytes, since nothing longer than four bytes spells a character.
str_char_rindex()andmrb_str_rindex_m()already read a boundary this way.The byte-indexed branch and the
\r\npair 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 givencontinuation byte is
mrb_utf8len()'s answer on both sides, so an overlongsequence, 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/mrubyfrom before and after, byte for byte.Measurements
gcc 13.3.0,
-O3, x86_64,full-coregembox withMRB_UTF8_STRING.s.chop1000 times,s= 100k characters (300 KB)s.chop!20,000 times down a 20k-character strings.chop1000 times,s= 90 KB of ASCIIs.chop!20,000 times down 20 KB of ASCIIThe first row still carries the copy
chopmakes, which is most of what isleft of it.
Tests
String#chop!had no case ending in a byte that spells no character, so one isadded 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\npair.rake testwith the config above: 2255 examples, 0 failures, 0 crashes. Thedefault host config passes its unit tests too (2061 examples, 0 failures); the
bintestfailure 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
String#chop!handling for UTF-8 strings, including invalid, truncated, and multibyte character sequences.Tests