Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions mrbgems/mruby-string-ext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ Example:

### `String#succ` (alias `String#next`)

Returns the successor to `str`. Increments the rightmost alphanumeric characters.
Returns the successor to `str`. Increments the rightmost alphanumeric character, carrying into the alphanumeric before it when it wraps. The carry crosses characters that are not alphanumeric, but not from a letter into a digit or from a digit into a letter; a new character goes in instead. A string with no alphanumeric increments its last character instead: a byte in a binary string (or in a build without `MRB_UTF8_STRING`), a code point in a UTF-8 string.

```ruby
str.succ #=> new_str
Expand All @@ -670,14 +670,25 @@ str.succ #=> new_str
Example:

```ruby
"a".succ #=> "b"
"z".succ #=> "aa"
"9".succ #=> "10"
"a9".succ #=> "b0"
"Az".succ #=> "Ba"
"zz".succ #=> "aaa"
"a".succ #=> "b"
"z".succ #=> "aa"
"9".succ #=> "10"
"a9".succ #=> "b0"
"Az".succ #=> "Ba"
"zz".succ #=> "aaa"
"1.9".succ #=> "2.0"
"1-z".succ #=> "1-aa"
"a-9".succ #=> "a-10"
"-".succ #=> "."
"\xff".b.succ #=> "\x01\x00"
"ÿ".succ #=> "Ā" (with MRB_UTF8_STRING)
"a、".succ #=> "b、" (with MRB_UTF8_STRING)
```

Which characters step and which carry, in a UTF-8 string: an ASCII letter or digit steps, and carries when it wraps. Punctuation, a symbol or a combining mark neither steps nor carries while the string holds a letter or digit; the walk passes over it, so `"a、".succ` is `"b、"`, `"a😀".succ` is `"b😀"` and `"e\u0301".succ` is `"f\u0301"`. Above ASCII, these are the code points of a table in the gem (`succ_symbol_bmp` and `succ_symbol_smp` in `src/string.c`) covering the punctuation, symbol and mark blocks around Latin and CJK text, the private use areas, the emoji, and the tags and variation selectors of plane 14. Any other character above ASCII steps as a letter when the next code point, or the one after it over a single symbol, is a character of the same byte length outside the table: `"aÿ".succ` is `"aĀ"`, `"1あ".succ` is `"1ぃ"`, `"Ö".succ` is `"Ø"`. It never carries.

CRuby knows every script's letters and digits, steps them within their script and wraps at its end, so `"ת".succ` is `"אא"` and `"az".succ` is `"ba"` there. mruby steps `"ת"` to `"\u05EB"`, the code point past the end; and where the code point after a letter is in the table, it leaves the letter and moves on to the left, so `"az".succ` is `"bz"`. Punctuation of other scripts, the Greek `;` or the Arabic `،`, is not in the table and steps as a letter does.

### `String#succ!` (alias `String#next!`)

Equivalent to `String#succ`, but modifies the receiver in place.
Expand Down
330 changes: 266 additions & 64 deletions mrbgems/mruby-string-ext/src/string.c
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,219 @@ int_chr(mrb_state *mrb, mrb_value num)
return mrb_nil_value();
}

/* String#succ steps the last character of a string, and it steps a run of
ASCII letters or digits the way an odometer does: the rightmost one that
can go up goes up, and one that cannot ('9', 'z', 'Z') wraps to the start of
its run and carries into the one before it. What a step did to a character
is one of these three, which is what CRuby's `enc_succ_char()` and
`enc_succ_alnum_char()` answer. */
enum succ_step {
SUCC_NOT_CHAR, /* not one this walk steps; left as it is, walk moves on */
SUCC_FOUND, /* stepped in place; the walk is done */
SUCC_WRAPPED /* wrapped to the start of its run; the walk carries on */
};

/* Where the character before `p` starts, and how many bytes the character at
`p` covers, in the reading the string has. A string read as bytes has a
character per byte, and so has every string of a build without
MRB_UTF8_STRING; a UTF-8 string steps by character, and a run of bytes in
it that spells no character has length 0 here, which is what the walks
below step over without touching. */
static char*
succ_prev_char(char *sbeg, char *p, char *e, mrb_bool chars)
{
#ifdef MRB_UTF8_STRING
if (chars) return (char*)mrb_utf8_char_head(sbeg, p - 1, e);
#else
(void)sbeg; (void)e; (void)chars;
#endif
return p - 1;
}

static mrb_int
succ_char_len(char *p, char *e, mrb_bool chars)
{
#ifdef MRB_UTF8_STRING
if (chars) {
mrb_int len = mrb_utf8len(p, e);
return (len == 1 && (unsigned char)*p >= 0x80) ? 0 : len;
}
#else
(void)p; (void)e; (void)chars;
#endif
return 1;
}

#ifdef MRB_UTF8_STRING
/* Whether `cp` is the code point that spells a character of `len` bytes after
the one at `p`: it is written there when it is. A surrogate spells no
character, and neither does the byte length growing, since a wider
character would move every character behind it. */
static mrb_bool
succ_utf8_write(char *p, mrb_int len, mrb_int cp)
{
char buf[4];

if (0xD800 <= cp && cp <= 0xDFFF) return FALSE;
if (mrb_utf8_to_buf(buf, cp) != len) return FALSE;
memcpy(p, buf, len);
return TRUE;
}

/* Runs of code points above ASCII that are neither letter nor digit: the
punctuation, symbol and mark blocks beside Latin and CJK text, and the
emoji. `succ_alnum()` passes over these to the letter or digit before
them, "a、" to "b、", and steps a letter over a run of one, "Ö" to "Ø"
across "×".

Each run holds only code points that CRuby's `succ` (Unicode 17.0.0)
steps as neither letter nor digit, and reaches as far as that holds: the
code point before it and the one after it are stepped there. What CRuby
steps inside these blocks, the subscript letters, the letterlike symbols
and Roman numerals, the circled letters, 々 〆 〇 and the Suzhou numerals,
lies between two runs. Runs of the same shape in other scripts, the Greek
and Hebrew punctuation, the Arabic and Indic signs, are not here.

The runs are in ascending order. Plane 1 is a second table with 0x10000
taken off each bound, so that both fit `uint16_t`. Above plane 1 the
letters are the CJK ideographs of planes 2 and 3, and from plane 14 up,
the tags, the variation selectors and the private use planes, there is
none, which `succ_symbol_p()` answers with a comparison. */
static const uint16_t succ_symbol_bmp[][2] = {
{ 0x0080, 0x00BF }, { 0x00D7, 0x00D7 }, { 0x00F7, 0x00F7 }, /* Latin-1 Supplement: controls, ¡ to ¿, ×, ÷ */
{ 0x02ED, 0x0362 }, /* Combining Diacritical Marks, and the modifier symbols before them */
{ 0x1FFD, 0x208F }, /* General Punctuation, Superscripts and Subscripts, and the Greek accents before them */
{ 0x209D, 0x2109 }, /* Currency Symbols, Combining Diacritical Marks for Symbols */
{ 0x2114, 0x2118 }, { 0x211E, 0x2123 }, { 0x2125, 0x2125 }, /* Letterlike Symbols, between its letters */
{ 0x2127, 0x2127 }, { 0x2129, 0x2129 }, { 0x212E, 0x212E },
{ 0x213A, 0x213B }, { 0x2140, 0x2144 }, { 0x214A, 0x215F }, /* .. and the fractions of Number Forms */
{ 0x2189, 0x24B5 }, /* Arrows through the circled numbers */
{ 0x24EA, 0x2BFF }, /* Box Drawing through Miscellaneous Symbols and Arrows */
{ 0x2E00, 0x3004 }, /* Supplemental Punctuation, the radicals,   、 。 〃 〄 */
{ 0x3008, 0x3020 }, { 0x302A, 0x3030 }, { 0x3036, 0x3037 }, /* CJK Symbols and Punctuation, between its letters */
{ 0x303D, 0x3040 },
{ 0x3097, 0x309C }, { 0x30A0, 0x30A0 }, { 0x30FB, 0x30FB }, /* the kana marks ゛ ゜ ゠ ・ */
{ 0x3200, 0x33FF }, /* Enclosed CJK Letters and Months, CJK Compatibility */
{ 0xE000, 0xF8FF }, /* Private Use Area */
{ 0xFDFC, 0xFE6F }, /* Variation Selectors through Small Form Variants */
{ 0xFEFD, 0xFF0F }, { 0xFF1A, 0xFF20 }, { 0xFF3B, 0xFF40 }, /* the byte order mark, fullwidth punctuation */
{ 0xFF5B, 0xFF65 },
{ 0xFFDD, 0xFFFF }, /* fullwidth signs, Specials */
};

static const uint16_t succ_symbol_smp[][2] = {
{ 0xEEBC, 0xF12F }, /* Mahjong Tiles through the digits of Enclosed Alphanumeric Supplement */
{ 0xF14A, 0xF14F }, { 0xF16A, 0xF16F }, /* between its squared and circled letters */
{ 0xF18A, 0xFBEF }, /* regional indicators, the emoji, Symbols for Legacy Computing */
};

static mrb_bool
succ_symbol_p(mrb_int cp)
{
const uint16_t (*t)[2];
size_t i, n;

if (cp < 0x10000) {
t = succ_symbol_bmp;
n = sizeof(succ_symbol_bmp) / sizeof(succ_symbol_bmp[0]);
}
else if (cp < 0x20000) {
t = succ_symbol_smp;
n = sizeof(succ_symbol_smp) / sizeof(succ_symbol_smp[0]);
cp -= 0x10000;
}
else {
return cp >= 0xE0000;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (i = 0; i < n; i++) {
if (cp < t[i][0]) return FALSE;
if (cp <= t[i][1]) return TRUE;
}
return FALSE;
}
#endif

/* Step the character at `p` as an alphanumeric, which is what String#succ
looks for first. An ASCII letter or digit steps within its run, and the
run's carry (its first member, or the digit after it) is left in `carry`
for the character before it. Anything else ASCII, and every byte of a
string read as bytes, is not one.

CRuby asks the encoding what a letter or digit is, so a Unicode letter
steps within its script, over a gap of one code point, and wraps at the
script's end, "ת" to "אא". This build has no table of the letters; it
has one of what is not a letter, `succ_symbol_p()`, over the blocks that
most often sit beside one. A UTF-8 character above ASCII in the table is
not one, and the walk passes over it: "a、" to "b、". One outside the
table steps as a letter when the next code point, or the one after that
over a symbol, is a character of the same byte length outside the table:
"aÿ" to "aĀ", "1あ" to "1ぃ" and "Ö" to "Ø" as CRuby does. It never
carries. Where the code point after it is in the table it is at its
script's end, which CRuby wraps to a start this build cannot name, so it
is left as it is and the walk goes on to the left, "az" to "bz" where
CRuby says "ba". Where CRuby wraps at a script's end the table does not
reach, or skips a gap in a script, this steps to the code point past the
end or in the gap, "ת" to U+05EB. A character that ends its byte length,
U+007F, U+07FF, U+FFFF and U+10FFFF, is not a letter here, and it is not
one in Unicode either. */
static enum succ_step
succ_alnum(char *p, mrb_int len, char *carry)
{
unsigned char c = (unsigned char)*p;

if (len == 1) {
if (!ISALNUM(c)) return SUCC_NOT_CHAR;
if (c == '9') { *p = '0'; *carry = '1'; return SUCC_WRAPPED; }
if (c == 'z') { *p = 'a'; *carry = 'a'; return SUCC_WRAPPED; }
if (c == 'Z') { *p = 'A'; *carry = 'A'; return SUCC_WRAPPED; }
*p = (char)(c + 1);
return SUCC_FOUND;
}
#ifdef MRB_UTF8_STRING
{
mrb_int l;
mrb_int cp = (mrb_int)mrb_utf8_decode(p, p + len, &l);
mrb_int next = cp + 1;
if (succ_symbol_p(cp)) return SUCC_NOT_CHAR;
if (succ_symbol_p(next)) next++;
if (succ_symbol_p(next)) return SUCC_NOT_CHAR;
if (succ_utf8_write(p, len, next)) return SUCC_FOUND;
}
#endif
return SUCC_NOT_CHAR;
}

/* Step the character at `p` as a character, which is what String#succ falls
back to when the string holds no alphanumeric: the next one that spells a
character of the same byte length. A byte steps to the next byte and 0xFF
wraps to 0x00; a UTF-8 character steps to the next code point, over the
surrogates, and wraps to the first character of its byte length ("\u{7FF}"
to "\u{80}") where the next would take one more. */
static enum succ_step
succ_char(char *p, mrb_int len, mrb_bool chars)
{
#ifdef MRB_UTF8_STRING
if (chars) {
static const mrb_int first_of_len[] = { 0, 0, 0x80, 0x800, 0x10000 };
mrb_int l;
mrb_int cp = (mrb_int)mrb_utf8_decode(p, p + len, &l);
mrb_int next = cp + 1;
if (next == 0xD800) next = 0xE000;
if (succ_utf8_write(p, len, next)) return SUCC_FOUND;
succ_utf8_write(p, len, first_of_len[len]);
return SUCC_WRAPPED;
}
#else
(void)len; (void)chars;
#endif
if ((unsigned char)*p == 0xFF) {
*p = 0;
return SUCC_WRAPPED;
}
(*p)++;
return SUCC_FOUND;
}

/*
* call-seq:
* string.succ -> string
Expand All @@ -928,77 +1141,66 @@ int_chr(mrb_state *mrb, mrb_value num)
static mrb_value
str_succ_bang(mrb_state *mrb, mrb_value self)
{
mrb_value result;
const char *prepend;
struct RString *s = mrb_str_ptr(self);

if (RSTRING_LEN(self) == 0)
return self;
mrb_int slen = RSTR_LEN(s);
char *sbeg, *e, *p, *last_alnum = NULL;
mrb_bool chars = FALSE, found_alnum = FALSE;
char carry = '\1';
mrb_int carry_pos = 0;
enum succ_step step = SUCC_FOUND;

mrb_str_modify(mrb, s);
mrb_int l = RSTRING_LEN(self);
unsigned char *p, *e, *b, *t;
b = p = (unsigned char*) RSTRING_PTR(self);
t = e = p + l;
*(e--) = 0;

// find trailing ascii/number
while (e >= b) {
if (ISALNUM(*e))
break;
e--;
}
if (e < b) {
e = p + l - 1;
result = mrb_str_new_lit(mrb, "");
}
else {
// find leading letter of the ascii/number
b = e;
while (b > p) {
if (!ISALNUM(*b) || (ISALNUM(*b) && *b != '9' && *b != 'z' && *b != 'Z'))
break;
b--;
}
if (!ISALNUM(*b))
b++;
result = mrb_str_new(mrb, (char*) p, b - p);
}

while (e >= b) {
if (!ISALNUM(*e)) {
if (*e == 0xff) {
mrb_str_cat_lit(mrb, result, "\x01");
(*e) = 0;
}
else
(*e)++;
if (slen == 0) return self;
#ifdef MRB_UTF8_STRING
chars = !RSTR_BINARY_P(s);
#endif
sbeg = RSTR_PTR(s);
e = sbeg + slen;

/* The rightmost alphanumeric steps; one that wraps carries into the
alphanumeric before it, across whatever is not one, but a letter does not
carry into a digit nor a digit into a letter across such a gap: "1.9" is
"2.0" and "a-z" is "b-a", while "1-z" is "1-aa". */
p = e;
while (p > sbeg) {
mrb_int len;
p = succ_prev_char(sbeg, p, e, chars);
if (step == SUCC_NOT_CHAR && last_alnum &&
(ISALPHA(*last_alnum) ? ISDIGIT(*p) : ISALPHA(*p))) {
break;
}
prepend = NULL;
if (*e == '9') {
if (e == b) prepend = "1";
*e = '0';
}
else if (*e == 'z') {
if (e == b) prepend = "a";
*e = 'a';
}
else if (*e == 'Z') {
if (e == b) prepend = "A";
*e = 'A';
}
else {
(*e)++;
break;
len = succ_char_len(p, e, chars);
if (len == 0) continue;
step = succ_alnum(p, len, &carry);
if (step == SUCC_NOT_CHAR) continue;
if (step == SUCC_FOUND) return self;
last_alnum = p;
found_alnum = TRUE;
carry_pos = p - sbeg;
}

/* No alphanumeric: the last character steps instead, and one that wraps
carries into the character before it. */
if (!found_alnum) {
p = e;
while (p > sbeg) {
mrb_int len;
p = succ_prev_char(sbeg, p, e, chars);
len = succ_char_len(p, e, chars);
if (len == 0) continue;
if (succ_char(p, len, chars) == SUCC_FOUND) return self;
carry_pos = p - sbeg;
}
if (prepend) mrb_str_cat_cstr(mrb, result, prepend);
e--;
}
result = mrb_str_cat(mrb, result, (char*) b, t - b);
l = RSTRING_LEN(result);
mrb_str_resize(mrb, self, l);
memcpy(RSTRING_PTR(self), RSTRING_PTR(result), l);

/* Everything that could carry has wrapped, so the carry goes in before the
leftmost character that did: "zz" to "aaa", "\xff\xff".b to
"\x01\x00\x00", and "\xff" read as UTF-8, which spells nothing to step,
to "\x01\xff". */
mrb_str_resize(mrb, self, slen + 1);
sbeg = RSTR_PTR(s);
memmove(sbeg + carry_pos + 1, sbeg + carry_pos, slen - carry_pos);
sbeg[carry_pos] = carry;
return self;
}

Expand Down
Loading
Loading