Skip to content

string.c: keep one answer about a string's bytes, not three flags - #7158

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:string-coderange-accessors
Aug 14, 2026
Merged

string.c: keep one answer about a string's bytes, not three flags#7158
matz merged 2 commits into
mruby:masterfrom
takumin:string-coderange-accessors

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7157, which is the first commit here. The second commit is this PR's own change, and the diff to read is git diff <first commit>..HEAD. Merging #7157 first leaves this one a single commit.

Reading a string's bytes as the encoding it carries comes back with one of four answers: not asked yet, nothing but ASCII, read whole and sound, read and found broken. include/mruby/string.h keeps them as three flags, MRB_STR_SINGLE_BYTE, MRB_STR_VALID_ENC and MRB_STR_BROKEN_ENC, each with a predicate, a set, an unset and a copy of its own.

No writer sets two of them, so the answers are exclusive already. What the three flags leave to their readers is the map from combinations back to answers:

/* src/string.c, mrb_str_valid_encoding_p(): SINGLE_BYTE implies VALID_ENC,
   so the reader answers off one flag and then writes the other. */
if (RSTR_SINGLE_BYTE_P(s)) {
  RSTR_SET_VALID_ENC_FLAG(s);
  return TRUE;
}

/* src/string.c, str_replace(): a copy stands where its source stands,
   spelled once per flag. */
RSTR_COPY_SINGLE_BYTE_FLAG(s1, s2);
RSTR_COPY_VALID_ENC_FLAG(s1, s2);
RSTR_COPY_BROKEN_ENC_FLAG(s1, s2);

This PR gives the answer a name of its own. The three bits stay exactly where they are and the accessors are written over them, so what changes is the vocabulary and not the layout.

What goes into include/mruby/string.h

#define MRB_STR_CODERANGE_UNKNOWN 0
#define MRB_STR_CODERANGE_7BIT    1
#define MRB_STR_CODERANGE_VALID   2
#define MRB_STR_CODERANGE_BROKEN  3

#ifdef MRB_UTF8_STRING
# define RSTR_CODERANGE(s) \
  (((s)->flags & MRB_STR_BROKEN_ENC) ? MRB_STR_CODERANGE_BROKEN : \
   ((s)->flags & MRB_STR_SINGLE_BYTE) ? MRB_STR_CODERANGE_7BIT : \
   ((s)->flags & MRB_STR_VALID_ENC) ? MRB_STR_CODERANGE_VALID : \
   MRB_STR_CODERANGE_UNKNOWN)
# define RSTR_CODERANGE_SET(s, cr) /* writes all three bits */
#else
# define RSTR_CODERANGE(s) MRB_STR_CODERANGE_7BIT
# define RSTR_CODERANGE_SET(s, cr) ((void)0)
#endif

#define RSTR_ENC_CR_COPY(dst, src)
#define RSTR_ENC_CR_COPY_FOR_SUBSTR(dst, src)

The four answers are the set CRuby closes on in ENC_CODERANGE_UNKNOWN, _7BIT, _VALID and _BROKEN, and the names follow. UNKNOWN is 0 for the same reason it is 0 there: a fresh string is filled with zeroes and has been asked nothing.

The values are small integers rather than the flag positions CRuby uses, because here they are not positions of anything: they sit over three bits that are not adjacent, and MRB_STR_SINGLE_BYTE|MRB_STR_VALID_ENC is not the bit pattern of any answer. A build without MRB_UTF8_STRING reads every byte as a character and asks the bytes nothing, which is where 7BIT stands, so RSTR_CODERANGE() is that constant there and RSTR_CODERANGE_SET() records nothing. That is what RSTR_SINGLE_BYTE_P() and RSTR_VALID_ENC_P() being TRUE said before.

Two copies, and why they carry the encoding too

The encoding travels with the answer in one macro rather than beside it in another, since a copy that keeps one and drops the other is how flags went missing on copy paths before (f71bd22 and the whole of #7136 are that shape: a path that copied the bytes and left the flag behind on the original). The two copies stay outside the #ifdef, so a byte indexed build still carries the encoding across.

There are two because a subrange is not an exact copy. A cut can leave a character in pieces, and it can cut away the piece that spelled none, so a subrange inherits neither soundness nor brokenness. Nothing but ASCII survives being cut anywhere, so 7BIT is the one answer mrb_str_byte_subseq() carries over. CRuby splits the same two ways, in rb_enc_cr_str_exact_copy() and rb_enc_cr_str_copy_for_substr(); the second of those also walks the subrange to promote VALID to 7BIT, which is not what this code does today and is not added here.

The sites

kind count where
reads 16 RSTR_SINGLE_BYTE_P 14, RSTR_VALID_ENC_P 1, RSTR_BROKEN_ENC_P 1
writes 15 the sets and unsets of the three flags
copies 7 into 3 mrb_str_byte_subseq(), str_replace(), String#*
RSTR_SET_ASCII_FLAG 12 src/object.c (4), mruby-string-ext (3), src/symbol.c (2), src/numeric.c (2), mruby-time (1)

RSTR_SET_ASCII_FLAG() was an alias for RSTR_SET_SINGLE_BYTE_FLAG(), and it goes with the flag it aliased. Its callers say RSTR_CODERANGE_SET(s, MRB_STR_CODERANGE_7BIT), so there is one word for the answer rather than two.

Writes where one flag became three bits

RSTR_CODERANGE_SET() writes all three bits where the old sets wrote one, so every converted write is a place where clearing the other two has to lose nothing. It does, because no writer sets two of them and 7BIT is the one answer that implies another. Three are worth naming:

  • mrb_str_valid_encoding_p() no longer writes "sound" onto a string that is already nothing but ASCII. It is the one write that goes rather than moves.
  • mrb_str_modify_keep_ascii() and mrb_str_modify() are where every in-place write passes and where the answer stops holding. As a pair they took soundness and brokenness away and then took ASCII away as well; now the first keeps 7BIT and writes UNKNOWN over anything else, and the second writes UNKNOWN. str_modify_cat(), which reaches neither when it appends into a shared buffer, writes UNKNOWN in one go instead of clearing three bits.
  • String#* still refuses to call a nought times repetition broken. The receiver's answer is copied and then taken back off the empty result, which is where it stood before.

Generated code

Two full-core builds, with mruby-encoding and with the gem removed from the box, gcc -O3, measured against the tree with #7157 alone:

build .text over the whole build
full-core without mruby-encoding unchanged, every object identical instruction for instruction
full-core, MRB_UTF8_STRING 1789338 -> 1787914 (-1424)

Where the UTF-8 build moves:

object .text
src/string.o 50944 -> 49328 (-1616)
mruby-string-ext 19813 -> 19893 (+80)
src/symbol.o 10496 -> 10544 (+48)
src/object.o 6152 -> 6200 (+48)
mruby-time 10277 -> 10293 (+16)

Six objects change at all, and src/numeric.o changes without growing. The four that grow are the files whose only change is RSTR_SET_ASCII_FLAG() becoming a write of one answer: a bit set turns into a read, a mask and an or. Inside src/string.o the sites this PR touches move by tens of bytes in both directions (mrb_str_times() +68, mrb_str_char_len() +64, mrb_str_modify_keep_ascii() +64, mrb_str_valid_encoding_p() -41), and the largest single movers are functions this PR does not touch at all: mrb_str_resize() 1057 -> 208 and mrb_str_to_s() 665 -> 110, where the inliner rearranges around a str_replace() that is now four macro expansions shorter.

Testing

rake -m test on both builds above, plus the C++ ABI build, all green:

  • full-core with MRB_UTF8_STRING: 2291 tests, 2281 OK, 10 skip, 0 KO, 0 crash, 0 warnings.
  • full-core without mruby-encoding, where strings index by byte: 2229 tests, 2200 OK, 29 skip, 0 KO, 0 crash, 0 warnings.
  • the same box with enable_cxx_abi: 2291 tests, 2281 OK, 10 skip.

No test accompanies the change. No string answers anything different, so there is nothing new to pin.

Not in this PR

The three bits are not folded into a two bit field, and none of them moves. MRB_STR_SINGLE_BYTE, MRB_STR_VALID_ENC and MRB_STR_BROKEN_ENC keep their values and are now named only inside the two accessors.

Summary by CodeRabbit

  • Improvements
    • Improved internal handling of string encoding and character validity.
    • String operations now better preserve encoding information when copying, slicing, combining, and transforming text.
    • Enhanced handling for ASCII, UTF-8, binary, valid, and invalid string content across common string operations.

`MRB_STR_BINARY` is the whole of the answer to how a string's bytes are
read: a string carrying it is read by byte, a string without it is read
the way the build reads strings by default. The macros around it are
named after the bit rather than after the question, so every write names
one value of the field instead of the field itself:

    RSTR_SET_BINARY_FLAG(s);
    RSTR_UNSET_BINARY_FLAG(s);
    RSTR_COPY_BINARY_FLAG(dst, src);

`include/mruby/string.h` now names the field and the values it takes:

    #define MRB_STR_ENCODING_DEFAULT 0
    #define MRB_STR_ENCODING_BINARY  1
    #ifdef MRB_UTF8_STRING
    # define MRB_STR_ENCODING_UTF8   MRB_STR_ENCODING_DEFAULT
    #endif

    #define RSTR_ENCODING(s)
    #define RSTR_ENCODING_SET(s, e)
    #define RSTR_ENC_COPY(dst, src)

and the 22 writes and copies go through them. `RSTR_BINARY_P()` stays,
since whether a string is read by byte is a question about the encoding
and not about the shape it is kept in. It reads the field now and
answers 1 rather than 16, which is the narrowing `re_binary_string_p()`
was doing on the way out.

Index 0 is the build's default because a fresh string is zero filled:
the strings the parser hands over, and the ones numbers and times spell
themselves with, then say what they are without a store on the path
every string is made on. `__ENCODING__` names index 0 for a given build,
so it is UTF-8 where `MRB_UTF8_STRING` is set and ASCII-8BIT where it is
not.

Binary cannot share index 0 with the default in a build that indexes by
byte anyway. `String#b` and `Integer#chr` mark strings there too, and
`mruby-regexp` reads the mark through `re_binary_string_p()` to decide
whether to walk the subject a byte at a time, so folding the two
together would change what that build answers.

`MRB_STR_ENCODING_UTF8` is defined only where the build carries UTF-8. A
build without it has no UTF-8 string to name, so naming one should stop
the compiler rather than quietly write the default. The only place that
names it is `String#force_encoding`, in the gem that sets the define.

Every write is bit for bit the write it replaces, so no answer changes
and no test comes with this. Building full-core with `mruby-encoding`
and without it, every object file is identical instruction for
instruction to the one master builds, and `.text` over the whole build
is unchanged in both.
Reading a string's bytes as the encoding it carries comes back with one
of four answers: not asked yet, nothing but ASCII, read whole and sound,
read and found broken. `include/mruby/string.h` kept them as three
flags, `MRB_STR_SINGLE_BYTE`, `MRB_STR_VALID_ENC` and
`MRB_STR_BROKEN_ENC`, each with a predicate, a set, an unset and a copy
of its own. No writer sets two of them, so the answers are exclusive
already, and what the flags leave to the readers is the map from
combinations back to answers: `mrb_str_valid_encoding_p()` answered TRUE
off `MRB_STR_SINGLE_BYTE` and then wrote `MRB_STR_VALID_ENC` beside it
because the first implies the second, and `str_replace()` wrote three
copies in a row so that a copy would stand where its source stood.

The answer gets a name of its own, spelled as CRuby spells it in
`ENC_CODERANGE_UNKNOWN` and the rest:

    #define MRB_STR_CODERANGE_UNKNOWN 0
    #define MRB_STR_CODERANGE_7BIT    1
    #define MRB_STR_CODERANGE_VALID   2
    #define MRB_STR_CODERANGE_BROKEN  3

    RSTR_CODERANGE(s)
    RSTR_CODERANGE_SET(s, cr)
    RSTR_ENC_CR_COPY(dst, src)
    RSTR_ENC_CR_COPY_FOR_SUBSTR(dst, src)

The three bits stay exactly where they are and the accessors are written
over them, so what changes here is the vocabulary and not the layout.
UNKNOWN is 0 because a fresh string is filled with zeroes and has been
asked nothing.

The encoding travels with the answer in one macro rather than two, since
a copy that keeps one and drops the other is how flags went missing on
copy paths before. There are two of them because a subrange is not an
exact copy: a cut can leave a character in pieces, and it can cut away
the piece that spelled none, so a subrange inherits neither soundness
nor brokenness. Nothing but ASCII survives being cut anywhere, so 7BIT
is the one answer `mrb_str_byte_subseq()` carries over. CRuby splits the
same two ways, in `rb_enc_cr_str_exact_copy()` and
`rb_enc_cr_str_copy_for_substr()`.

`RSTR_SET_ASCII_FLAG()` goes with the flag it aliased, and its 11
callers in `src/symbol.c`, `src/object.c`, `src/numeric.c`,
`mruby-time` and `mruby-string-ext` say `MRB_STR_CODERANGE_7BIT`
instead, so there is one word for the answer rather than two.

`RSTR_CODERANGE_SET()` writes all three bits where the old sets wrote
one, so each converted write is a place where clearing the other two has
to lose nothing. Three are worth naming:

  * `mrb_str_valid_encoding_p()` no longer writes "sound" onto a string
    that is already nothing but ASCII, since 7BIT says that too. It is
    the one write that goes rather than moves.
  * `mrb_str_modify_keep_ascii()` and `mrb_str_modify()` are where every
    in-place write passes and where the answer stops holding. As a pair
    they took soundness and brokenness away and then took ASCII away as
    well; now the first keeps 7BIT and writes UNKNOWN over anything
    else, and the second writes UNKNOWN.
  * `String#*` still refuses to call a nought times repetition broken.
    The receiver's answer is copied and then taken back off the empty
    result, which is where it stood before.

A build without `MRB_UTF8_STRING` reads every byte as a character and
asks the bytes nothing, so `RSTR_CODERANGE()` there is 7BIT and
`RSTR_CODERANGE_SET()` records nothing, which is what
`RSTR_SINGLE_BYTE_P()` and `RSTR_VALID_ENC_P()` being TRUE said. The two
copies stay outside the define, so the encoding still travels there.

No answer changes, so no test comes with this. Building full-core with
`mruby-encoding` and without it, the byte indexed build is identical
instruction for instruction to what master builds. The UTF-8 build loses
1,424 bytes of `.text` over the whole build: `src/string.o` loses 1,616,
and 4 files gain between 16 and 80 bytes where a one bit set became a
three bit write. The largest single movers inside `src/string.o` are
`mrb_str_resize()` and `mrb_str_to_s()`, which are the inliner
rearranging around a smaller `str_replace()` rather than code that went
away where it stood.
@coderabbitai

coderabbitai Bot commented Aug 14, 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: cf3c5182-c6c3-406e-bc89-5f5c6042538c

📥 Commits

Reviewing files that changed from the base of the PR and between 8bd5bd0 and f51a52e.

📒 Files selected for processing (11)
  • include/mruby/string.h
  • mrbgems/mruby-encoding/src/encoding.c
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-sprintf/src/sprintf.c
  • mrbgems/mruby-string-bitops/src/string_bitops.c
  • mrbgems/mruby-string-ext/src/string.c
  • mrbgems/mruby-time/src/time.c
  • src/numeric.c
  • src/object.c
  • src/string.c
  • src/symbol.c

📝 Walkthrough

Walkthrough

The string metadata API now separates encoding from coderange. Core string operations and related gems use the new accessors, setters, and copy helpers. ASCII, UTF-8, binary, and broken states use the unified coderange model.

Changes

String metadata migration

Layer / File(s) Summary
Metadata contract and copy macros
include/mruby/string.h
Defines coderange and encoding constants, accessors, setters, and propagation macros. Removes the legacy flag-based macros.
Core string coderange state
src/string.c
Updates validation, indexing, mutation, concatenation, repetition, escaping, replacement, and binary propagation to use coderange and encoding metadata.
Encoding and derived-string integration
mrbgems/mruby-encoding/src/encoding.c, mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-sprintf/src/sprintf.c, mrbgems/mruby-string-bitops/src/string_bitops.c, mrbgems/mruby-string-ext/src/string.c
Migrates encoding assignments, metadata copies, character processing, scrubbing, slicing, partitioning, padding, and related paths to the new APIs.
ASCII metadata producers
src/numeric.c, src/object.c, mrbgems/mruby-time/src/time.c, src/symbol.c
Marks generated ASCII strings with MRB_STR_CODERANGE_7BIT through RSTR_CODERANGE_SET.

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

Merge Risk: ⚪ Minimal · up to f51a5

This change consolidates string encoding state behind shared accessors without an identified current-head correctness or integration issue; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: consolidating three string encoding flags into one coderange representation.
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.

@takumin
takumin force-pushed the string-coderange-accessors branch from b938640 to f51a52e Compare August 14, 2026 07:35
@matz
matz merged commit 5cfa6ae into mruby:master Aug 14, 2026
21 checks passed
@takumin
takumin deleted the string-coderange-accessors branch August 14, 2026 07:52
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