From cb572a7b6718e913b8a42b1e62cc09938f8bc6c1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 02:44:56 -0500 Subject: [PATCH] Pad ASCII strings without indexing their UTF-16 units `toUpperCase` and `toLowerCase` now record that a result of their ASCII path is ASCII. `padStart` and `padEnd` take the UTF-16 lengths of the receiver and the fill the way `length` does: a proven-ASCII or short string is counted directly instead of claiming a string-index entry. A one-unit fill repeats without a division, a one-byte fill with memset, and a result padded from an ASCII receiver and fill is recorded as ASCII. `word.slice(0, 3).toUpperCase().padStart(4, "-")` previously built an index entry for the three-byte receiver on every call. --- .../corpus/pad-case-converted-strings.ts.json | 6 +++ packages/runtime/src/scr_regex.c | 1 + packages/runtime/src/scr_string.c | 28 ++++++++++--- tests/corpus/pad-case-converted-strings.ts | 39 +++++++++++++++++++ .../pad-case-converted-strings.ts.json | 1 + 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json create mode 100644 tests/corpus/pad-case-converted-strings.ts create mode 100644 tests/harness/corpus-costs/pad-case-converted-strings.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json new file mode 100644 index 0000000000..6e10810ee1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pad-case-converted-strings.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 4296ce791e..47c56899cf 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -2161,6 +2161,7 @@ static ScrStr *scr_str_case_conv(const ScrStr *s, int to_lower) { out->data[i] = (char)(c >= first && c <= last ? c ^ 0x20 : c); } out->data[s->len] = '\0'; + scr_str_note_ascii(out); return out; } ScrJsonBuf b; diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index cfbb0f9808..a9138cedc4 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -2118,6 +2118,14 @@ double scr_str_utf16_len(ScrStr *s) { return (double)(int64_t)units; } +/* scr_str_utf16_len as a size: proven-ASCII and short strings are counted + * here, without the call. */ +static inline size_t scr_str_len16(ScrStr *s) { + if (s->ascii) return s->len; + if (s->len <= 64) return scr_utf16_units_short(s->data, s->len); + return (size_t)scr_str_utf16_len(s); +} + /* Records the UTF-16 length of a string whose producer counted it (latin1 * text has one unit per byte), so scr_str_utf16_len does not walk it. Only * strings that scr_str_utf16_len would otherwise record are kept. */ @@ -3137,15 +3145,17 @@ ScrStr *scr_str_replace_literal(ScrStr *s, ScrStr *search, ScrStr *rep, bool all static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, bool at_start) { double target = scr_to_integer_or_infinity(maxLength); - size_t len16 = scr_sidx_len(s, scr_sidx_for(s)); + size_t len16 = scr_str_len16(s); if (!(target > (double)len16) || fill->len == 0) return scr_str_retain(s); /* Reject pad sizes malloc could not satisfy before the double→size_t * conversion can overflow (each unit is at most 3 bytes here: BMP chars * and lone surrogates; astral chars are 4 bytes for 2 units). */ if (target > (double)((SIZE_MAX - sizeof(ScrStr) - 1) / 4)) scr_oom(); size_t pad16 = (size_t)target - len16; - size_t fill16 = scr_sidx_len(fill, scr_sidx_for(fill)); - size_t reps = pad16 / fill16, rem16 = pad16 % fill16; + size_t fill16 = scr_str_len16(fill); + /* A one-unit fill, the common case, needs no division. */ + size_t reps = fill16 == 1 ? pad16 : pad16 / fill16; + size_t rem16 = fill16 == 1 ? 0 : pad16 % fill16; /* The truncated prefix of fill: whole chars while they fit in rem16 * units; a final astral char that doesn't fit contributes its high * surrogate. */ @@ -3167,9 +3177,14 @@ static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, size_t total = s->len + pad_b; ScrStr *r = scr_str_alloc(total, total); char *w = r->data + (at_start ? 0 : s->len); - for (size_t i = 0; i < reps; i++) { - memcpy(w, fill->data, fill->len); - w += fill->len; + if (fill->len == 1) { + memset(w, fill->data[0], reps); + w += reps; + } else { + for (size_t i = 0; i < reps; i++) { + memcpy(w, fill->data, fill->len); + w += fill->len; + } } memcpy(w, fill->data, prefix_b); w += prefix_b; @@ -3179,6 +3194,7 @@ static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, } memcpy(at_start ? w : r->data, s->data, s->len); r->data[total] = '\0'; + if (s->ascii && fill->ascii) scr_str_note_ascii(r); /* Only a piece that starts with a lone low surrogate can complete a * pair across a seam: the fill (after a fill or the receiver) or the * receiver (after a fill ending in a high surrogate). */ diff --git a/tests/corpus/pad-case-converted-strings.ts b/tests/corpus/pad-case-converted-strings.ts new file mode 100644 index 0000000000..16d5ca2dc5 --- /dev/null +++ b/tests/corpus/pad-case-converted-strings.ts @@ -0,0 +1,39 @@ +// padStart and padEnd on the results of case conversion and slicing, with +// ASCII and non-ASCII receivers and fills, and index reads of the results. + +const words = ["alpha", "beta", "gamma", "Delta", "épée", "straße", "日本語", "a😀b"]; +const fills = ["-", "ab", "é", "😀", "", " "]; + +function describe(s: string): string { + return [ + JSON.stringify(s), + s.length, + s.charAt(1), + s.at(-1), + s.indexOf("A"), + s.slice(1, 4), + s.charCodeAt(s.length - 1), + s.codePointAt(0), + ].join(" "); +} + +for (const word of words) { + const upper = word.slice(0, 3).toUpperCase(); + const lower = word.toLowerCase(); + for (const fill of fills) { + console.log(describe(upper.padStart(6, fill))); + console.log(describe(lower.padEnd(9, fill))); + } + console.log(describe(upper.padStart(2)), describe(upper.padEnd(5))); +} + +// The same string padded again, compared and used as a key. +const counts = new Map(); +for (let i = 0; i < 40; i++) { + const key = words[i % words.length]!.slice(0, 3).toUpperCase().padStart(4, "-"); + counts.set(key, (counts.get(key) ?? 0) + 1); +} +console.log([...counts].map(([k, v]) => `${k}=${v}`).join(",")); +const padded = "abc".toUpperCase().padStart(5, "😀"); +console.log(padded.length, [...padded].length, padded === "\ud83dABC".padStart(5, "😀")); +console.log("x".toUpperCase().padEnd(4, "é").normalize("NFD").length); diff --git a/tests/harness/corpus-costs/pad-case-converted-strings.ts.json b/tests/harness/corpus-costs/pad-case-converted-strings.ts.json new file mode 100644 index 0000000000..dbdfb1c126 --- /dev/null +++ b/tests/harness/corpus-costs/pad-case-converted-strings.ts.json @@ -0,0 +1 @@ +{ "plain": 3600, "san": 3700 }