Streamline native string construction

- Combine bounded string concatenations while preserving evaluation and ownership.
- Build array joins and raw strings directly in their final storage.
- Support literal null and undefined string conversions with preserved effects.
This commit is contained in:
Chris Tate
2026-10-05 07:00:05 -05:00
parent 0311082801
commit a451b6b596
12 changed files with 361 additions and 35 deletions
@@ -9,6 +9,7 @@ import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { BYTES_ELEM_NUM, f64Lit } from "./common.js";
import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, stringParts } from "./string-construction.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { exactInteger, widenInteger, integerNumber } from "./integer-values.js";
import { integerArithmeticRange } from "../../ir/integer-ranges.js";
@@ -282,6 +283,9 @@ export function emitStringExpr(host: LlvmEmitterContext, e: ExprOf<"strConcat" |
const B = host.B;
switch (e.kind) {
case "strConcat": {
if (e.left.kind === "strConcat" || e.right.kind === "strConcat") {
return emitStringParts(host, stringParts(e));
}
// Concat may append to a uniquely owned left temporary. A retained
// read protects an existing binding from that in-place operation.
const l = host.emitExpr(e.left);
@@ -0,0 +1,66 @@
import { expect, test } from "vitest";
import { STRING, VOID, type IrExpr, type IrModule } from "../../ir/ir.js";
import { validateModule } from "../../ir/validate.js";
import { emitLlvmModule } from "./emitter.js";
import { stringParts } from "./string-construction.js";
const loc = { file: "construction.ts", start: 0, end: 1 };
const text = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc });
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: STRING, loc });
const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "strConcat", left, right, type: STRING, loc });
function module(value: IrExpr): IrModule {
return { irVersion: 13, sourceFile: loc.file, entry: "main", functions: [
{ name: "main", loc, params: [], locals: [], returnType: VOID, body: [] },
{ name: "assemble", loc, params: [{ name: "value", localId: "value", type: STRING }],
locals: [{ id: "value", name: "value", type: STRING, mutable: true }],
returnType: STRING, body: [{ kind: "return", value, loc }] },
] };
}
test("concat grouping bounds each stack vector and preserves leaf order", () => {
const leaves = Array.from({ length: 100 }, (_, i) => text(String(i)));
for (const tree of [leaves.reduce(concat), leaves.reduceRight((right, left) => concat(left, right))]) {
const seen: IrExpr[] = [];
const walk = (value: IrExpr): void => {
if (value.kind !== "strConcat") { seen.push(value); return; }
const parts = stringParts(value);
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts.length).toBeLessThanOrEqual(16);
for (const part of parts) walk(part);
};
walk(tree);
expect(seen).toEqual(leaves);
}
});
test.each([32, 64] as const)("concat uses a borrowed stack vector with %i-bit size_t", (bits) => {
const mod = module(concat(concat(text("<"), ref("value")), text(">")));
expect(validateModule(mod)).toEqual([]);
const ir = emitLlvmModule(mod, { pointerBits: bits });
const body = /^define internal [^\n]*@sc_bf_assemble\([^]*?^}/m.exec(ir)![0];
expect(body).toContain("alloca [3 x ptr]");
expect(body).toMatch(new RegExp(`call ptr @scr_str_concat_parts\\(ptr %[^,]+, i${bits} 3\\)`));
expect(body).not.toContain("@scr_str_retain_v");
expect(body).not.toContain("@scr_str_release");
});
test("later writes preserve an owned snapshot of earlier string parts", () => {
const changed: IrExpr = { kind: "assignExpr", localId: "value", value: text("new"), type: STRING, loc };
const mod = module(concat(concat(ref("value"), changed), ref("value")));
expect(validateModule(mod)).toEqual([]);
const body = /^define internal [^\n]*@sc_f_assemble\([^]*?^}/m.exec(emitLlvmModule(mod))![0];
const retain = body.indexOf("@scr_str_retain_v");
const release = body.indexOf("@scr_str_release");
const assemble = body.indexOf("@scr_str_concat_parts");
expect(retain).toBeGreaterThanOrEqual(0);
expect(retain).toBeLessThan(release);
expect(release).toBeLessThan(assemble);
expect(body.slice(assemble)).toContain("@scr_str_release");
});
test("two-part concatenation keeps the existing append path", () => {
const ir = emitLlvmModule(module(concat(ref("value"), text("suffix"))));
expect(ir).toContain("call ptr @scr_str_concat(");
expect(ir).not.toContain("@scr_str_concat_parts");
});
@@ -0,0 +1,41 @@
import type { IrExpr } from "../../ir/ir.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { emitStringInputs } from "./string-lifetimes.js";
const MAX_PARTS = 16;
/** Flatten only string concatenation, preserving conversion and operand
* order. Larger trees remain operands of bounded groups, so each call uses
* at most sixteen stack slots and does not need a heap argument array. */
export function stringParts(value: IrExpr): IrExpr[] {
const pending = [value];
const parts: IrExpr[] = [];
while (pending.length > 0) {
const part = pending.pop()!;
if (part.kind === "strConcat" && parts.length + pending.length < MAX_PARTS - 1) {
pending.push(part.right, part.left);
} else {
parts.push(part);
}
}
return parts;
}
/** The runtime borrows every part without mutating it. Earlier operands
* therefore borrow only when their owners survive all later evaluations;
* other operands retain ordinary snapshots and exception cleanup. */
export function emitStringParts(host: LlvmEmitterContext, parts: readonly IrExpr[]): LlValue {
const values = emitStringInputs(host, parts);
const B = host.B;
const storage = B.slot();
B.entryAllocas.push(`${storage} = alloca [${parts.length} x ptr]`);
for (let i = 0; i < values.length; i++) {
const slot = B.tmp();
B.line(`${slot} = getelementptr [${parts.length} x ptr], ptr ${storage}, i32 0, i32 ${i}`);
B.line(`store ptr ${values[i]!.name}, ptr ${slot}`);
}
host.declare(`declare ptr @scr_str_concat_parts(ptr, ${host.sizeType})`);
const result = B.tmp();
B.line(`${result} = call ptr @scr_str_concat_parts(ptr ${storage}, ${host.sizeType} ${parts.length})`);
return host.own({ name: result, type: parts[0]!.type });
}
@@ -9431,6 +9431,7 @@ function lowerStoredTextCodecCall(lowerer: Lowerer, call: ts.CallExpression, acc
}
const subs = call.arguments.slice(1).map((a): IrExpr => {
const v = lowerer.lowerExpr(a);
if (isUnitType(v.type)) return lowerer.ensureString(v, a);
if (v.type.kind === "string") return v;
if (v.type.kind === "f64" || v.type.kind === "bool" || v.type.kind === "record") {
return { kind: "toString", operand: v, type: STRING, loc };
@@ -6311,8 +6311,18 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr
);
}
if (isUnitType(e.type)) {
// `${undefined}` / "" + null: Node prints "undefined"/"null", but
// these only arise spelled literally — reject rather than special-case.
if (e.kind === "unitLit") {
return { kind: "strLit", value: e.unit, type: STRING, loc: e.loc };
}
// Keep sequencing and branch effects even though the final unit's
// spelling is constant. Never turn an unknown producer into a literal.
if (e.kind === "seqExpr") {
return { ...e, result: lowerer.ensureString(e.result, node), type: STRING };
}
if (e.kind === "ternary") {
return { ...e, then: lowerer.ensureString(e.then, node),
else_: lowerer.ensureString(e.else_, node), type: STRING };
}
lowerer.unsupported(
"SC1090",
node,
@@ -14210,6 +14210,12 @@
"<repo>/tests/corpus/checked-dispatch-input-lifetimes.js"
],
"diags": []
},
"<repo>/tests/corpus/string-construction-order.ts": {
"order": [
"<repo>/tests/corpus/string-construction-order.ts"
],
"diags": []
}
}
}
+45 -31
View File
@@ -1335,21 +1335,26 @@ bool scr_arr_includes_ref(ScrArr *a, void *v) {
/* ── join ──────────────────────────────────────────────────────────────── */
static void scr_join_append(char **buf, size_t *len, size_t *cap,
const char *bytes, size_t n) {
if (*len + n > *cap) {
size_t cap2 = *cap;
while (*len + n > cap2) {
if (cap2 > SIZE_MAX / 2) scr_arr_oom();
cap2 *= 2;
}
char *grown = realloc(*buf, cap2);
if (!grown) scr_arr_oom();
*buf = grown;
*cap = cap2;
static size_t scr_join_size(size_t len, size_t extra) {
if (extra > SIZE_MAX - sizeof(ScrStr) - 1 - len) scr_arr_oom();
return len + extra;
}
/* The builder owns the final string allocation throughout construction.
* It has no aliases or index-cache entries, and no user callbacks run while
* appending primitive array values. Finish by publishing the terminator. */
static void scr_join_append(ScrStr **out, const char *bytes, size_t n) {
ScrStr *s = *out;
size_t need = scr_join_size(s->len, n);
if (need > s->cap) {
const size_t limit = SIZE_MAX - sizeof(ScrStr) - 1;
size_t cap = s->cap;
while (cap < need) cap = cap > limit / 2 ? need : cap * 2;
s = scr_str_regrow(s, cap);
*out = s;
}
memcpy(*buf + *len, bytes, n);
*len += n;
memcpy(s->data + s->len, bytes, n);
s->len = need;
}
/* `a.slice(start?, end?)` — a fresh shallow copy of the index range,
@@ -1392,11 +1397,24 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) {
}
ScrStr *scr_arr_join(ScrArr *a, ScrStr *sep) {
size_t cap = 64, len = 0;
char *buf = malloc(cap);
if (!buf) scr_arr_oom();
size_t cap = 64;
/* String widths are already known. Size once to avoid growth and copying
* even when the array holds holes, explicit undefined or sparse slots.
* Numeric formatting stays single-pass rather than formatting twice. */
if (a->elem == SCR_ELEM_STR) {
const size_t limit = SIZE_MAX - sizeof(ScrStr) - 1;
if (a->len > 1 && sep->len > limit / (a->len - 1)) scr_arr_oom();
cap = a->len > 1 ? (a->len - 1) * sep->len : 0;
for (size_t i = 0; i < a->len; i++) {
uint64_t slot;
if (scr_arr_state_at(a, i, &slot) != SCR_ARR_VALUE) continue;
const ScrStr *s = (const ScrStr *)scr_slot_to_ptr(slot);
if (s) cap = scr_join_size(cap, s->len);
}
}
ScrStr *out = scr_str_alloc_raw(0, cap);
for (size_t i = 0; i < a->len; i++) {
if (i > 0) scr_join_append(&buf, &len, &cap, sep->data, sep->len);
if (i > 0) scr_join_append(&out, sep->data, sep->len);
uint64_t slot;
uint8_t state = scr_arr_state_at(a, i, &slot);
if (state != SCR_ARR_VALUE) continue;
@@ -1404,16 +1422,16 @@ ScrStr *scr_arr_join(ScrArr *a, ScrStr *sep) {
case SCR_ELEM_F64: {
char nb[32];
size_t n = scr_f64_to_str(scr_slot_to_f64(slot), nb);
scr_join_append(&buf, &len, &cap, nb, n);
scr_join_append(&out, nb, n);
break;
}
case SCR_ELEM_BOOL:
if (slot != 0) scr_join_append(&buf, &len, &cap, "true", 4);
else scr_join_append(&buf, &len, &cap, "false", 5);
if (slot != 0) scr_join_append(&out, "true", 4);
else scr_join_append(&out, "false", 5);
break;
case SCR_ELEM_STR: {
const ScrStr *s = (const ScrStr *)scr_slot_to_ptr(slot);
if (s) scr_join_append(&buf, &len, &cap, s->data, s->len);
if (s) scr_join_append(&out, s->data, s->len);
break;
}
case SCR_ELEM_ARR:
@@ -1423,8 +1441,7 @@ ScrStr *scr_arr_join(ScrArr *a, ScrStr *sep) {
scr_trap("scriptc: internal error: join on a ref-element array\n");
}
}
ScrStr *out = scr_str_new(buf, len);
free(buf);
out->data[out->len] = '\0';
return out;
}
@@ -1434,25 +1451,22 @@ ScrStr *scr_arr_join(ScrArr *a, ScrStr *sep) {
* missing ones skip, exactly the spec's loop. Both arrays are
* SCR_ELEM_STR; borrows both; +1 result. Never throws. */
ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) {
size_t cap = 64, len = 0;
char *buf = malloc(cap);
if (!buf) scr_arr_oom();
ScrStr *out = scr_str_alloc_raw(0, 64);
for (size_t i = 0; i < raw->len; i++) {
uint64_t raw_slot;
if (scr_arr_slot_at(raw, i, &raw_slot)) {
const ScrStr *s = (const ScrStr *)scr_slot_to_ptr(raw_slot);
if (s) scr_join_append(&buf, &len, &cap, s->data, s->len);
if (s) scr_join_append(&out, s->data, s->len);
}
if (i + 1 < raw->len && i < subs->len) {
uint64_t sub_slot;
if (scr_arr_slot_at(subs, i, &sub_slot)) {
const ScrStr *v = (const ScrStr *)scr_slot_to_ptr(sub_slot);
if (v) scr_join_append(&buf, &len, &cap, v->data, v->len);
if (v) scr_join_append(&out, v->data, v->len);
}
}
}
ScrStr *out = scr_str_new(buf, len);
free(buf);
out->data[out->len] = '\0';
return out;
}
+4 -2
View File
@@ -437,7 +437,7 @@ typedef struct ScrVt {
*
* cap is the usable byte capacity of data[] excluding the NUL (allocation
* is sizeof(ScrStr) + cap + 1); cap == len for interned literals and plain
* allocations. Spare capacity (cap > len) exists only on concat results so
* allocations. Builders and concat results may have spare capacity so
* scr_str_concat can append in place when the left operand is uniquely
* owned (rc == 1) — observable immutability is preserved: a string with
* rc > 1 or rc == SIZE_MAX is never mutated.
@@ -454,7 +454,7 @@ ScrStr *scr_str_new(const char *bytes, size_t len); /* returns +1 */
* malformed subsequences with U+FFFD. NULL with len == 0 is an empty span. */
ScrStr *scr_str_from_utf8_lossy(const uint8_t *bytes, size_t len); /* +1 */
/* Internal allocators for buffer builders (scr_json.c): a +1 string with
/* Internal allocators for buffer builders: a +1 string with
* UNINITIALIZED data (the builder fills bytes, then len and the NUL), and
* an rc==1-only realloc that grows capacity in place. Both keep the RC
* audit's live count exact. */
@@ -470,6 +470,8 @@ void scr_str_release(ScrStr *s); /* NULL-tolerant (uninitialized locals) */
/* Borrow both args, return +1. */
ScrStr *scr_str_concat(ScrStr *a, ScrStr *b);
/* Borrow each part without mutation; return one independently owned result. */
ScrStr *scr_str_concat_parts(ScrStr *const *parts, size_t count);
bool scr_str_eq(ScrStr *a, ScrStr *b);
+29
View File
@@ -271,6 +271,7 @@ ScrStr *scr_str_alloc_raw(size_t len, size_t cap) {
}
ScrStr *scr_str_regrow(ScrStr *s, size_t newcap) {
if (newcap < s->len || newcap > SIZE_MAX - sizeof(ScrStr) - 1) scr_oom();
scr_short_forget(s);
scr_sidx_purge(s); /* realloc may move; the old address may be recycled */
ScrStr *r = realloc(s, sizeof(ScrStr) + newcap + 1);
@@ -300,6 +301,8 @@ void scr_str_release(ScrStr *s) {
}
ScrStr *scr_str_concat(ScrStr *a, ScrStr *b) {
if (a->len == 0) return scr_str_retain(b);
if (b->len == 0) return scr_str_retain(a);
if (a->len > SIZE_MAX - b->len - sizeof(ScrStr) - 1) scr_oom();
size_t newlen = a->len + b->len;
/* In-place append: a is uniquely owned by the caller's borrow (rc == 1 —
@@ -343,6 +346,32 @@ ScrStr *scr_str_concat(ScrStr *a, ScrStr *b) {
return s;
}
ScrStr *scr_str_concat_parts(ScrStr *const *parts, size_t count) {
const size_t limit = SIZE_MAX - sizeof(ScrStr) - 1;
size_t len = 0;
ScrStr *only = NULL;
size_t nonempty = 0;
for (size_t i = 0; i < count; i++) {
const size_t n = parts[i]->len;
if (n > limit - len) scr_oom();
len += n;
if (n != 0) {
only = parts[i];
nonempty++;
}
}
if (nonempty == 1) return scr_str_retain(only);
ScrStr *out = scr_str_alloc_raw(len, len);
size_t offset = 0;
for (size_t i = 0; i < count; i++) {
const ScrStr *part = parts[i];
memcpy(out->data + offset, part->data, part->len);
offset += part->len;
}
out->data[len] = '\0';
return out;
}
bool scr_str_eq(ScrStr *a, ScrStr *b) {
return a == b || (a->len == b->len && memcmp(a->data, b->data, a->len) == 0);
}
+30
View File
@@ -549,6 +549,36 @@ static void test_join(void) {
scr_str_release(sep2);
scr_arr_release(s);
s = scr_arr_new(SCR_ELEM_STR, 1);
ScrStr *shared = scr_str_new("x\0y", 3);
scr_arr_set_ref(s, 0, scr_str_retain(shared));
scr_arr_set_undefined(s, 1);
scr_arr_set_ref(s, 8192, scr_str_retain(shared));
scr_arr_set_ref(s, -1, scr_str_new("ignored", 7));
sep0 = scr_str_new("", 0);
strs = scr_arr_join(s, sep0);
check(strs->len == 6 && memcmp(strs->data, "x\0yx\0y", 6) == 0,
"join handles sparse values and embedded zero without properties");
check(shared->rc == 3 && strs->data[strs->len] == '\0',
"join preserves duplicate input owners and terminates final storage");
scr_str_release(strs);
strs = scr_arr_join(s, sep);
check(strs->len == 8198 && strs->data[3] == ',' && strs->data[8195] == 'x',
"join sizing includes separators for sparse holes and undefined");
scr_str_release(strs);
scr_arr_release(s);
scr_str_release(shared);
scr_str_release(sep0);
n = scr_arr_new(SCR_ELEM_F64, 0);
for (size_t i = 0; i < 300; i++) scr_arr_push_f64(n, 123.5);
nums = scr_arr_join(n, sep);
check(nums->len == 1799 && nums->data[1799] == '\0' &&
memcmp(nums->data + 1794, "123.5", 5) == 0,
"numeric join grows final storage without losing its suffix");
scr_str_release(nums);
scr_arr_release(n);
scr_str_release(sep);
#ifdef SCR_RC_AUDIT
check(scr_str_live_count() == strings0, "join: no strings leaked");
+36
View File
@@ -171,6 +171,41 @@ static void handoff_append(ScrStr **binding, ScrStr *suffix) {
scr_str_release(snapshot);
}
static void construction_asserts(void) {
ScrStr *empty = scr_str_new("", 0);
ScrStr *value = scr_str_new("a\0\xE6\x97\xA5", 5);
ScrStr *parts[] = {empty, value, empty, value};
ScrStr *out = scr_str_concat_parts(parts, 4);
total++;
if (out->len != 10 || memcmp(out->data, value->data, 5) != 0 ||
memcmp(out->data + 5, value->data, 5) != 0 || out->data[10] != '\0' ||
value->rc != 1 || value->len != 5) {
failed++;
fputs("CONSTRUCTION: duplicate inputs, embedded zero or ownership\n", stderr);
}
scr_str_release(out);
ScrStr *single = scr_str_concat_parts(parts, 3);
ScrStr *left = scr_str_concat(empty, value);
ScrStr *right = scr_str_concat(value, empty);
total++;
if (single != value || left != value || right != value || value->rc != 4) {
failed++;
fputs("CONSTRUCTION: empty operands must preserve independent owners\n", stderr);
}
scr_str_release(single);
scr_str_release(left);
scr_str_release(right);
out = scr_str_concat_parts(NULL, 0);
total++;
if (out->len != 0 || out->data[0] != '\0') {
failed++;
fputs("CONSTRUCTION: empty part list\n", stderr);
}
scr_str_release(out);
scr_str_release(value);
scr_str_release(empty);
}
static void accumulation_asserts(void) {
ScrStr *piece = scr_str_new("x", 1);
ScrStr *acc = scr_str_new("", 0);
@@ -733,6 +768,7 @@ int main(int argc, char **argv) {
if (in != stdin) fclose(in);
divergence_asserts();
construction_asserts();
accumulation_asserts();
short_string_asserts();
#ifdef SCR_SIDX_TEST
+87
View File
@@ -0,0 +1,87 @@
function changingParts(): void {
let text = "initial";
const alias = text;
const replace = (): string => {
text = "replacement";
return "middle";
};
console.log(text + "/" + replace() + "/" + text, alias);
text = "again";
console.log(`${text}:${replace()}:${text}:${alias}`);
console.log((text + "-") + (replace() + "!") + text);
}
changingParts();
function projections(): void {
const holder = { text: "before" };
const change = (): string => {
holder.text = "after";
return "changed";
};
console.log(holder.text + " / " + change() + " / " + holder.text);
}
projections();
function throwingParts(): void {
let order = "";
const part = (name: string): string => {
order += name;
if (name === "b") throw new Error("stopped");
return name.repeat(3);
};
try {
console.log(`${part("a")}:${part("b")}:${part("c")}`);
} catch (error) {
console.log((error as Error).message, order);
}
}
throwingParts();
const names = ["alpha", "日本語", "😀", "zero\0byte"];
for (const name of names) {
console.log("[" + name + "]" + name + ":" + name);
console.log(`${name}|${-0}|${NaN}|${Infinity}|${false}|${null}|${undefined}`);
console.log("" + name, name + "", "" + name + "");
}
console.log(String(null), String(undefined), "null=" + null, undefined + "=missing");
let effects = 0;
const condition = (): boolean => { effects++; return effects % 2 === 0; };
console.log(`${condition() ? null : null}/${condition() ? null : null}/${undefined}`, effects);
console.log(String((condition(), null)), String((condition(), undefined)), effects);
const words: string[] = ["first"];
words[2] = "last";
words.length = 5;
console.log(words.join("::"), words.join(""));
words[20] = "far";
words[-1] = "property";
console.log(words.join("|").length, words.join("") === "firstlastfar");
console.log([true, false, true].join("/"));
console.log([NaN, Infinity, -Infinity, -0, 1e-7, 1e21].join(","));
const raw = ["<", ">", "</", ">"];
console.log(String.raw({ raw }, "tag", "text", "tag"));
console.log(String.raw({ raw }, "tag"));
const empty: string[] = [];
console.log(String.raw({ raw: empty }, "unused"));
console.log(String.raw({ raw }, null, undefined, false));
let acc = "";
const retained: string[] = [];
for (let i = 0; i < 6; i++) {
retained.push(acc);
acc = acc + ("[" + names[i % names.length]! + "]");
}
console.log(acc, retained.join("|"));
async function suspendedParts(): Promise<void> {
let text = "before";
const change = async (): Promise<string> => {
await Promise.resolve();
text = "after";
return "middle";
};
console.log(`${text}/${await change()}/${text}`);
}
await suspendedParts();