Stream string split loops

- Stream eligible split loops while preserving snapshots, bindings, and cleanup.
- Reuse unescaped piece storage and batch materialized results.
- Cover limits, Unicode, debugging, and native/WASI behavior.
This commit is contained in:
Chris Tate
2026-10-06 19:01:30 -05:00
committed by GitHub
parent 25b7fd266a
commit 763541b433
15 changed files with 801 additions and 66 deletions
+77 -22
View File
@@ -48,6 +48,7 @@ import {
RUNTIME_ERROR_CLASSES,
RUNTIME_STREAM_CLASSES,
typeKey,
STRING,
VOID,
} from "../../ir/ir.js";
import {
@@ -64,6 +65,18 @@ import {
import { emitCountedLoopLimit } from "./counted-loops.js";
import { emitLiteralSwitch } from "./switch-dispatch.js";
import { findBytesBounds } from "./bytes-bounds.js";
import {
emitSplitCursor,
emitSplitNext,
emitSplitScratch,
emitSplitSnapshot,
findPrivateSplitLocals,
loadSplitSnapshot,
storeSplitSnapshot,
stringSplit,
type StoredSplitSnapshot,
type StringSplit,
} from "./split-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { everyStmtList } from "../../ir/traverse.js";
import { analyzeIntegerRanges, type IntegerRanges } from "../../ir/integer-ranges.js";
@@ -534,6 +547,9 @@ export class LlEmitter {
finallyDepth: number;
}[] = [];
private currentLocals = new Map<string, IrLocal>();
private privateSplitLocals = new Map<string, StringSplit>();
private storedSplits = new Map<string, StoredSplitSnapshot>();
private streamingSplitsEnabled = false;
private readonly initializerBindings: ReturnType<typeof findInitializerBindings>;
private numericLocals = new Map<string, IrLocal>();
private captureIds = new Set<string>();
@@ -3731,6 +3747,11 @@ export class LlEmitter {
this.unwindCleanups.clear();
this.jumpTargets = [];
this.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
// Preserve concrete source bindings for debugger inspection. Suspended
// functions keep the established array lifetime across continuations.
this.streamingSplitsEnabled = this.debug === null && !fn.async && !fn.generator;
this.privateSplitLocals = this.streamingSplitsEnabled ? findPrivateSplitLocals(fn) : new Map();
this.storedSplits.clear();
const initializerBindings = this.initializerBindings.get(fn.name) ?? [];
const numericFn = withInitializerBindings(fn, initializerBindings);
this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l]));
@@ -4061,6 +4082,16 @@ export class LlEmitter {
switch (s.kind) {
case "varDecl": {
const b = this.binding(s.localId);
const split = this.privateSplitLocals.get(s.localId);
if (split) {
const snapshot = storeSplitSnapshot(this, emitSplitSnapshot(this, split));
this.storedSplits.set(s.localId, snapshot);
this.scopes[this.scopes.length - 1]!.push(
{ slot: snapshot.sourceSlot, type: STRING },
{ slot: snapshot.separatorSlot, type: STRING },
);
break;
}
const localUnion = this.localStackUnions.get(s.localId);
if (localUnion) {
const union = emitStackUnion(this, localUnion);
@@ -4723,23 +4754,42 @@ export class LlEmitter {
if (s.iterable.type.kind !== "array")
throw new LlvmUnsupportedError(`forOf:${s.iterable.type.kind}`, s.loc);
const elem = s.iterable.type.elem;
const arr = this.emitExpr(s.iterable);
const idxSlot = B.slot();
B.entryAllocas.push(`${idxSlot} = alloca double`);
B.line(`store double ${f64Lit(0)}, ptr ${idxSlot}`);
const stored =
s.iterable.kind === "varRef" ? this.storedSplits.get(s.iterable.localId) : undefined;
const direct = this.streamingSplitsEnabled ? stringSplit(s.iterable) : null;
const snapshot = stored
? loadSplitSnapshot(this, stored)
: direct
? emitSplitSnapshot(this, direct)
: null;
const arr = snapshot ? null : this.emitExpr(s.iterable);
const cursor = snapshot ? emitSplitCursor(this, snapshot) : null;
const scratch = snapshot ? emitSplitScratch(this) : null;
const idxSlot = snapshot ? null : B.slot();
if (idxSlot) {
B.entryAllocas.push(`${idxSlot} = alloca double`);
B.line(`store double ${f64Lit(0)}, ptr ${idxSlot}`);
}
const lc = B.newLabel("fof.c");
const lb = B.newLabel("fof.b");
const lu = B.newLabel("fof.u");
const le = B.newLabel("fof.e");
B.br(lc);
B.startBlock(lc);
const i = B.tmp();
const len = B.tmp();
const inBounds = B.tmp();
this.declare(`declare double @scr_arr_len(ptr)`);
B.line(`${i} = load double, ptr ${idxSlot}`);
B.line(`${len} = call double @scr_arr_len(ptr ${arr.name})`);
B.line(`${inBounds} = fcmp olt double ${i}, ${len}`);
let cur: string;
if (snapshot && cursor && scratch) {
cur = emitSplitNext(this, snapshot, cursor, scratch);
B.line(`${inBounds} = icmp ne ptr ${cur}, null`);
} else {
const i = B.tmp(),
len = B.tmp();
this.declare(`declare double @scr_arr_len(ptr)`);
B.line(`${i} = load double, ptr ${idxSlot}`);
B.line(`${len} = call double @scr_arr_len(ptr ${arr!.name})`);
B.line(`${inBounds} = fcmp olt double ${i}, ${len}`);
cur = i;
}
B.condBr(inBounds, lb, le);
B.startBlock(lb);
this.jumpTargets.push({
@@ -4757,13 +4807,16 @@ export class LlEmitter {
this.scopes.push([]);
const localInfo = this.currentLocals.get(s.localId);
const slot = `%${mangleLocal(s.localId)}`;
const acc = elemAccess(elem);
const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
this.declare(
`declare ${acc === "bool" ? "zeroext i1" : accTy} @scr_arr_get_${acc}(ptr, double)`,
);
const cur = B.tmp();
B.line(`${cur} = call ${accTy} @scr_arr_get_${acc}(ptr ${arr.name}, double ${i})`);
if (!snapshot) {
const acc = elemAccess(elem);
const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
this.declare(
`declare ${acc === "bool" ? "zeroext i1" : accTy} @scr_arr_get_${acc}(ptr, double)`,
);
const value = B.tmp();
B.line(`${value} = call ${accTy} @scr_arr_get_${acc}(ptr ${arr!.name}, double ${cur})`);
cur = value;
}
if (localInfo?.boxed) {
// Captured loop variable: a fresh box per iteration, matching the
// fresh const binding. The box takes ownership of a ref element's
@@ -4784,11 +4837,13 @@ export class LlEmitter {
this.jumpTargets.pop();
B.br(lu);
B.startBlock(lu);
const i2 = B.tmp();
const i3 = B.tmp();
B.line(`${i2} = load double, ptr ${idxSlot}`);
B.line(`${i3} = fadd double ${i2}, ${f64Lit(1)}`);
B.line(`store double ${i3}, ptr ${idxSlot}`);
if (idxSlot) {
const i2 = B.tmp(),
i3 = B.tmp();
B.line(`${i2} = load double, ptr ${idxSlot}`);
B.line(`${i3} = fadd double ${i2}, ${f64Lit(1)}`);
B.line(`store double ${i3}, ptr ${idxSlot}`);
}
B.br(lc);
B.startBlock(le);
break;
@@ -0,0 +1,153 @@
import { STRING, type IrExpr, type IrFunction } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { LlvmEmitterContext } from "./expr-context.js";
export type StringSplit = IrExpr & { kind: "strIntrinsic" };
export function stringSplit(value: IrExpr): StringSplit | null {
return value.kind === "strIntrinsic" && value.method === "split" ? value : null;
}
/** Stored arrays can disappear only when one loop is their entire use.
* Count every executable reference and explicit write; captured, TDZ and
* suspended bindings retain their ordinary representation. Frontend
* density proofs alone are not an escape or lifetime proof. */
export function findPrivateSplitLocals(fn: IrFunction): Map<string, StringSplit> {
const splits = new Map<string, StringSplit>();
if (fn.async || fn.generator) return splits;
everyStmtList(fn.body, {
expr: () => true,
stmt: (stmt) => {
if (stmt.kind !== "varDecl" || !stmt.init) return true;
const split = stringSplit(stmt.init);
if (!split) return true;
const local = fn.locals.find((local) => local.id === stmt.localId);
if (local && !local.mutable && !local.boxed && !local.tdz) splits.set(stmt.localId, split);
return true;
},
});
if (splits.size === 0) return splits;
const definitions = new Map<string, number>();
const references = new Map<string, number>();
const consumers = new Set<string>();
const writes = new Set<string>();
everyStmtList(fn.body, {
expr: (value) => {
if (value.kind === "varRef" && splits.has(value.localId))
references.set(value.localId, (references.get(value.localId) ?? 0) + 1);
if (value.kind === "closure" || value.kind === "classRef")
for (const id of value.captures ?? []) if (splits.has(id)) writes.add(id);
return true;
},
stmt: (stmt) => {
if (stmt.kind === "varDecl" && splits.has(stmt.localId)) {
definitions.set(stmt.localId, (definitions.get(stmt.localId) ?? 0) + 1);
}
if (stmt.kind === "assign" && splits.has(stmt.localId)) writes.add(stmt.localId);
if (stmt.kind === "forOf" && stmt.iterable.kind === "varRef")
consumers.add(stmt.iterable.localId);
return true;
},
});
for (const id of splits.keys())
if (
definitions.get(id) !== 1 ||
references.get(id) !== 1 ||
!consumers.has(id) ||
writes.has(id)
)
splits.delete(id);
return splits;
}
export interface SplitSnapshot {
source: string;
separator: string;
limit: string;
}
/** Evaluate in source order, retaining even stable bindings: body updates
* and unique-string concatenation must not alter either snapshot. Pending
* exceptions still see frame-owned inputs until every argument succeeds. */
export function emitSplitSnapshot(host: LlvmEmitterContext, split: StringSplit): SplitSnapshot {
const source = host.emitExpr(split.receiver);
const separator = host.emitExpr(split.args[0]!);
const limit = host.emitExpr(split.args[1]!);
host.declare("declare i32 @scr_to_uint32(double)");
const converted = host.B.tmp();
host.B.line(`${converted} = call i32 @scr_to_uint32(double ${limit.name})`);
return { source: source.name, separator: separator.name, limit: converted };
}
export interface StoredSplitSnapshot {
sourceSlot: string;
separatorSlot: string;
limitSlot: string;
}
/** Move snapshots into the declaration's lexical scope. Their ownership
* then participates in normal, jumping and exceptional scope cleanup. */
export function storeSplitSnapshot(
host: LlvmEmitterContext,
snapshot: SplitSnapshot,
): StoredSplitSnapshot {
const sourceSlot = host.B.slot(),
separatorSlot = host.B.slot(),
limitSlot = host.B.slot();
host.B.entryAllocas.push(
`${sourceSlot} = alloca ptr`,
`${separatorSlot} = alloca ptr`,
`${limitSlot} = alloca i32`,
);
host.B.line(`store ptr ${snapshot.source}, ptr ${sourceSlot}`);
host.B.line(`store ptr ${snapshot.separator}, ptr ${separatorSlot}`);
host.B.line(`store i32 ${snapshot.limit}, ptr ${limitSlot}`);
host.moveTemp({ name: snapshot.source, type: STRING });
host.moveTemp({ name: snapshot.separator, type: STRING });
return { sourceSlot, separatorSlot, limitSlot };
}
export function loadSplitSnapshot(
host: LlvmEmitterContext,
snapshot: StoredSplitSnapshot,
): SplitSnapshot {
const source = host.B.tmp(),
separator = host.B.tmp(),
limit = host.B.tmp();
host.B.line(`${source} = load ptr, ptr ${snapshot.sourceSlot}`);
host.B.line(`${separator} = load ptr, ptr ${snapshot.separatorSlot}`);
host.B.line(`${limit} = load i32, ptr ${snapshot.limitSlot}`);
return { source, separator, limit };
}
/** Each loop entry restarts a split result, including repeated execution
* of the single syntactic consumer. No cursor owns heap memory. */
export function emitSplitCursor(host: LlvmEmitterContext, snapshot: SplitSnapshot): string {
const cursor = host.B.slot();
host.B.entryAllocas.push(`${cursor} = alloca { ${host.sizeType}, i32, i32 }`);
host.declare("declare void @scr_str_split_cursor_init(ptr, i32)");
host.B.line(`call void @scr_str_split_cursor_init(ptr ${cursor}, i32 ${snapshot.limit})`);
return cursor;
}
export function emitSplitNext(
host: LlvmEmitterContext,
snapshot: SplitSnapshot,
cursor: string,
scratch: string,
): string {
const piece = host.B.tmp();
host.declare("declare ptr @scr_str_split_cursor_next(ptr, ptr, ptr, ptr)");
host.B.line(
`${piece} = call ptr @scr_str_split_cursor_next(ptr ${snapshot.source}, ptr ${snapshot.separator}, ptr ${cursor}, ptr ${scratch})`,
);
return piece;
}
export function emitSplitScratch(host: LlvmEmitterContext): string {
const scratch = host.B.slot();
host.B.entryAllocas.push(`${scratch} = alloca ptr`);
host.B.line(`store ptr null, ptr ${scratch}`);
host.ownSlot(scratch, STRING);
return scratch;
}
@@ -2,6 +2,7 @@ import { dynUndefinedExpr, numLit, varRef } from "../../ir/build.js";
import { lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { importMetaBindingSource } from "./import-meta.js";
import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js";
import { isPrivateSplitConsumer } from "./split-consumers.js";
import { InternalCompilerError } from "../../errors.js";
/* Statement lowering: the statement dispatch (lowerStmt), variable
* declarations including destructuring patterns, scoped blocks, control
@@ -8923,7 +8924,9 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
}
}
const elementType = iterable.type.kind === "array" ? iterable.type.elem : tupleSource!.elem;
const yieldedT = tupleSource === null ? arrayValueType(lowerer, elementType) : elementType;
const privateSplit = !awaitArray && isPrivateSplitConsumer(lowerer, iterSrc, iterable);
const yieldedT =
tupleSource === null && !privateSplit ? arrayValueType(lowerer, elementType) : elementType;
let elemValueT = yieldedT;
let awaitPromiseTag: number | null = null;
if (awaitArray) {
@@ -8978,12 +8981,15 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
}
// Native arrays retain their payload ABI; the loop binds the value yielded
// by Get, which is undefined for both holes and present undefined slots.
const sourceT = iterable.type;
const source = lowerer.declareHiddenLocal("%arrayIterator", sourceT);
const cursor = lowerer.declareHiddenLocal("%arrayCursor", F64);
cursor.mutable = true;
const forValues = (localId: string, body: IrStmt[]): IrStmt => {
const loc = locOf(stmt);
if (privateSplit) {
return { kind: "forOf", localId, iterable, body, ...(labels && { labels }), loc };
}
const sourceT = iterable.type;
const source = lowerer.declareHiddenLocal("%arrayIterator", sourceT);
const cursor = lowerer.declareHiddenLocal("%arrayCursor", F64);
cursor.mutable = true;
const needsClose = awaitArray
? lowerer.declareHiddenLocal("%arrayIteratorNeedsClose", BOOL)
: null;
@@ -2201,6 +2201,7 @@ export class Lowerer {
* (the ctx guard keeps hidden locals out of closures — a cross-function
* walk falls back to the plain array walk and the fence). */
readonly matchAllDrainIndexes = new Map<ts.Symbol, { idxsLocalId: string; ctx: FnCtx }>();
readonly splitIdentifierUses = new Map<ts.SourceFile, Map<string, ts.Identifier[]>>();
/** STORED numeric value iterators: `const it = numbers.values()` (and
* the equivalent `[Symbol.iterator]()` spelling) over number[] or a
* represented typed array has no first-class IR value, so its statically
@@ -0,0 +1,66 @@
import * as ts from "../ts7/adapter.js";
import type { IrExpr } from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
import { stripValueWrappers } from "./binding-analysis.js";
/** A fresh split array is dense. A stored result has the same guarantee
* only when this loop is its sole observation: even a const array can be
* mutated through an alias. Keep the ordinary array protocol otherwise. */
export function isPrivateSplitConsumer(
lowerer: Lowerer,
source: ts.Expression,
iterable: IrExpr,
): boolean {
if (iterable.type.kind !== "array" || iterable.type.elem.kind !== "string") return false;
if (iterable.kind === "strIntrinsic" && iterable.method === "split") return true;
const name = stripValueWrappers(source);
if (!ts.isIdentifier(name) || iterable.kind !== "varRef") return false;
const symbol = lowerer.resolveValueSymbol(name);
if (!symbol) return false;
const local = lowerer.bindingIn(lowerer.ctx, symbol);
if (!local || local.boxed || local.tdz || local.mutable || local.id !== iterable.localId)
return false;
const decl = lowerer.checker.valueDeclarationOf(symbol);
if (
!decl ||
!ts.isVariableDeclaration(decl) ||
!ts.isIdentifier(decl.name) ||
!decl.initializer ||
!ts.isVariableDeclarationList(decl.parent) ||
(decl.parent.flags & ts.NodeFlags.Const) === 0
)
return false;
const init = stripValueWrappers(decl.initializer);
if (
!ts.isCallExpression(init) ||
init.questionDotToken ||
!ts.isPropertyAccessExpression(init.expression) ||
init.expression.questionDotToken ||
init.expression.name.text !== "split" ||
!lowerer.isStdlibMember(init.expression) ||
lowerer.mapTypeOf(lowerer.typeOf(init.expression.expression))?.kind !== "string" ||
init.arguments.length === 0 ||
lowerer.mapTypeOf(lowerer.typeOf(init.arguments[0]!))?.kind !== "string"
)
return false;
// Index spelling once per file; resolve only this binding's names. Type
// positions and shorthand properties conservatively count as uses too.
const file = decl.getSourceFile();
let names = lowerer.splitIdentifierUses.get(file);
if (!names) {
names = new Map();
ts.walkPreorder(file, (node) => {
if (!ts.isIdentifier(node)) return;
const uses = names!.get(node.text);
if (uses) uses.push(node);
else names!.set(node.text, [node]);
});
lowerer.splitIdentifierUses.set(file, names);
}
for (const use of names.get(name.text) ?? []) {
if (use === decl.name || use === name) continue;
if (lowerer.resolveValueSymbol(use) === symbol) return false;
}
return true;
}
@@ -0,0 +1,103 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, serializeModule, validateModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
test("private split loops keep dense bindings and target-sized snapshot cursors", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-loops-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(
entry,
`
function direct(input: string, limit: number): string {
let result = "";
for (const piece of input.split(",", limit)) result += piece;
return result;
}
function stored(input: string, limit: number): string {
const pieces = input.split(",", limit);
input += ",changed";
let result = "";
for (let pass = 0; pass < 2; pass++) {
for (const piece of pieces) result += piece;
}
return result;
}
function observed(input: string): string {
const pieces = input.split(",");
pieces.push("extra");
let result = "";
for (const piece of pieces) result += piece;
return result;
}
function captured(input: string): string {
const pieces = input.split(",");
const use = (): number => pieces.length;
let result = "";
for (const piece of pieces) result += piece;
return result + String(use());
}
async function suspended(input: string): Promise<string> {
let result = "";
for (const piece of input.split(",")) { await Promise.resolve(); result += piece; }
return result;
}
console.log(direct("a,b", 2), stored("a,b", 2), observed("a,b"), captured("a,b"));
suspended("a,b").then(console.log);
`,
);
const result = await compile(entry, {
outDir: dir,
outPath: output,
outputKind: "ir",
dynamic: false,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const module = deserializeModule(await readFile(output, "utf8"));
expect(validateModule(module)).toEqual([]);
const body = (llvm: string, name: string): string => {
const definition = new RegExp(
`^define internal [^\\n]*@sc_(?:bf|f)_${name}\\([^]*?^}`,
"m",
).exec(llvm);
expect(definition, name).not.toBeNull();
return definition![0];
};
for (const pointerBits of [32, 64] as const) {
const llvm = emitLlvmModule(deserializeModule(serializeModule(module)), { pointerBits });
for (const name of ["direct", "stored"]) {
const work = body(llvm, name);
expect(work).toContain(`alloca { i${pointerBits}, i32, i32 }`);
expect(work).toContain("@scr_str_split_cursor_next");
expect(work).not.toContain("@scr_str_split_limit");
expect(work).not.toContain("@scr_arr_get_ref");
expect(work).not.toContain("@scr_union_wrap");
}
const stored = body(llvm, "stored");
expect(stored.indexOf("@scr_to_uint32")).toBeLessThan(stored.indexOf("@scr_str_concat"));
expect(stored.indexOf("@scr_str_concat")).toBeLessThan(
stored.indexOf("@scr_str_split_cursor_init"),
);
for (const name of ["observed", "captured", "suspended"]) {
const work = body(llvm, name);
expect(work).toContain("@scr_str_split_limit");
expect(work).not.toContain("@scr_str_split_cursor_next");
}
const debug = emitLlvmModule(deserializeModule(serializeModule(module)), {
pointerBits,
debugSources: new Map([[entry, await readFile(entry, "utf8")]]),
});
for (const name of ["direct", "stored"]) {
expect(body(debug, name)).toContain("@scr_str_split_limit");
expect(body(debug, name)).not.toContain("@scr_str_split_cursor_next");
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -11176,6 +11176,12 @@
],
"diags": []
},
"<repo>/tests/corpus/string-split-traversal.ts": {
"order": [
"<repo>/tests/corpus/string-split-traversal.ts"
],
"diags": []
},
"<repo>/tests/corpus/structural-class-properties.ts": {
"order": [
"<repo>/tests/corpus/structural-class-properties.ts"
+14
View File
@@ -805,6 +805,20 @@ ScrStr *scr_str_trim_end(ScrStr *s);
struct ScrArr;
struct ScrArr *scr_str_split(ScrStr *s, ScrStr *sep);
struct ScrArr *scr_str_split_limit(ScrStr *s, ScrStr *sep, double limit);
/* Internal dense split traversal. The cursor borrows input strings, returns
* one owned piece per step, and uses NULL only for exhaustion. Its layout
* is shared with generated stack storage, including wasm32. */
typedef struct ScrSplitCursor {
size_t offset;
uint32_t remaining;
uint32_t pending_unit;
} ScrSplitCursor;
void scr_str_split_cursor_init(ScrSplitCursor *cursor, uint32_t limit);
/* Optional scratch is a separately owned, NULL-initialized string slot.
* A piece that has not escaped can reuse it after the preceding yield is
* released. The caller releases the slot on every exit; NULL disables reuse. */
ScrStr *scr_str_split_cursor_next(ScrStr *s, ScrStr *sep, ScrSplitCursor *cursor,
ScrStr **scratch);
/* padStart(maxLength, fill)/padEnd — ECMA StringPad, UTF-16 unit counts.
* Target at or below the length (or empty fill) returns the receiver
+97 -37
View File
@@ -1317,15 +1317,6 @@ static const struct { size_t rc; size_t len; size_t cap; char data[4]; }
/* Split owns a fresh array and appends only present string values. Fill an
* available dense slot directly; ordinary push handles growth and the sparse
* boundary. The array remains valid after every append, including RC audit. */
static void scr_str_split_append(ScrArr *out, ScrStr *value) {
if (out->len < out->cap) {
out->data[out->len] = (uint64_t)(uintptr_t)value;
out->present[out->len++] = SCR_ARR_VALUE;
} else {
scr_arr_push_ref(out, value);
}
}
/* split(separator, limit) with a STRING separator (ECMA-262 22.1.3.23):
* limit is ToUint32'd; zero returns [] and reaching the limit stops before
* any later separator probes. The no-limit wrapper supplies 2^32-1.
@@ -1338,38 +1329,107 @@ static void scr_str_split_append(ScrArr *out, ScrStr *value) {
* yield the two lone surrogate halves, each half is U+FFFD here
* (divergence 2 — the same substitution the island's boundary marshal
* applied). Borrows both; returns a +1 string[]. */
ScrArr *scr_str_split_limit(ScrStr *s, ScrStr *sep, double limit_num) {
ScrArr *out = scr_arr_new(SCR_ELEM_STR, 0);
uint32_t limit = scr_to_uint32(limit_num);
if (limit == 0) return out;
if (sep->len == 0) {
size_t i = 0;
while (i < s->len) {
size_t adv;
uint32_t cp = scr_utf8_decode(s->data + i, &adv);
if (cp >= 0x10000) { /* two units in JS: both halves become U+FFFD */
scr_str_split_append(out, scr_str_retain((ScrStr *)&scr_lit_fffd));
if (out->len == limit) return out;
scr_str_split_append(out, scr_str_retain((ScrStr *)&scr_lit_fffd));
} else {
scr_str_split_append(out, scr_str_from_span(s->data + i, adv));
}
if (out->len == limit) return out;
i += adv;
void scr_str_split_cursor_init(ScrSplitCursor *cursor, uint32_t limit) {
cursor->offset = 0;
cursor->remaining = limit;
cursor->pending_unit = 0;
}
static ScrStr *scr_str_split_piece(const char *bytes, size_t len, ScrStr **scratch) {
if (!scratch || len <= 4) return scr_str_from_span(bytes, len);
ScrStr *piece = *scratch;
if (piece && piece->rc == 1 && piece->cap >= len) {
/* The scratch owner is the only remaining reference. Invalidate all
* metadata before replacing bytes, just as unique concatenation does. */
scr_short_forget(piece);
scr_sidx_purge(piece);
memcpy(piece->data, bytes, len);
piece->len = len;
piece->data[len] = '\0';
} else {
ScrStr *fresh = scr_str_alloc_raw(len, len < 64 ? 64 : len);
memcpy(fresh->data, bytes, len);
fresh->data[len] = '\0';
*scratch = fresh;
scr_str_release(piece);
piece = fresh;
}
return scr_str_retain(piece);
}
/* Both eager and loop consumers use the same boundary state machine.
* Eager consumers select the unit/substring path once before their loop. */
static const char *scr_str_split_span(ScrStr *s, ScrStr *sep,
ScrSplitCursor *cursor, bool units,
size_t *len) {
if (cursor->remaining == 0 || cursor->offset > s->len) return NULL;
if (units) {
if (cursor->pending_unit) {
cursor->pending_unit = 0;
cursor->remaining--;
*len = 3;
return scr_lit_fffd.data;
}
return out;
if (cursor->offset == s->len) return NULL;
size_t start = cursor->offset, adv;
uint32_t cp = scr_utf8_decode(s->data + start, &adv);
cursor->offset += adv;
cursor->remaining--;
if (cp >= 0x10000) {
cursor->pending_unit = 1;
*len = 3;
return scr_lit_fffd.data;
}
*len = adv;
return s->data + start;
}
size_t start = 0;
size_t start = cursor->offset;
const char *found = scr_byte_find(s->data + start, s->len - start,
sep->data, sep->len);
size_t end = found ? (size_t)(found - s->data) : s->len;
/* String allocations reserve the header and terminator, so len + 1
* cannot overflow. This sentinel includes a final empty trailing piece. */
cursor->offset = found ? end + sep->len : s->len + 1;
cursor->remaining--;
*len = end - start;
return s->data + start;
}
ScrStr *scr_str_split_cursor_next(ScrStr *s, ScrStr *sep, ScrSplitCursor *cursor,
ScrStr **scratch) {
size_t len;
const char *bytes = scr_str_split_span(s, sep, cursor, sep->len == 0, &len);
if (!bytes) return NULL;
if (bytes == scr_lit_fffd.data) return scr_str_retain((ScrStr *)&scr_lit_fffd);
return scr_str_split_piece(bytes, len, scratch);
}
/* Move a bounded batch of pieces into array storage in one operation.
* This avoids a second separator scan and preserves sparse overflow. */
static void scr_str_split_fill(ScrArr *out, ScrStr *s, ScrStr *sep,
ScrSplitCursor *cursor, bool units) {
uint64_t slots[64];
for (;;) {
const char *found = scr_byte_find(s->data + start, s->len - start,
sep->data, sep->len);
if (!found) break;
size_t at = (size_t)(found - s->data);
scr_str_split_append(out, scr_str_from_span(s->data + start, at - start));
if (out->len == limit) return out;
start = at + sep->len;
size_t count = 0, len;
const char *bytes;
while (count < 64 && (bytes = scr_str_split_span(s, sep, cursor, units, &len))) {
ScrStr *piece = bytes == scr_lit_fffd.data
? scr_str_retain((ScrStr *)&scr_lit_fffd)
: scr_str_from_span(bytes, len);
slots[count++] = (uint64_t)(uintptr_t)piece;
}
if (count) scr_arr_push_many(out, count, slots);
if (count < 64) return;
}
scr_str_split_append(out, scr_str_from_span(s->data + start, s->len - start));
}
ScrArr *scr_str_split_limit(ScrStr *s, ScrStr *sep, double limit_num) {
uint32_t limit = scr_to_uint32(limit_num);
ScrArr *out = scr_arr_new(SCR_ELEM_STR, 0);
ScrSplitCursor cursor;
scr_str_split_cursor_init(&cursor, limit);
if (sep->len == 0) scr_str_split_fill(out, s, sep, &cursor, true);
else scr_str_split_fill(out, s, sep, &cursor, false);
return out;
}
@@ -78,6 +78,26 @@ function emit(op, input, args, expected) {
lines.add(`${op}\t${hex(input)}\t${args}\t${expected}`);
}
// Pin conversion and early exhaustion separately from the broad method
// oracle: limits can stop between the surrogate halves of an astral unit.
for (const [input, separator] of [
["", ","],
[",a,,b,", ","],
["ababa", "aba"],
["a\0b\0", "\0"],
["a😀b", ""],
]) {
for (const limit of [0, 1, 2, 3, 7, -1, 1.9, NaN, Infinity, 4294967296, 4294967297]) {
const pieces = input.split(separator, limit);
emit(
"splitLimit",
input,
`${hex(separator)},${num(limit)}`,
hex(`${pieces.length}:${pieces.join("\x01")}`),
);
}
}
for (const s of strings) {
const L = s.length;
const idxs = [
+55
View File
@@ -1,3 +1,58 @@
splitLimit - 2c,0 303a
splitLimit - 2c,1 313a
splitLimit - 2c,2 313a
splitLimit - 2c,3 313a
splitLimit - 2c,7 313a
splitLimit - 2c,-1 313a
splitLimit - 2c,1.9 313a
splitLimit - 2c,NaN 303a
splitLimit - 2c,Infinity 303a
splitLimit - 2c,4294967296 303a
splitLimit - 2c,4294967297 313a
splitLimit 2c612c2c622c 2c,0 303a
splitLimit 2c612c2c622c 2c,1 313a
splitLimit 2c612c2c622c 2c,2 323a0161
splitLimit 2c612c2c622c 2c,3 333a016101
splitLimit 2c612c2c622c 2c,7 353a016101016201
splitLimit 2c612c2c622c 2c,-1 353a016101016201
splitLimit 2c612c2c622c 2c,1.9 313a
splitLimit 2c612c2c622c 2c,NaN 303a
splitLimit 2c612c2c622c 2c,Infinity 303a
splitLimit 2c612c2c622c 2c,4294967296 303a
splitLimit 2c612c2c622c 2c,4294967297 313a
splitLimit 6162616261 616261,0 303a
splitLimit 6162616261 616261,1 313a
splitLimit 6162616261 616261,2 323a016261
splitLimit 6162616261 616261,3 323a016261
splitLimit 6162616261 616261,7 323a016261
splitLimit 6162616261 616261,-1 323a016261
splitLimit 6162616261 616261,1.9 313a
splitLimit 6162616261 616261,NaN 303a
splitLimit 6162616261 616261,Infinity 303a
splitLimit 6162616261 616261,4294967296 303a
splitLimit 6162616261 616261,4294967297 313a
splitLimit 61006200 00,0 303a
splitLimit 61006200 00,1 313a61
splitLimit 61006200 00,2 323a610162
splitLimit 61006200 00,3 333a61016201
splitLimit 61006200 00,7 333a61016201
splitLimit 61006200 00,-1 333a61016201
splitLimit 61006200 00,1.9 313a61
splitLimit 61006200 00,NaN 303a
splitLimit 61006200 00,Infinity 303a
splitLimit 61006200 00,4294967296 303a
splitLimit 61006200 00,4294967297 313a61
splitLimit 61f09f988062 -,0 303a
splitLimit 61f09f988062 -,1 313a61
splitLimit 61f09f988062 -,2 323a6101efbfbd
splitLimit 61f09f988062 -,3 333a6101efbfbd01efbfbd
splitLimit 61f09f988062 -,7 343a6101efbfbd01efbfbd0162
splitLimit 61f09f988062 -,-1 343a6101efbfbd01efbfbd0162
splitLimit 61f09f988062 -,1.9 313a61
splitLimit 61f09f988062 -,NaN 303a
splitLimit 61f09f988062 -,Infinity 303a
splitLimit 61f09f988062 -,4294967296 303a
splitLimit 61f09f988062 -,4294967297 313a61
len - - 30
trim - - -
trimStart - - -
+65 -2
View File
@@ -200,6 +200,40 @@ static void split_storage_asserts(void) {
scr_str_release(input);
}
static void split_scratch_asserts(void) {
_Static_assert(offsetof(ScrSplitCursor, offset) == 0, "split cursor offset");
_Static_assert(offsetof(ScrSplitCursor, remaining) == sizeof(size_t), "split cursor limit");
_Static_assert(offsetof(ScrSplitCursor, pending_unit) == sizeof(size_t) + 4, "split cursor pending unit");
_Static_assert(sizeof(ScrSplitCursor) == sizeof(size_t) + 8, "split cursor storage");
ScrStr *input = scr_str_new("alpha,beta-long,\xE4\xBD\xA0\xE5\xA5\xBD\xE4\xB8\x96\xE7\x95\x8C,test-tail,,last", 44);
ScrStr *separator = scr_str_new(",", 1), *scratch = NULL;
ScrSplitCursor cursor;
scr_str_split_cursor_init(&cursor, UINT32_MAX);
ScrStr *first = scr_str_split_cursor_next(input, separator, &cursor, &scratch);
total++;
if (first != scratch || first->rc != 2 || scr_str_utf16_len(first) != 5) failed++;
scr_str_release(first);
ScrStr *saved = scr_str_split_cursor_next(input, separator, &cursor, &scratch);
total++;
if (saved != first || scr_str_utf16_len(saved) != 9) failed++;
/* Keep this yield alive: the next piece must allocate independent storage. */
ScrStr *mixed = scr_str_split_cursor_next(input, separator, &cursor, &scratch);
total++;
if (mixed == saved || scr_str_utf16_len(mixed) != 4 ||
saved->len != 9 || memcmp(saved->data, "beta-long", 9) != 0) failed++;
scr_str_release(mixed);
ScrStr *tail = scr_str_split_cursor_next(input, separator, &cursor, &scratch);
total++;
if (tail != mixed || scr_str_utf16_len(tail) != 9) failed++;
scr_str_release(tail);
while ((tail = scr_str_split_cursor_next(input, separator, &cursor, &scratch)))
scr_str_release(tail);
scr_str_release(saved);
scr_str_release(scratch);
scr_str_release(separator);
scr_str_release(input);
}
static void construction_asserts(void) {
ScrStr *empty = scr_str_new("", 0);
ScrStr *value = scr_str_new("a\0\xE6\x97\xA5", 5);
@@ -802,12 +836,16 @@ int main(int argc, char **argv) {
} else if (strcmp(op, "parseInt") == 0) {
check_f64(op, args, input, scr_parse_int(input, strtod(args, NULL)),
expected_bytes, exp_len);
} else if (strcmp(op, "split") == 0) {
} else if (strcmp(op, "split") == 0 || strcmp(op, "splitLimit") == 0) {
/* args = separator hex; expected = "<count>:<pieces joined by 0x01>" */
double limit = 4294967295.0;
char *comma = strcmp(op, "splitLimit") == 0 ? strchr(args, ',') : NULL;
if (comma) { *comma = '\0'; limit = strtod(comma + 1, NULL); }
size_t sep_len = hex_decode(args, needle_bytes);
if (comma) *comma = ',';
if (sep_len == (size_t)-1) goto badline_release;
ScrStr *sep = scr_str_new(needle_bytes, sep_len);
ScrArr *pieces = scr_str_split(input, sep);
ScrArr *pieces = scr_str_split_limit(input, sep, limit);
size_t count = (size_t)scr_arr_len(pieces);
size_t cap = 32;
for (size_t i = 0; i < count; i++) {
@@ -825,6 +863,30 @@ int main(int argc, char **argv) {
scr_str_release(p);
}
check(op, args, input, joined, o, expected_bytes, exp_len);
/* Consume and release one piece at a time. Empty pieces must remain
* distinct from exhaustion, and resetting must restart the walk. */
ScrSplitCursor cursor;
for (int pass = 0; pass < 2; pass++) {
scr_str_split_cursor_init(&cursor, scr_to_uint32(limit));
ScrStr *scratch = NULL;
size_t n = 0, used = 32;
ScrStr *piece;
while ((piece = scr_str_split_cursor_next(input, sep, &cursor, &scratch))) {
if (n) joined[used++] = '\x01';
memcpy(joined + used, piece->data, piece->len);
used += piece->len;
n++;
scr_str_release(piece);
}
size_t prefix = (size_t)snprintf(joined, 32, "%zu:", n);
total++;
if (n != count || prefix > exp_len || used - 32 != exp_len - prefix ||
memcmp(joined + 32, expected_bytes + prefix, used - 32) != 0)
failed++;
total++;
if (scr_str_split_cursor_next(input, sep, &cursor, &scratch) != NULL) failed++;
scr_str_release(scratch);
}
free(joined);
scr_arr_release(pieces);
scr_str_release(sep);
@@ -889,6 +951,7 @@ int main(int argc, char **argv) {
divergence_asserts();
construction_asserts();
split_storage_asserts();
split_scratch_asserts();
accumulation_asserts();
short_string_asserts();
#ifdef SCR_SIDX_TEST
+131
View File
@@ -0,0 +1,131 @@
function collect(input: string, separator: string, limit: number): string {
let result = "";
for (const piece of input.split(separator, limit)) result += "[" + piece + "]";
return result;
}
for (const limit of [0, 1, 2, 7, -1, 1.9, NaN, Infinity, 4294967296, 4294967297]) {
console.log("limit", limit, collect(",a,,b,", ",", limit));
}
console.log("empty", collect("", "", 9), collect("", ",", 9));
console.log("overlap", collect("ababa", "aba", 9));
console.log("unicode", collect("é世界é中é", "é", 9), collect("世界", "", 9));
console.log("zero", collect("a\0b\0", "\0", 9));
function snapshot(): string {
let input = "left,right";
let separator = ",";
let limit = 2;
const pieces = input.split(separator, limit);
input += ",changed";
separator += "changed";
limit = 0;
let result = "";
for (let pass = 0; pass < 3; pass++) {
for (const piece of pieces) result += "[" + piece + "]";
}
return result + ":" + input + ":" + separator + ":" + limit;
}
console.log("snapshot", snapshot());
let receiver = "first,second";
let effects = "";
function source(): string { effects += "s"; return receiver; }
function separator(): string { effects += "p"; receiver += ",late"; return ","; }
function limit(): number { effects += "l"; return 2; }
let evaluated = "";
for (const piece of source().split(separator(), limit())) {
receiver += ",body";
evaluated += "[" + piece + "]";
}
console.log("order", effects, evaluated, receiver);
function failingSeparator(): string { throw new Error("separator"); }
function failingLimit(): number { throw new Error("limit"); }
try {
for (const piece of (receiver + ",temporary").split(failingSeparator())) console.log(piece);
} catch (error) { console.log("argument", (error as Error).message); }
try {
for (const piece of (receiver + ",temporary").split(receiver + ",separator", failingLimit())) console.log(piece);
} catch (error) { console.log("argument", (error as Error).message); }
function bindings(): string {
const readers: (() => string)[] = [];
for (let piece of "first-word,second-word,third-word".split(",")) {
piece += "!";
readers.push(() => piece);
}
let result = "";
for (const read of readers) result += read();
const shared: (() => string)[] = [];
for (var piece of "four,five".split(",")) shared.push(() => piece);
for (const read of shared) result += read();
let existing = "unchanged";
for (existing of "six,seven".split(",")) result += existing;
result += existing;
for (const [first, second = "_"] of "ab,c".split(",")) result += first + second;
return result;
}
console.log("bindings", bindings());
function exits(input: string): string {
const pieces = input.split(",");
let result = "";
outer: for (const piece of pieces) {
try {
for (const inner of "a,b,c".split(",")) {
if (inner === "a") continue;
if (piece === "break") break outer;
if (piece === "return") return result;
if (piece === "throw") throw new Error("stop");
result += piece + inner;
break;
}
} finally { result += "!"; }
}
return result;
}
console.log("break", exits("one,break,three"));
console.log("return", exits("one,return,three"));
try { console.log(exits("one,throw,three")); } catch (error) { console.log("throw", (error as Error).message); }
function observable(): string {
const pieces = "one,two".split(",");
const alias = pieces;
alias.push("three");
let result = "";
for (const piece of pieces) {
result += piece;
if (piece === "one") pieces.push("four");
}
return result + ":" + pieces.length;
}
console.log("observable", observable());
function escapes(): string {
const pieces = "one,two".split(",");
const read = (): string => pieces.join("/");
let result = "";
for (const piece of pieces) result += piece;
return result + ":" + read();
}
console.log("escapes", escapes());
async function suspended(): Promise<string> {
let input = "one,two";
const pieces = input.split(",");
input += ",late";
let result = "";
for (const piece of pieces) {
await Promise.resolve();
result += piece;
}
for await (const piece of "three,four".split(",")) result += piece;
return result;
}
suspended().then((value) => console.log("async", value));
function* generator(): Generator<string> {
for (const piece of "one,two".split(",")) yield piece;
}
for (const piece of generator()) console.log("generator", piece);
@@ -101,6 +101,7 @@ const programs = [
"3120-ir-nonfinite-numbers.ts",
"4031-event-emitter-long-tuples.ts",
"text-codec-values/main.ts",
"string-split-traversal.ts",
"heap-input-aliases.ts",
"heap-input-collections.ts",
"input-lifetime-intervals.ts",
+1
View File
@@ -157,6 +157,7 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => {
"1002-json-parse-cast.ts",
"1010-json-stringify-space.ts",
"json-output-bulk.ts",
"string-split-traversal.ts",
"1565-union-element-join.ts",
"1020-async-basics.ts",
"1021-async-ordering.ts",