Skip to content

Share UTF-8 byte lengths and character heads between core and mruby-regexp - #7109

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:utf8-share-with-regexp
Aug 12, 2026
Merged

Share UTF-8 byte lengths and character heads between core and mruby-regexp#7109
matz merged 2 commits into
mruby:masterfrom
takumin:utf8-share-with-regexp

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

mruby-regexp carries its own answers to two questions core already answers:
how many bytes the character at a pointer takes, and where the character
covering a pointer starts. mrb_re_utf8_charlen() and core's mrb_utf8len()
agree byte for byte, and mrb_re_utf8_interior_p() applies the same rule as the
str_char_head() that #7107 arrived at.

They are separate because core keeps mrb_utf8len() behind MRB_UTF8_STRING,
and the regexp engine has no such guard: it reads UTF-8 whatever a build's
strings index by. That is not a hypothetical configuration. mruby-regexp is in
the stdlib gembox and mruby-encoding is not, so the stock default gembox
already builds the engine without MRB_UTF8_STRING
, decoding UTF-8 with its
own copy while core's sits compiled out.

What moves

MRB_UTF8_STRING says a string indexes by character. What a run of bytes spells
is a question apart from that, so the two functions that only answer it come out
from under the guard:

Nothing else follows. mrb_utf8_strlen(), the character and byte conversions
and the searches stay behind the guard, because those are the indexing.

What the engine drops

  • mrb_re_utf8_charlen() is deleted; mrb_re_charlen() and
    emit_char_bytes() call mrb_utf8len()
  • mrb_re_utf8_interior_p() becomes mrb_utf8_char_head(...) != s
  • mrb_re_utf8_decode() stays, since core offers no decoder, but takes its
    byte length from mrb_utf8len() too

The predicate keeps its own answer for a byte that starts a character, so most
of the positions the matcher tries stay a branch rather than a call. It also
stops reading *s at the end of the subject, where it used to lean on the
terminating NUL to answer the same way.

Equivalence

Checked by brute force rather than argued:

  • byte length, mrb_re_utf8_charlen() against mrb_utf8len(): 2,359,296 cases,
    both first bytes exhaustively, by nine representative tail bytes, by every
    available length from 1 to 4
  • the predicate, before against after: 33,338,661 positions, every buffer of up
    to five bytes over a 23-value alphabet covering each lead, continuation and
    discriminant class, at every position

No differences in either. The same run checks each head against a forward walk
from the start of the buffer, which is what makes the three-byte lookback exact
rather than an approximation of one: 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 that lead cannot
belong to an earlier character in turn.

Size

Deleting one copy costs less than exposing the other, in both directions:

build before after
full-core 1808085 1806989
default gembox (no MRB_UTF8_STRING) 1722079 1721479

size(1) text of bin/mruby, gcc -O2 on x86-64.

Base

Branched off #7107, which is still open, because str_char_head() arrives
there. The first two commits here are that PR and drop out once it merges.

Verified

  • rake test on a full-core build: 2248 tests, all green
  • rake test on the default gembox, which is the build where the engine reads
    UTF-8 without MRB_UTF8_STRING: 2057 tests, all green
  • each commit is green on its own
  • 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 UTF-8 character boundary handling for string indexing and reverse searches.
    • Corrected String#rindex behavior with negative positions, multibyte characters, invalid UTF-8 bytes, and boundary limits.
    • Improved regular-expression processing of valid and invalid UTF-8 sequences.
  • Tests
    • Added coverage for UTF-8-aware String#rindex behavior and edge cases.

@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: 6d0567fe-4117-4e40-a691-d6e731bf3dcd

📥 Commits

Reviewing files that changed from the base of the PR and between e33179b and 56a55f5.

📒 Files selected for processing (1)
  • include/mruby/internal.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • include/mruby/internal.h

📝 Walkthrough

Walkthrough

The change centralizes UTF-8 length and boundary handling in shared internal helpers. Regexp processing uses these helpers. String#rindex traverses valid and invalid UTF-8 data by character boundaries. Tests cover truncated multibyte sequences.

Changes

UTF-8 boundary handling

Layer / File(s) Summary
Shared UTF-8 helper contract and implementation
include/mruby/internal.h, src/string.c
The internal API exposes mrb_utf8_char_head and unconditional mrb_utf8len. The implementation validates UTF-8 sequences and resolves character boundaries.
Regexp UTF-8 helper integration
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_utf8.c, mrbgems/mruby-regexp/src/re_compile.c
Regexp code replaces its private UTF-8 length validator with mrb_utf8len and uses mrb_utf8_char_head for bounded interior detection.
UTF-8-aware String#rindex search
src/string.c, test/t/string.rb
Reverse traversal and negative offsets use mrb_utf8_char_head. Tests verify that truncated UTF-8 bytes are found individually by rindex.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant String_rindex
  participant mrb_utf8_char_head
  participant StringBuffer
  String_rindex->>mrb_utf8_char_head: resolve the previous character boundary
  mrb_utf8_char_head->>StringBuffer: inspect bytes and UTF-8 validity
  StringBuffer-->>mrb_utf8_char_head: provide byte boundary
  mrb_utf8_char_head-->>String_rindex: return the character head
Loading

Possibly related PRs

  • mruby/mruby#7097: Both changes centralize UTF-8 handling between core and mruby-regexp.
  • mruby/mruby#7099: Both changes modify UTF-8-aware reverse String#rindex handling.
  • mruby/mruby#7105: Both changes update shared UTF-8 validation centered on mrb_utf8len.

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 and concisely describes sharing UTF-8 byte-length and character-head logic between mruby core and mruby-regexp.
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 (2)
include/mruby/internal.h (1)

198-209: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the non-empty input precondition for mrb_utf8len.

mrb_utf8len reads str[0] before any bounds test. The comment does not state that str < end is required. mrb_utf8_char_head differs, because it tests p >= end first. State this difference so new callers do not pass an empty range.

📝 Proposed comment addition
 /* What a run of bytes spells, which every build answers the same way, whether
    or not its strings index by character. mrb_utf8len returns the byte length of
-   the character at `str`, and 1 for anything that spells no character: a byte
+   the character at `str`, which must satisfy `str < end`, and 1 for anything
+   that spells no character: a byte
    that starts no sequence, a sequence `end` cuts short, one whose continuation
    bytes are not continuation bytes, and one that RFC 3629 forbids (an overlong
    encoding, a UTF-16 surrogate, a code point above U+10FFFF).
🤖 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 `@include/mruby/internal.h` around lines 198 - 209, Update the comment for
mrb_utf8len to explicitly require a non-empty range, with str < end, because it
reads str[0] before checking bounds. Contrast this with mrb_utf8_char_head,
which safely handles p >= end, so callers do not pass an empty range to
mrb_utf8len.
test/t/string.rb (1)

662-682: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a truncated-sequence case.

The tests cover an orphan continuation byte, an overlong sequence, and a surrogate. They do not cover a lead byte whose sequence the string end cuts short. That path is the len > e - p branch of mrb_utf8len and it drives mrb_utf8_char_head back-scan behavior at the end of the buffer.

🧪 Proposed test addition
   assert_equal 3, "\xED\xA0\x80".length
   assert_equal 2, "\xED\xA0\x80".rindex("\x80")
+
+  # A lead byte the string end cuts short spells no character either.
+  assert_equal 2, "a\xE3\x81".length
+  assert_equal 1, "a\xE3\x81".rindex("\xE3")
 end if UTF8STRING

Confirm the expected length value against the core implementation before you apply this diff.

🤖 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 662 - 682, Add a truncated UTF-8 sequence case
to the String#rindex test, using a lead byte whose continuation sequence is cut
off at the string end. Confirm the expected String#length from mrb_utf8len, then
assert the length and relevant rindex behavior to exercise the
mrb_utf8_char_head end-of-buffer back-scan path alongside the existing
malformed-sequence cases.
🤖 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 `@include/mruby/internal.h`:
- Around line 198-209: Update the comment for mrb_utf8len to explicitly require
a non-empty range, with str < end, because it reads str[0] before checking
bounds. Contrast this with mrb_utf8_char_head, which safely handles p >= end, so
callers do not pass an empty range to mrb_utf8len.

In `@test/t/string.rb`:
- Around line 662-682: Add a truncated UTF-8 sequence case to the String#rindex
test, using a lead byte whose continuation sequence is cut off at the string
end. Confirm the expected String#length from mrb_utf8len, then assert the length
and relevant rindex behavior to exercise the mrb_utf8_char_head end-of-buffer
back-scan path alongside the existing malformed-sequence cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ccc71475-5bcf-47af-b0f5-eb7df369c2b9

📥 Commits

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

📒 Files selected for processing (6)
  • include/mruby/internal.h
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/re_utf8.c
  • src/string.c
  • test/t/string.rb

@takumin
takumin force-pushed the utf8-share-with-regexp branch from 59cf80c to e33179b Compare August 12, 2026 10:30
@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Both applied in e33179b, which is also rebased onto master now that #7107 has merged.

mrb_utf8len's precondition. Right that the comment left it unsaid. It now names the byte the function reads:

mrb_utf8len returns the byte length of the character at str, which has to be a byte of the string rather than end itself, and 1 for anything that spells no character: [...]

Every caller in the tree already holds to it: the walks test p < e before they step, and io.c returns early on an empty buffer.

The truncated sequence. Added, though not with the expected values the diff proposed. "a\xE3\x81".length is 3, not 2: \xE3 claims three bytes and the string end leaves it two, so mrb_utf8len answers 1 for it, and \x81 is then a continuation byte that no lead byte reaches, which is 1 as well.

  # A lead byte the string end cuts short reaches none of the bytes that
  # follow it, so those stand alone too.
  assert_equal 3, "a\xE3\x81".length
  assert_equal 1, "a\xE3\x81".rindex("\xE3")
  assert_equal 2, "a\xE3\x81".rindex("\x81")

It is worth having, since it is the case that pins end down to the end of the subject: measure that lead byte against a longer buffer and the length would come out 2.

Green after the change on both builds, 2248 tests on full-core and 2057 on the default gembox, and the first commit is green on its own.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@takumin Thanks for the detailed correction. The documented mrb_utf8len precondition is clear.

The revised truncated-sequence expectations are correct. \xE3 is truncated and counts as one character. The following \x81 byte is not part of a valid reachable sequence and also counts as one character. The test also verifies that callers pass the subject end as end.


✏️ Learnings added
Learnt from: takumin
URL: https://github.com/mruby/mruby/pull/7109

Timestamp: 2026-08-12T10:31:19.573Z
Learning: In mruby, `mrb_utf8len(const char *str, const char *end)` requires `str < end`; `str` must point to a byte in the subject, not to `end`. For an invalid or truncated UTF-8 sequence, it returns 1. In a subject such as `"a\xE3\x81"`, both the truncated lead byte and the following unreachable continuation byte count as separate characters, so `String#length` is 3.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: takumin
Repo: mruby/mruby PR: 7097
File: src/string.c:2503-2503
Timestamp: 2026-08-12T04:31:24.902Z
Learning: In mruby `src/string.c`, `mrb_str_rindex_m` must route strings with either `RSTR_SINGLE_BYTE_P` or `RSTR_BINARY_P` to `mrb_str_byterindex_m`. This preserves byte-based handling for negative offsets when a binary string has not computed its single-byte flag. `String#byterindex` must also scan bytes for binary strings.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

`MRB_UTF8_STRING` says that a string indexes by character. What a run
of bytes spells is a question apart from that, and mruby-regexp asks
it whatever the build does: it carries its own copy of both answers
because core's were behind the guard.

Move the two that are only about the bytes out from under it. Nothing
else follows: `mrb_utf8_strlen()`, the character and byte conversions
and the searches stay where they were, since those are the indexing.

`str_char_head()` is now `mrb_utf8_char_head()` and declared in
internal.h beside `mrb_utf8len()`, the function whose answer it is
built on.
`mrb_re_utf8_charlen()` and `mrb_re_utf8_interior_p()` answer the same
two questions as core's `mrb_utf8len()` and `mrb_utf8_char_head()`,
byte for byte. They exist because core kept both behind
`MRB_UTF8_STRING`, which the previous commit ended: the engine reads
UTF-8 whatever a build's strings index by, and the default gembox
already builds it that way, with mruby-regexp and without
mruby-encoding.

Call core's instead. `mrb_re_utf8_decode()` stays, since core offers
no decoder, but takes its byte length from `mrb_utf8len()` too.

The predicate keeps its own answer for a byte that starts a character.
That is most of the positions the matcher tries, and each of them is
now a branch rather than a call. It also stops reading `*s` at the end
of the subject, where it used to lean on the terminating NUL to answer
the same way.

Equivalence was checked by brute force: 2,359,296 cases for the byte
length (both first bytes exhaustively, by nine representative tails,
by every available length) and 33,338,661 positions for the predicate
(every buffer of up to five bytes over a 23-value alphabet covering
each lead, continuation and discriminant class). No differences. The
same run checks each head against a forward walk from the start of the
buffer, which is what makes the three-byte lookback exact rather than
an approximation of one.
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