Skip to content

string.c: drop a case walk's check that nothing reaches - #7191

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-drop-dead-case-check
Aug 15, 2026
Merged

string.c: drop a case walk's check that nothing reaches#7191
matz merged 1 commit into
mruby:masterfrom
takumin:string-drop-dead-case-check

Conversation

@takumin

@takumin takumin commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

mrb_str_case_convert_unicode(), the walk String#upcase, #downcase, #capitalize and #swapcase reach for where a string holds a character the case tables speak about, asks twice whether there is anything to walk:

  if (RSTR_BINARY_P(s) || str_ascii_p(s)) return -1;

  str_modify_keep_cr(mrb, s);
  if (RSTR_LEN(s) == 0 || RSTR_PTR(s) == NULL) return -1;

The second question has no answer left to give, and this drops it.

Why the first line has already answered

str_ascii_p() answers FALSE in one place only, out of its scan:

static mrb_bool
str_ascii_p(struct RString *s)
{
  if (RSTR_CODERANGE(s) == MRB_STR_CODERANGE_7BIT) return TRUE;

  const char *p = RSTR_PTR(s);
  const char *e = p + RSTR_LEN(s);
  if (search_nonascii(p, e) != e) return FALSE;
  RSTR_CODERANGE_SET(s, MRB_STR_CODERANGE_7BIT);
  return TRUE;
}

and search_nonascii() hands back something other than e only where it read a byte with the high bit set. So a string that gets past the first line has a length above zero and a pointer that was read through, which is both halves of the check below it. str_modify_keep_cr() changes neither: it unshares the buffer, which allocates, and clears a coderange that says broken.

An empty string leaves through the first line rather than the second. search_nonascii(p, e) does not enter its loop when p == e, so it hands back e, str_ascii_p() records the string as holding nothing but ASCII and answers TRUE, and the -1 sends the caller back to the byte loop it has of its own, where the same question waits:

  str_modify_keep_cr(mrb, s);
  char *p = RSTR_PTR(s);
  char *pend = RSTR_PTR(s) + len;
  if (len == 0 || p == NULL) return mrb_nil_value();

That is where the line came from: it arrived with the function, in dcddbd4, written beside those loops.

Generated code

gcc -O3, ci/gcc-clang bintest, against the same object built from master. The check is emitted, since nothing tells the compiler what str_ascii_p() answers: one test where the string is embedded and two where it is on the heap, all three jumping to the return -1 at a4a8 that the first line has already reached.

master, on return from str_modify_keep_cr():

    a50a:	8b 51 08             	mov    0x8(%rcx),%edx
    a50d:	89 d0                	mov    %edx,%eax
    a50f:	c1 e8 0c             	shr    $0xc,%eax
    a512:	80 e6 80             	and    $0x80,%dh
    a515:	0f 84 72 03 00 00    	je     a88d                <- on the heap
    a51b:	a9 f0 01 00 00       	test   $0x1f0,%eax         <- embedded length
    a520:	74 86                	je     a4a8                <- to return -1
    a88d:	4c 8b 71 10          	mov    0x10(%rcx),%r14     <- heap length
    a891:	4d 85 f6             	test   %r14,%r14
    a894:	0f 84 0e fc ff ff    	je     a4a8
    a89a:	48 8b 69 20          	mov    0x20(%rcx),%rbp     <- heap pointer
    a89e:	48 89 4c 24 08       	mov    %rcx,0x8(%rsp)
    a8a3:	48 85 ed             	test   %rbp,%rbp
    a8a6:	0f 84 fc fb ff ff    	je     a4a8

this branch, where the same two paths walk straight into the conversion:

    a4f7:	8b 51 08             	mov    0x8(%rcx),%edx
    a4fa:	89 d0                	mov    %edx,%eax
    a4fc:	c1 e8 0c             	shr    $0xc,%eax
    a4ff:	80 e6 80             	and    $0x80,%dh
    a502:	0f 84 75 03 00 00    	je     a87d
    a508:	c1 f8 04             	sar    $0x4,%eax           <- embedded length, used
    a87d:	4c 8b 71 10          	mov    0x10(%rcx),%r14     <- heap length, used
    a881:	49 8b 53 50          	mov    0x50(%r11),%rdx
    a885:	4c 89 df             	mov    %r11,%rdi
    a888:	be 12 00 00 00       	mov    $0x12,%esi
    a88d:	48 8b 69 20          	mov    0x20(%rcx),%rbp

Size

.text, master then this branch:

build string.o bin/mruby
full-debug 45487 to 45377, -110 1866118 to 1866006, -112
bintest 48768 to 48736, -32 1262662 to 1262630, -32
cxx_abi 50608 to 50576, -32 1288150 to 1288118, -32
byte-string 35184, unchanged 1239686, unchanged

The byte-indexed build is unchanged byte for byte: the walk is behind MRB_UTF8_STRING and is not compiled there at all.

Testing

rake -m test, all green with 0 KO, 0 crash, and no new warnings:

build result
full-debug 2311 tests, 2308 OK, 3 skip
bintest 2312 tests, 2301 OK, 11 skip, plus 117 bintests
cxx_abi 2312 tests, 2301 OK, 11 skip
byte-string 2243 tests, 2195 OK, 48 skip
build_config/asan.rb 2312 tests, 2309 OK, 3 skip, plus 79 bintests

Behaviour is compared directly as well. 1160 case conversions, over 29 receivers spanning empty, ASCII, multi-byte, title case, one-to-many ("ß", "fi"), astral, broken and truncated UTF-8, each in five forms (literal, dup, + "", [0, length], * 1) and through upcase, downcase, capitalize, swapcase and their ! forms, with the length, valid_encoding? and bytesize read afterwards: the output is identical to master byte for byte, and identical again under address,undefined with no report.

Relation to #7190

None, in either direction. #7190 adds an #elif defined(MRB_UTF8_STRING) arm after this function's closing brace, three lines below the line dropped here, and leaves the line itself alone. The two merge cleanly whichever lands first; this branch is on master.

Environment

Versions, and the compile line of every build named above
OS Ubuntu 24.04.4 LTS, Linux 7.0.0-28-generic x86_64
CPU AMD Ryzen 9 5950X, 16 cores
C compiler gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1)
Sanitizer compiler clang 22.1.8 (Homebrew), which is what build_config/asan.rb picks
Linker GNU ld 2.47.20260726, and g++ for cxx_abi
CRuby 4.0.6 (2026-07-14) +PRISM, running rake

What each build compiles src/string.c with, -MMD -c, the -I paths and -o stripped:

# ci/gcc-clang, full-debug
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRB_UNICODE_CASE -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# ci/gcc-clang, bintest
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK

# ci/gcc-clang, cxx_abi
gcc -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRB_USE_CXX_EXCEPTION -DMRB_USE_CXX_ABI -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# ci/gcc-clang, byte-string
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# build_config/asan.rb
clang -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -Wzero-length-array -fsanitize=address,undefined -g3 -O0 -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

-g -O3 is what the gcc toolchain sets. full-debug and the sanitizer build then append -g3 -O0 through enable_debug(), so those two are -O0, not -O3. cxx_abi is the C compiler driven as C++ with -x c++ -std=gnu++03, and g++ links it.

Summary by CodeRabbit

  • Bug Fixes
    • Improved Unicode case conversion handling for strings, including empty and null-related edge cases.

`mrb_str_case_convert_unicode()` asks twice whether the string it was
handed holds anything to walk:

```c
  if (RSTR_BINARY_P(s) || str_ascii_p(s)) return -1;

  str_modify_keep_cr(mrb, s);
  if (RSTR_LEN(s) == 0 || RSTR_PTR(s) == NULL) return -1;
```

The second question has no answer left to give. `str_ascii_p()` answers
FALSE only out of its scan, and the scan hands back a pointer other than
`e` only where it read a byte with the high bit set. A string that gets
past the first line therefore has a length above zero and a pointer that
was read through, and `str_modify_keep_cr()` changes neither: it unshares
the buffer, which allocates, and clears a coderange that says broken.

An empty string leaves through the first line rather than the second.
`search_nonascii(p, e)` does not enter its loop when `p == e`, so it
hands back `e`, `str_ascii_p()` records the string as holding nothing but
ASCII and answers TRUE, and the `-1` sends the caller back to the byte
loop it has of its own, where the same question waits:

```c
  if (len == 0 || p == NULL) return mrb_nil_value();
```

The line arrived with the function, in dcddbd4, where those loops are
what it was written beside.

Nothing tells the compiler what `str_ascii_p()` answers for an empty
string, so `string.o` carries the check: one test where the string is
embedded and two where it is on the heap, all three jumping to the
`return -1` that the first line has already reached.

```
  a51b: a9 f0 01 00 00       test   $0x1f0,%eax       # embedded length
  a520: 74 86                je     a4a8              # to return -1
  ...
  a88d: 4c 8b 71 10          mov    0x10(%rcx),%r14   # heap length
  a891: 4d 85 f6             test   %r14,%r14
  a894: 0f 84 0e fc ff ff    je     a4a8
  a89a: 48 8b 69 20          mov    0x20(%rcx),%rbp   # heap pointer
  a8a3: 48 85 ed             test   %rbp,%rbp
  a8a6: 0f 84 fc fb ff ff    je     a4a8
```

Dropping it takes 32 bytes of `.text` out of each build that compiles the
walk, and leaves a build whose strings index by byte where it was, since
the walk is behind `MRB_UTF8_STRING` and is not compiled there at all.
@takumin
takumin requested a review from matz as a code owner August 15, 2026 18:28
@github-actions github-actions Bot added the core label Aug 15, 2026
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 466665df-2e62-430e-94ea-8652beee6303

📥 Commits

Reviewing files that changed from the base of the PR and between c02991b and f1479f2.

📒 Files selected for processing (1)
  • src/string.c
💤 Files with no reviewable changes (1)
  • src/string.c

📝 Walkthrough

Walkthrough

The Unicode case-conversion path no longer returns early for empty strings or null pointers after binary and ASCII checks. It proceeds directly to str_modify_keep_cr and Unicode conversion.

Changes

Unicode case conversion

Layer / File(s) Summary
Simplify Unicode case conversion
src/string.c
Removed the redundant `RSTR_LEN(s) == 0

Estimated code review effort: 1 (Trivial) | ~2 minutes

Merge Risk: ⚪ Minimal · up to f1479

This change removes a redundant case-conversion check without changing behavior; the reported tests and sanitizer runs remain green, so no actionable merge-blocking risk remains.

Possibly related PRs

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 identifies the file and clearly describes removal of an unnecessary check from the case-conversion walk.
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 068b20e into mruby:master Aug 15, 2026
21 checks passed
@takumin
takumin deleted the string-drop-dead-case-check branch August 15, 2026 21:49
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