Iterate strings over their bytes (#2233)

This commit is contained in:
Chris Tate
2026-10-25 14:14:06 -05:00
committed by GitHub
30 changed files with 499 additions and 153 deletions
+1
View File
@@ -32,6 +32,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
| `regex-replacers` | cpu | regex `replace`/`replaceAll` with inline callbacks whose parameters have no annotations: dictionary placeholders, snake_case to camelCase, date reordering, optional captures, capitalization, and a string search that reads the offset |
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
| `string-iteration` | cpu | code point iteration over text that mixes ASCII, Latin-1 letters, CJK and emoji: a for-of word tokenizer, spread reversal, and `Array.from` over words |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative |
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
@@ -0,0 +1,6 @@
{
"entry": "workloads/string-iteration.ts",
"args": [
"1"
]
}
@@ -0,0 +1,59 @@
// Code point iteration over mixed text: a word tokenizer over for-of, a
// character histogram, reversal through spread, and Array.from over
// words. The text mixes ASCII, Latin-1 letters, CJK and emoji.
let seed = 11;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const pieces = [
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog",
"café", "naïve", "über", "señor", "straße", "déjà", "vu",
"日本", "語", "テキスト", "中文", "한국어",
"😀", "🚀", "👍", "🎉",
];
const punctuation = [" ", " ", " ", ", ", ". ", "! ", "\n"];
const scale = Number(process.argv[2] ?? "1");
const lines: string[] = [];
for (let i = 0; i < Math.floor(4000 * scale); i++) {
let line = "";
const words = 6 + Math.floor(random() * 10);
for (let w = 0; w < words; w++) {
line += pieces[Math.floor(random() * pieces.length)]!;
line += punctuation[Math.floor(random() * punctuation.length)]!;
}
lines.push(line);
}
function isWordChar(c: string): boolean {
const code = c.codePointAt(0)!;
return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) || code >= 0xc0;
}
let tokens = 0;
let tokenUnits = 0;
let reversedLength = 0;
let fromLength = 0;
const histogram = new Map<string, number>();
for (let round = 0; round < 30; round++) {
for (const line of lines) {
let word = "";
for (const c of line) {
if (isWordChar(c)) {
word += c;
continue;
}
if (word !== "") {
tokens++;
tokenUnits += word.length;
if (round === 0) histogram.set(word, (histogram.get(word) ?? 0) + 1);
fromLength += Array.from(word).length;
word = "";
}
}
reversedLength += [...line].reverse().join("").length;
}
}
const top = [...histogram].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 5);
console.log(top.map(([word, count]) => `${word}:${count}`).join(" "));
console.log("tokens", tokens, "units", tokenUnits, "reversed", reversedLength, "from", fromLength);
@@ -50,11 +50,13 @@ import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js";
import { emitScratchStringSlice, emitStringSliceRead } from "./string-slices.js";
import {
STRING_CHAR_AT_DECL,
STRING_CP_AT_BYTE_DECL,
STRING_CHAR_AT_INT_MARKER,
STRING_CHAR_CODE_AT_DECL,
STRING_CHAR_CODE_AT_INT_MARKER,
emitAsciiChar,
emitAsciiCharCode,
emitStringByteLength,
emitUnitCharCode,
STRING_UTF16_LEN_DECL,
} from "./string-reads.js";
@@ -565,16 +567,30 @@ export function emitStrIntrinsic(
return call("scr_str_is_well_formed", "zeroext i1 (ptr)", `ptr ${r.name}`, "i1", false);
case "toWellFormed":
return call("scr_str_to_well_formed", "ptr (ptr)", `ptr ${r.name}`, "ptr", true);
case "cpAt":
// The code point AT an index as a one-code-point string (+1) —
// the string-for-of desugar's read.
return call(
"scr_str_cp_at",
"ptr (ptr, double)",
`ptr ${r.name}, double ${args[0]!.name}`,
"ptr",
true,
);
case "byteLength": {
const t = B.tmp();
// A byte count fits in a signed size, whose conversion is one instruction.
B.line(`${t} = sitofp ${host.sizeType} ${emitStringByteLength(host, r.name)} to double`);
return { name: t, type: e.type };
}
case "cpAtByte": {
// sc_str_cp_at_byte (string-reads.ts) answers ASCII bytes from the
// charAt table inline. The cursor is a character start, an exact
// integer below the byte count.
host.declare(STRING_CHAR_AT_DECL);
host.declare(STRING_CP_AT_BYTE_DECL);
const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
let wide: string;
if (index) {
wide = widenInteger(host, index);
} else {
wide = B.tmp();
B.line(`${wide} = fptosi double ${args[0]!.name} to i64`);
}
const t = B.tmp();
B.line(`${t} = call ptr @sc_str_cp_at_byte(ptr ${r.name}, i64 ${wide})`);
return host.own({ name: t, type: e.type });
}
default: {
const _exhaustive: never = method;
void _exhaustive;
@@ -104,6 +104,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"str.localeCase": "scr_str_locale_case",
"str.replaceLiteral": "scr_str_replace_literal",
"str.replaceChars": "scr_str_replace_chars",
"str.codePoints": "scr_str_code_points",
"num.fromString": "scr_string_to_number",
"math.round": "scr_math_round",
"math.sin": "sin",
@@ -341,7 +341,7 @@ const methodCases: {
{ method: "normalize", args: [ref("argument")], result: STRING, target: "normalize" },
{ method: "isWellFormed", args: [], result: BOOL, target: "is_well_formed" },
{ method: "toWellFormed", args: [], result: STRING, target: "to_well_formed" },
{ method: "cpAt", args: [num(1)], result: STRING, target: "cp_at" },
{ method: "cpAtByte", args: [num(1)], result: STRING, target: "cp_at_byte", inline: true },
];
test.each(methodCases)(
@@ -30,7 +30,8 @@ export function borrowsStringInputs(method: IrStrIntrinsicMethod): boolean {
case "normalize":
case "isWellFormed":
case "toWellFormed":
case "cpAt":
case "byteLength":
case "cpAtByte":
return true;
default:
return false;
@@ -43,6 +43,10 @@ export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int";
export const STRING_CHAR_AT_DECL = "declare ptr @scr_str_char_at(ptr, double)";
/** Requests the integer-index charAt helper, as the marker above does. */
export const STRING_CHAR_AT_INT_MARKER = "; uses @sc_str_char_at_int";
/** The code-point character starting at a byte offset (the string
* iterator's step). Requested together with STRING_CHAR_AT_DECL, which
* defines the ASCII table. */
export const STRING_CP_AT_BYTE_DECL = "declare ptr @scr_str_cp_at_byte(ptr, i64)";
/** The proven-ASCII flag of string `s`, as an i1. */
export function emitStringAsciiFlag(host: LlvmEmitterContext, s: string): string {
@@ -351,6 +355,29 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
];
},
},
{
// The string iterator's step at byte `%b`, a character start below the
// byte count: an ASCII byte is the table entry for it; any other lead
// byte takes the runtime, which copies the character's bytes.
decl: STRING_CP_AT_BYTE_DECL,
define: (sz, attrs) => [
`define internal ptr @sc_str_cp_at_byte(ptr %s, i64 %b) ${attrs} {`,
`entry:`,
` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
` %p = getelementptr inbounds i8, ptr %data, i64 %b`,
` %byte = load i8, ptr %p`,
` %ascii = icmp sge i8 %byte, 0`,
` br i1 %ascii, label %read, label %slow`,
`read:`,
` %code = zext i8 %byte to i64`,
` %char = getelementptr inbounds [128 x { ${sz}, ${sz}, ${sz}, [2 x i8] }], ptr @sc_ascii_chars, i64 0, i64 %code`,
` ret ptr %char`,
`slow:`,
` %r = call ptr @scr_str_cp_at_byte(ptr %s, i64 %b) nounwind willreturn`,
` ret ptr %r`,
`}`,
],
},
{
// An exact integer index, as for sc_str_char_code_at_int. Requested
// together with STRING_CHAR_AT_DECL, which defines the table.
@@ -1081,9 +1081,7 @@ export function lowerArrayFromCall(
if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) {
const source = lowerer.lowerExpr(args[0]!);
let src =
source.type.kind === "string" && args.length === 2
? strCharsCall(lowerer, source, loc)
: source;
source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source;
if (src.type.kind === "array") {
const arrT = src.type;
if (args.length === 1) {
@@ -1171,7 +1169,7 @@ export function lowerArrayFromCall(
}
// String iteration advances by code point; the same helper serves
// `[...s]` and both Array.from(s) forms.
if (src.type.kind === "string" && args.length === 1) return strCharsCall(lowerer, src, loc);
if (src.type.kind === "string" && args.length === 1) return strCharsCall(src, loc);
if (src.type.kind === "dyn" && args.length === 1) {
return lowerCheckedArrayFrom(lowerer, src, loc);
}
@@ -1318,112 +1316,9 @@ function buildArrayFromArrayFn(
}
/** `Array.from(s)` / `[...s]` on a STRING: the code-point split into a
* fresh string[], through one interned helper per module. */
export function strCharsCall(lowerer: Lowerer, src: IrExpr, loc: SrcLoc): IrExpr {
const key = "strChars";
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = "%str.chars";
lowerer.arrHofHelpers.set(key, helper);
lowerer.liftedFns.push(buildStrCharsFn(helper, loc));
}
return { kind: "call", callee: helper, args: [src], type: arrayOf(STRING), loc };
}
/** The code-point split, from existing IR nodes — the string for-of
* desugar's UTF-16 cursor as a function:
*
* out = [];
* i = 0;
* while (i < s.length) { ch = cpAt(s, i); i += ch.length; out.push(ch); }
* return out;
*/
function buildStrCharsFn(name: string, loc: SrcLoc): IrFunction {
const outT = arrayOf(STRING);
const sLen = (recv: IrExpr): IrExpr => ({
kind: "strIntrinsic",
method: "length",
receiver: recv,
args: [],
type: F64,
loc,
});
const body: IrStmt[] = [
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: outT, loc },
loc,
},
{ kind: "varDecl", localId: "i.0", init: { kind: "numLit", value: 0, type: F64, loc }, loc },
{
kind: "while",
cond: {
kind: "bin",
op: "<",
left: varRef("i.0", F64, loc),
right: sLen(varRef("s.0", STRING, loc)),
type: BOOL,
loc,
},
body: [
{
kind: "varDecl",
localId: "ch.0",
init: {
kind: "strIntrinsic",
method: "cpAt",
receiver: varRef("s.0", STRING, loc),
args: [varRef("i.0", F64, loc)],
type: STRING,
loc,
},
loc,
},
{
kind: "assign",
localId: "i.0",
value: {
kind: "bin",
op: "+",
left: varRef("i.0", F64, loc),
right: sLen(varRef("ch.0", STRING, loc)),
type: F64,
loc,
},
loc,
},
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: varRef("out.0", outT, loc),
args: [varRef("ch.0", STRING, loc)],
type: F64,
loc,
},
loc,
},
],
loc,
},
{ kind: "return", value: varRef("out.0", outT, loc), loc },
];
return {
name,
params: [{ localId: "s.0", name: "s", type: STRING }],
returnType: outT,
locals: [
{ id: "s.0", name: "s", type: STRING, mutable: true },
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "ch.0", name: "ch", type: STRING, mutable: false },
],
body,
loc,
};
* fresh string[] (scr_str_code_points). */
export function strCharsCall(src: IrExpr, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "str.codePoints", args: [src], type: arrayOf(STRING), loc };
}
/** The lowered `length` value of the one-property source literal, or null
@@ -157,7 +157,8 @@ const PURE_STRING = new Set<string>([
"toUpperCase",
"isWellFormed",
"toWellFormed",
"cpAt",
"byteLength",
"cpAtByte",
]);
function primitive(t: IrType): boolean {
@@ -1135,7 +1135,7 @@ function setFromSeedValue(
if (source.type.kind === "set" && typeEquals(source.type, setT)) {
return { kind: "setIntrinsic", method: "clone", receiver: source, args: [], type: setT, loc };
} else if (source.type.kind === "string" && setT.elem.kind === "string") {
seed = strCharsCall(lowerer, source, loc);
seed = strCharsCall(source, loc);
} else if (source.type.kind === "array" && typeEquals(source.type.elem, setT.elem)) {
seed = source;
} else if (
@@ -504,7 +504,7 @@ export function lowerArrayLiteral(
// interned helper as Array.from(s); the result rides the array
// machinery below like any string[] source.
if (src.type.kind === "string") {
src = strCharsCall(lowerer, src, locOf(el));
src = strCharsCall(src, locOf(el));
}
// `[...typedArray]`: represented typed arrays are dense numeric
// iterables, so drain their elements into the fresh number[] that
@@ -577,10 +577,11 @@ export function lowerBuiltinDelegation(
loc,
});
} else if (sourceT.kind === "string") {
// A byte cursor over the WTF-8 data, as for-of over a string walks.
const ch = lowerer.declareHiddenLocal("%delegateElement", STRING);
const length = (receiver: IrExpr): IrExpr => ({
kind: "strIntrinsic",
method: "length",
method: "byteLength",
receiver,
args: [],
type: F64,
@@ -595,7 +596,7 @@ export function lowerBuiltinDelegation(
localId: ch.id,
init: {
kind: "strIntrinsic",
method: "cpAt",
method: "cpAtByte",
receiver: src(),
args: [at()],
type: STRING,
@@ -743,7 +743,7 @@ export function lowerStringIteratorValue(
left: index,
right: {
kind: "strIntrinsic",
method: "length",
method: "byteLength",
receiver: text,
args: [],
type: F64,
@@ -758,7 +758,7 @@ export function lowerStringIteratorValue(
localId: "point.0",
init: {
kind: "strIntrinsic",
method: "cpAt",
method: "cpAtByte",
receiver: text,
args: [index],
type: STRING,
@@ -775,7 +775,7 @@ export function lowerStringIteratorValue(
left: index,
right: {
kind: "strIntrinsic",
method: "length",
method: "byteLength",
receiver: point,
args: [],
type: F64,
@@ -2668,7 +2668,7 @@ export function lowerBindingPattern(
lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts");
const loc = locOf(el);
let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc, (text) =>
strCharsCall(lowerer, text, loc),
strCharsCall(text, loc),
);
if (!value)
lowerer.unsupported(
@@ -2798,7 +2798,7 @@ export function lowerBindingPattern(
}
// STRING sources (`const [a, b] = str`): array destructuring walks
// the STRING ITERATOR — code points, astral characters whole —
// which is Array.from(string)'s own machinery (%str.chars), so the
// which is Array.from(string)'s own machinery (str.codePoints), so the
// source splits once into a hidden chars array and the pattern
// lowers as an array pattern over string[]. Positions, holes,
// defaults (with the bounds test), rest (the remaining code points
@@ -2817,7 +2817,7 @@ export function lowerBindingPattern(
out.push({
kind: "varDecl",
localId: chars.id,
init: strCharsCall(lowerer, srcRef(), loc),
init: strCharsCall(srcRef(), loc),
loc,
});
lowerer.lowerBindingPattern(
@@ -9826,7 +9826,7 @@ function lowerArrayAssignInto(
let srcType = tmpRef().type;
// A STRING source (`[a, b] = s`): the declaration path's rule in
// assignment position — the string iterator's code-point split
// (%str.chars, astral characters whole) into a hidden chars array,
// (str.codePoints, astral characters whole) into a hidden chars array,
// then the pattern proceeds as an array pattern over string[]
// (defaults with the bounds test, rest packing the remaining code
// points, elisions; positions past the last code point inherit the
@@ -9839,7 +9839,7 @@ function lowerArrayAssignInto(
out.push({
kind: "varDecl",
localId: chars.id,
init: strCharsCall(lowerer, tmpRef(), loc),
init: strCharsCall(tmpRef(), loc),
loc,
});
tmpRef = () => ({ kind: "varRef", localId: chars.id, type: charsT, loc });
@@ -11383,16 +11383,18 @@ function lowerForOfMatchAll(
/** `for (const ch of s)` over a STRING: JS's string iterator, which walks
* code POINTS — each pass binds the whole character (a two-unit string
* for astral chars, where charAt/slice would return one surrogate half).
* Desugars over the UTF-16 cursor machinery: a hidden unit
* index reads the character with the cpAt intrinsic and advances by ITS
* length before the body runs (so `continue` never skips the advance):
* Desugars over a byte cursor into the WTF-8 data: a hidden byte offset
* reads the character that starts there with the cpAtByte intrinsic and
* advances by ITS byte count before the body runs (so `continue` never
* skips the advance):
*
* { const %s = <expr>; let %i = 0;
* while (%i < %s.length) { const ch = cpAt(%s, %i); %i += ch.length; <body> } }
* while (%i < byteLength(%s)) { const ch = cpAtByte(%s, %i); %i += byteLength(ch); <body> } }
*
* Strings are immutable, so the length is stable and there is no
* Strings are immutable, so the byte count is stable and there is no
* iterator state to unwind on throw; a lone surrogate is a one-unit
* character, so every step lands on a character start. */
* character with its own three-byte encoding, so every step lands on a
* character start. */
/** for-of over a CLASS ITERABLE (classIteratorOf's shape) — the
* iterator protocol as ordinary method calls:
*
@@ -12087,7 +12089,7 @@ function lowerForOfString(
try {
const s = lowerer.declareHiddenLocal("%strof", STRING);
const i = lowerer.declareHiddenLocal("%iterof", F64);
i.mutable = true; // the unit index reassigns (hidden locals default const)
i.mutable = true; // the byte cursor reassigns (hidden locals default const)
const sRef = (): IrExpr => ({ kind: "varRef", localId: s.id, type: STRING, loc });
const iRef = (): IrExpr => ({ kind: "varRef", localId: i.id, type: F64, loc });
// `for (var c of str)`: the character mechanics stay on a hidden
@@ -12118,7 +12120,7 @@ function lowerForOfString(
localId: ch.id,
init: {
kind: "strIntrinsic",
method: "cpAt",
method: "cpAtByte",
receiver: sRef(),
args: [iRef()],
type: STRING,
@@ -12145,7 +12147,7 @@ function lowerForOfString(
left: iRef(),
right: {
kind: "strIntrinsic",
method: "length",
method: "byteLength",
receiver: chRef,
args: [],
type: F64,
@@ -12177,7 +12179,7 @@ function lowerForOfString(
left: iRef(),
right: {
kind: "strIntrinsic",
method: "length",
method: "byteLength",
receiver: sRef(),
args: [],
type: F64,
@@ -848,6 +848,7 @@ function buildLibFnSignatures() {
// (subject, characters, NUL-separated replacement texts): the fused
// chain of global one-character replacements.
"str.replaceChars": { argTypes: [STRING, STRING, STRING], result: STRING },
"str.codePoints": { argTypes: [STRING], result: arrayOf(STRING) },
/* ToNumber(string) — ECMA-262 7.1.4.1 StringToNumber (scr_string.c):
* trim the JS StrWhiteSpace set, empty/whitespace-only → +0, then the
* whole span must be one StrNumericLiteral — signed decimal (Infinity
@@ -867,6 +867,8 @@ export function analyzeIntegerRangeFacts(
);
if (e.type.kind === "f64") {
if (e.method === "length") range = { min: 0, max: STRING_MAX_UNITS };
// A string holds at most SCR_STR_MAX_CAP (0x7fffffff) bytes.
else if (e.method === "byteLength") range = { min: 0, max: STRING_MAX_UNITS };
// An empty needle is found at the clamped position, up to the length.
else if (e.method === "indexOf") range = { min: -1, max: STRING_MAX_UNITS };
}
@@ -31,7 +31,8 @@ export const STR_INTRINSIC_SIGS: Record<
normalize: { argTypes: [STRING], minArgs: 1, result: STRING },
isWellFormed: { argTypes: [], minArgs: 0, result: BOOL },
toWellFormed: { argTypes: [], minArgs: 0, result: STRING },
cpAt: { argTypes: [F64], minArgs: 1, result: STRING },
byteLength: { argTypes: [], minArgs: 0, result: F64 },
cpAtByte: { argTypes: [F64], minArgs: 1, result: STRING },
};
/** Per-method signature for regexIntrinsic. test/source/flags take the
+7 -4
View File
@@ -2451,10 +2451,13 @@ export type IrStrIntrinsicMethod =
// surrogate, and the copy with each one replaced by U+FFFD.
| "isWellFormed"
| "toWellFormed"
// The string iterator's step: the full code-POINT character at a UTF-16
// index (astral chars come back whole where charAt truncates) — for-of
// over strings desugars onto it, advancing by the result's length.
| "cpAt";
// The string iterator over the WTF-8 bytes: the byte count of a string,
// and the code-point character that starts at a byte offset (a lone
// surrogate is a one-unit character). for-of, spread and Array.from of a
// string walk a byte cursor with them, advancing by the character's
// byte count; the cursor is always a character start below the count.
| "byteLength"
| "cpAtByte";
/** The typed-array/Buffer method surface (bytesIntrinsic). Receiver/arg
* conventions (validated): `length`/`byteLength` are property reads → f64;
@@ -0,0 +1,47 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile } from "../src/index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function emit(source: string): Promise<string> {
const outDir = await mkdtemp(join(tmpdir(), "scriptc-string-iteration-"));
directories.push(outDir);
const entry = join(outDir, "main.ts");
await writeFile(entry, source);
const outPath = join(outDir, "program.ll");
const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result)).toBe(true);
return readFile(outPath, "utf8");
}
const calls = (llvm: string, symbol: string): string[] =>
llvm.split("\n").filter((line) => line.includes("call ") && line.includes(`@${symbol}(`));
test("for-of over a string walks a byte cursor", async () => {
const llvm = await emit(`
function count(s: string): number {
let n = 0;
for (const c of s) n += c.length;
return n;
}
console.log(count(process.argv[1] ?? ""));
`);
expect(calls(llvm, "sc_str_cp_at_byte")).toHaveLength(1);
expect(calls(llvm, "scr_str_cp_at")).toHaveLength(0);
// The helper answers ASCII bytes from the one-character table.
expect(llvm).toContain("define internal ptr @sc_str_cp_at_byte(ptr %s, i64 %b)");
});
test("spread and Array.from of a string build the array in one call", async () => {
const llvm = await emit(`
const s = process.argv[1] ?? "";
console.log([...s].length, Array.from(s).length);
`);
expect(calls(llvm, "scr_str_code_points")).toHaveLength(2);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8347-string-iteration-code-points.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8348-string-iteration-worker-characters.ts"
],
"diags": []
}
+76 -1
View File
@@ -3514,6 +3514,8 @@ ScrStr *scr_bool_to_scrstr(bool b) {
* decode is a whole char; an i addressing the LOW half of an astral pair
* (unreachable from the iterator, which only lands on char starts) answers
* the containing char. Out of range → empty string. Borrows s; +1. */
static ScrStr *scr_str_char_of(const char *bytes, size_t adv);
ScrStr *scr_str_cp_at(ScrStr *s, double i) {
double idx = scr_to_integer_or_infinity(i);
if (!(idx >= 0)) return scr_str_empty();
@@ -3528,7 +3530,80 @@ ScrStr *scr_str_cp_at(ScrStr *s, double i) {
if (off >= s->len) return scr_str_empty(); /* out of range */
size_t adv;
(void)scr_utf8_decode(s->data + off, &adv);
return scr_str_from_span(s->data + off, adv);
return scr_str_char_of(s->data + off, adv);
}
/* The one-character strings of U+0080..U+07FF (two-byte sequences, lead
* bytes 0xC2..0xDF), made immortal on first use and shared by every
* thread, as the ASCII ones are static: the string iterator returns them
* instead of a new string per character. A string is immortal before its
* slot publishes it; a thread that loses the race to fill a slot keeps its
* own immortal copy, so at most one extra string per slot and thread. The
* strings come from the system allocator, as other state that outlives a
* thread does: a worker's own memory goes back when it exits without
* sharing (scr_alloc.c), and the table outlives the worker that filled a
* slot. */
static ScrStr *scr_two_byte_chars[0x800 - 0x80];
static ScrStr *scr_two_byte_char(const char *bytes) {
uint32_t cp = ((uint32_t)((unsigned char)bytes[0] & 0x1F) << 6) | ((unsigned char)bytes[1] & 0x3F);
ScrStr **slot = &scr_two_byte_chars[cp - 0x80];
ScrStr *known = __atomic_load_n(slot, __ATOMIC_ACQUIRE);
if (known) return known;
ScrStr *made = calloc(1, sizeof(ScrStr) + 3);
if (!made) scr_oom();
made->len = 2;
made->cap = 2;
memcpy(made->data, bytes, 2);
scr_str_hash_prime(made);
made->rc = SIZE_MAX;
scr_lsan_immortal(made);
ScrStr *expected = NULL;
if (__atomic_compare_exchange_n(slot, &expected, made, false, __ATOMIC_ACQ_REL,
__ATOMIC_ACQUIRE))
return made;
return expected;
}
/* The character whose encoding starts at bytes and is adv bytes long (+1,
* or immortal). */
static ScrStr *scr_str_char_of(const char *bytes, size_t adv) {
unsigned char lead = (unsigned char)bytes[0];
if (adv == 1 && lead < 0x80) return (ScrStr *)&scr_ascii1[lead];
if (adv == 2) return scr_two_byte_char(bytes);
return scr_str_from_span(bytes, adv);
}
ScrStr *scr_str_cp_at_byte(ScrStr *s, uint64_t b) {
if (b >= s->len) return scr_str_empty();
size_t adv;
(void)scr_utf8_decode(s->data + b, &adv);
return scr_str_char_of(s->data + b, adv);
}
ScrArr *scr_str_code_points(ScrStr *s) {
/* Every byte but a continuation byte starts one character; a lone
* surrogate's three bytes are one character. */
size_t count = s->len;
if (!s->ascii)
for (size_t b = 0; b < s->len; b++) count -= ((unsigned char)s->data[b] & 0xC0) == 0x80;
ScrArr *out = scr_arr_new_inline(SCR_ELEM_STR, count, false);
size_t k = 0;
for (size_t b = 0; b < s->len;) {
unsigned char c = (unsigned char)s->data[b];
if (c < 0x80) {
out->data[k++] = (uint64_t)(uintptr_t)&scr_ascii1[c];
b++;
continue;
}
size_t adv;
(void)scr_utf8_decode(s->data + b, &adv);
out->data[k++] = (uint64_t)(uintptr_t)scr_str_char_of(s->data + b, adv);
b += adv;
}
out->len = (uint32_t)count;
out->filled = (uint32_t)count;
return out;
}
/* ── the URI component codecs (encodeURIComponent/decodeURIComponent) ──
@@ -150,6 +150,15 @@ ScrStr *scr_str_char_at(ScrStr *s, double i);
* (two units); the consuming loop advances by the result's length.
* Out of range → empty string. Returns +1. */
ScrStr *scr_str_cp_at(ScrStr *s, double i);
/* The string iterator's step over the WTF-8 bytes: the code-point
* character whose encoding starts at byte offset b, a character start
* below s->len (a lone surrogate is its own three-byte character). The
* compiler answers ASCII bytes inline and calls this for the rest.
* Returns +1. */
ScrStr *scr_str_cp_at_byte(ScrStr *s, uint64_t b);
/* [...s] and Array.from(s): the code-point characters of s in order, as a
* fresh string array of exactly that length. Returns +1. */
struct ScrArr *scr_str_code_points(ScrStr *s);
/* trimStart()/trimEnd(): the one-sided halves of trim. Returns +1. */
ScrStr *scr_str_trim_start(ScrStr *s);
+2 -2
View File
@@ -1,7 +1,7 @@
// for-of over STRINGS: JS's string iterator walks code POINTS — each pass
// binds the whole character, so astral chars arrive as one two-unit string
// (where charAt/slice would truncate the halves). Desugars over the UTF-16
// cursor with the cpAt step intrinsic.
// (where charAt/slice would truncate the halves). Desugars over a byte
// cursor with the cpAtByte step intrinsic.
for (const ch of "héllo 🎉x") console.log(ch, ch.length);
// The empty string iterates zero times.
@@ -0,0 +1,96 @@
// The string iterator: for-of, spread, Array.from, yield* and
// [Symbol.iterator]() walk code points. Astral characters come back whole,
// a lone surrogate is a one-unit character, and halves joined by
// concatenation form one character, as in Node.
function unitCodes(c: string): string {
const codes: string[] = [];
for (let k = 0; k < c.length; k++) codes.push(c.charCodeAt(k).toString(16));
return `${c.length}:${codes.join(".")}`;
}
const show = (chars: string[]): string => chars.map(unitCodes).join(" ");
const samples = [
"",
"abc",
"aé😀b",
"\u0000\u007f\u0080߿ࠀ￿",
"\u{10000}\u{10ffff}",
"é ñ",
"\ud800",
"\udc00",
"x\ud83d",
"\ude00x",
"\udc00\ud800",
"\ud83d" + "\ude00",
"a\ud83d" + "\ude00b\ud83d",
"日本語テキスト",
];
for (const s of samples) {
const looped: string[] = [];
for (const c of s) looped.push(c);
console.log(JSON.stringify(s), show(looped), show([...s]), show(Array.from(s)), [...s].length, s.length);
}
// Loop control, heads and bindings.
const text = "a😀b\ud800c";
let out = "";
outer: for (const c of text) {
if (c === "b") continue;
for (const d of "xy") {
if (d === "y" && c === "c") continue outer;
out += c + d;
}
if (c === "\ud800") out += "|";
}
console.log(out);
for (var v of "hé") out += v;
console.log(out);
let last = "";
for (last of "q😀") out += last.length;
console.log(out, last);
for (const [first] of ["😀z", "ab"].join("")) out += first;
console.log(out);
let source = "abc";
for (const c of source) {
source += c;
out += source.length;
}
console.log(out, source);
let n = 0;
for (const c of "stop😀here") {
if (c === "😀") break;
n += c.length;
}
console.log(n);
// Characters as values: lengths, Map keys and code points.
const counts = new Map<string, number>();
for (const c of "abracadabra😀😀é") counts.set(c, (counts.get(c) ?? 0) + 1);
console.log([...counts].map(([k, v]) => `${k}=${v}`).join(","));
let points = 0;
for (const c of "😀é\ud800a") points += c.codePointAt(0)!;
console.log(points);
// Generators and explicit iterators.
function* letters(s: string): Generator<string, void, undefined> {
yield* s;
}
console.log(show([...letters("x😀\udc00y")]));
const it = "o😀"[Symbol.iterator]();
let step = it.next();
while (!step.done) {
out += step.value.length;
step = it.next();
}
console.log(out);
// A long string built from mixed pieces.
let long = "";
for (let i = 0; i < 2000; i++) long += i % 3 === 0 ? "a" : i % 3 === 1 ? "é" : "😀";
let units = 0;
let chars = 0;
for (const c of long) {
units += c.length;
chars++;
}
console.log(units, chars, long.length, [...long].length);
@@ -0,0 +1,44 @@
import { Worker, isMainThread, parentPort } from "node:worker_threads";
// The one-character strings of U+0080..U+07FF that string iteration returns
// are shared by every thread. A worker that is the first to iterate them
// and exits without sharing anything else leaves them in use: the main
// thread iterates the same characters after the worker's memory goes back.
const text = "aé ñ ß ü Ω ж א ع ߿";
function codes(s: string): string {
const out: string[] = [];
for (const c of s) out.push(`${c.length}:${c.charCodeAt(0).toString(16)}`);
return out.join(" ");
}
function runWorker(): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url));
let count = -1;
worker.on("message", (value: number) => {
count = value;
});
worker.on("error", reject);
worker.on("exit", () => resolve(count));
});
}
async function main(): Promise<void> {
console.log("worker", await runWorker());
// The worker's memory goes back shortly after it exits.
await new Promise((resolve) => setTimeout(resolve, 300));
console.log("main", codes(text));
console.log("spread", [...text].join("|"));
console.log("from", Array.from("ñandú").map((c) => c.length).join(","));
}
if (isMainThread) {
void main();
} else {
let count = 0;
for (const c of text) count += c.length;
count += [...text].length + Array.from("ñandú").length;
parentPort!.postMessage(count);
}
@@ -0,0 +1 @@
{ "plain": 2770, "san": 3750 }
@@ -0,0 +1 @@
{ "plain": 3960, "san": 3900 }
@@ -0,0 +1,44 @@
/* The one-character strings that string iteration shares between threads
* outlive the worker that first made them. On Linux a worker that exits
* without sharing anything returns its memory; with SCR_SA_DISCARD=poison
* that memory becomes inaccessible instead, so a cached character
* allocated from the worker's memory would fault when the main thread
* reads it. The corpus program pins Node.js parity; this suite runs it
* with the discard poisoned and immediate. Under SCRIPTC_SAN=1 the runtime
* keeps every worker's memory, so the check does not apply. */
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const exec = promisify(execFile);
const program = join(import.meta.dirname, "../corpus/8348-string-iteration-worker-characters.ts");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
test.skipIf(process.platform !== "linux" || sanitize)(
"characters a discarded worker cached stay readable",
{ timeout: 240_000 },
async () => {
const outDir = await mkdtemp(join(tmpdir(), "scriptc-string-iteration-workers-"));
try {
const result = await compile(program, {
backend: "llvm",
outDir,
outPath: join(outDir, "program"),
});
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
if (!result.ok) return;
const { stdout } = await exec(result.binaryPath, [], {
env: { ...process.env, SCR_SA_DISCARD: "poison", SCR_SA_DISCARD_DELAY_MS: "0" },
timeout: 120_000,
});
expect(stdout).toContain("main 1:61 1:e9 1:20 1:f1");
expect(stdout).toContain("spread a|é| |ñ");
} finally {
await rm(outDir, { recursive: true, force: true });
}
},
);