diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 0243763f1..7c4a8aed1 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -68,7 +68,7 @@ import { type StoredSplitSnapshot, type StringSplit, } from "./split-loops.js"; -import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; +import { scalarizeLiteralResults, scalarizeNumericRecords } from "../../ir/scalar-records.js"; import { addRecordResultEntries, resultEntryName } from "./record-results.js"; import { specializeCallbackCalls } from "../../ir/callback-specialization.js"; import { @@ -578,27 +578,29 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { ? selectInt32Arrays( versionParameters( addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - inlineExpressionFunctions( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + scalarizeLiteralResults( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + ), ), ), ), ), ), ), + mangleFunction, ), mangleFunction, ), - mangleFunction, ), ), ), diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index a74878080..c4f2bedc4 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -286,8 +286,9 @@ function bump(s: State): State { return { ...s, count: s.count + 1 }; } export function tokens(n: number, w: string): number { const t = { kind: "id", text: w, pos: n }; const u = make("num", w, n); - const [q, r] = divmod(n, 7); - return t.pos + width(u) + width({ kind: w, text: w, pos: 0 }) + q + r; + // Read by index: a destructured literal result is inlined instead. + const qr = divmod(n, 7); + return t.pos + width(u) + width({ kind: w, text: w, pos: 0 }) + qr[0] + qr[1]; } export function states(n: number, w: string): number { let s: State = { name: w, count: 0 }; diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index d00c681d6..35abaec14 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -8,6 +8,7 @@ import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse import { F64_INF, f64Lit, sizeToDouble } from "./common.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js"; type Slice = IrExpr & { kind: "strIntrinsic" }; @@ -66,6 +67,58 @@ export function findScalarStringSlices( return slices; } +/** scr_slice_unit: a slice()/substring() bound `v` over `length` units. A + * non-negative bound clamps to the length; a negative one counts from the + * end for slice (truncated toward zero) and is 0 for substring, as is NaN. */ +function emitSliceUnit( + host: LlvmEmitterContext, + v: string, + length: string, + substring: boolean, +): string { + const B = host.B; + const size = host.sizeType; + const lengthNumber = B.tmp(), + nonNegative = B.tmp(), + past = B.tmp(), + whole = B.tmp(), + clamped = B.tmp(), + result = B.tmp(); + B.line(`${lengthNumber} = ${sizeToDouble(size)} ${size} ${length} to double`); + B.line(`${nonNegative} = fcmp oge double ${v}, 0.0`); + B.line(`${past} = fcmp oge double ${v}, ${lengthNumber}`); + // Frozen: the conversion is only used where the bound is in range. + const raw = B.tmp(); + B.line(`${raw} = fptosi double ${v} to i64`); + B.line(`${whole} = freeze i64 ${raw}`); + let unit = whole; + if (size !== "i64") { + unit = B.tmp(); + B.line(`${unit} = trunc i64 ${whole} to ${size}`); + } + B.line(`${clamped} = select i1 ${past}, ${size} ${length}, ${size} ${unit}`); + if (substring) { + B.line(`${result} = select i1 ${nonNegative}, ${size} ${clamped}, ${size} 0`); + return result; + } + // Negative: (-length, -1] counts back from the end; (-1, 0) truncates to + // 0, and at or below -length, and NaN, give 0. + const negLength = B.tmp(), + inside = B.tmp(), + zero = B.tmp(), + back = B.tmp(), + counted = B.tmp(), + negative = B.tmp(); + B.line(`${negLength} = fneg double ${lengthNumber}`); + B.line(`${inside} = fcmp ogt double ${v}, ${negLength}`); + B.line(`${zero} = icmp eq ${size} ${unit}, 0`); + B.line(`${back} = add ${size} ${length}, ${unit}`); + B.line(`${counted} = select i1 ${zero}, ${size} 0, ${size} ${back}`); + B.line(`${negative} = select i1 ${inside}, ${size} ${counted}, ${size} 0`); + B.line(`${result} = select i1 ${nonNegative}, ${size} ${clamped}, ${size} ${negative}`); + return result; +} + export interface StringSliceSnapshot { source: string; range: string; @@ -87,10 +140,55 @@ export function emitStringSliceSnapshot( `${range} = alloca { ${host.sizeType}, ${host.sizeType}, i32 }`, ); B.line(`store ptr ${source.name}, ptr ${sourceSlot}`); + const start = args[0]?.name ?? f64Lit(0), + end = args[1]?.name ?? F64_INF; + const substring = slice.method === "substring"; + // A proven-ASCII receiver's units are its bytes, so the range is the + // runtime's clamped bounds over its byte length, and no boundary splits a + // surrogate pair (scr_str_slice_range); other strings ask the runtime. + const ascii = B.newLabel("slice.range.ascii"), + runtime = B.newLabel("slice.range.runtime"), + done = B.newLabel("slice.range.done"); + B.condBr(emitStringAsciiFlag(host, source.name), ascii, runtime); + B.startBlock(ascii); + const size = host.sizeType; + const length = emitStringByteLength(host, source.name); + let from = emitSliceUnit(host, start, length, substring), + to = emitSliceUnit(host, end, length, substring); + if (substring) { + const swap = B.tmp(), + low = B.tmp(), + high = B.tmp(); + B.line(`${swap} = icmp ugt ${size} ${from}, ${to}`); + B.line(`${low} = select i1 ${swap}, ${size} ${to}, ${size} ${from}`); + B.line(`${high} = select i1 ${swap}, ${size} ${from}, ${size} ${to}`); + from = low; + to = high; + } + const nonEmpty = B.tmp(), + difference = B.tmp(), + units = B.tmp(), + startPtr = B.tmp(), + lengthPtr = B.tmp(), + splitPtr = B.tmp(); + B.line(`${nonEmpty} = icmp ugt ${size} ${to}, ${from}`); + B.line(`${difference} = sub ${size} ${to}, ${from}`); + B.line(`${units} = select i1 ${nonEmpty}, ${size} ${difference}, ${size} 0`); + const rangeType = `{ ${size}, ${size}, i32 }`; + B.line(`${startPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 0`); + B.line(`store ${size} ${from}, ptr ${startPtr}`); + B.line(`${lengthPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 1`); + B.line(`store ${size} ${units}, ptr ${lengthPtr}`); + B.line(`${splitPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 2`); + B.line(`store i32 0, ptr ${splitPtr}`); + B.br(done); + B.startBlock(runtime); host.declare("declare void @scr_str_slice_range(ptr, double, double, i1 zeroext, ptr)"); B.line( - `call void @scr_str_slice_range(ptr ${source.name}, double ${args[0]?.name ?? f64Lit(0)}, double ${args[1]?.name ?? F64_INF}, i1 ${slice.method === "substring" ? "true" : "false"}, ptr ${range})`, + `call void @scr_str_slice_range(ptr ${source.name}, double ${start}, double ${end}, i1 ${substring ? "true" : "false"}, ptr ${range})`, ); + B.br(done); + B.startBlock(done); host.toFrame(source); return { source: sourceSlot, range }; } diff --git a/packages/compiler/src/ir/scalar-records.test.ts b/packages/compiler/src/ir/scalar-records.test.ts index e3132088d..7b355ee26 100644 --- a/packages/compiler/src/ir/scalar-records.test.ts +++ b/packages/compiler/src/ir/scalar-records.test.ts @@ -10,7 +10,7 @@ import { type IrStmt, type IrType, } from "./ir.js"; -import { scalarizeNumericRecords } from "./scalar-records.js"; +import { scalarizeLiteralResults, scalarizeNumericRecords } from "./scalar-records.js"; import { validateModule } from "./validate.js"; const loc = { file: "scalar-records.ts", start: 0, end: 0 }; @@ -284,3 +284,161 @@ test("bounds expansion in callers with many eligible calls", () => { expect(remaining).toBeGreaterThan(0); expect(remaining).toBeLessThan(300); }); + +const tuple: IrType = { kind: "record", shapeId: "t0" }; +const strRef = (localId: string): IrExpr => ref(localId, STRING); +const element = (localId: string, field: string, type: IrType): IrStmt => ({ + kind: "varDecl", + localId, + init: { kind: "recordGet", obj: ref("result", tuple), shapeId: "t0", field, type, loc }, + loc, +}); + +/** `split(text, at)` returning `[text.slice(0, at), text.slice(at), at > 0]`, + * and a `main` destructuring it. */ +function literalFixture(after: IrStmt[]): IrModule { + const slice = (args: IrExpr[]): IrExpr => ({ + kind: "strIntrinsic", + method: "slice", + receiver: strRef("text"), + args, + type: STRING, + loc, + }); + return { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { + id: "t0", + fields: [ + { name: "0", type: STRING }, + { name: "1", type: STRING }, + { name: "2", type: BOOL }, + ], + tuple: true, + }, + ], + functions: [ + { + name: "split", + params: [ + { localId: "text", name: "text", type: STRING }, + { localId: "at", name: "at", type: F64 }, + ], + returnType: tuple, + locals: [ + { id: "text", name: "text", type: STRING, mutable: false }, + { id: "at", name: "at", type: F64, mutable: false }, + ], + body: [ + { + kind: "return", + value: { + kind: "recordLit", + type: tuple, + fields: [ + { name: "0", value: slice([num(0), ref("at")]) }, + { name: "1", value: slice([ref("at")]) }, + { + name: "2", + value: { kind: "bin", op: ">", left: ref("at"), right: num(0), type: BOOL, loc }, + }, + ], + loc, + }, + loc, + }, + ], + loc, + }, + { + name: "main", + params: [{ localId: "source", name: "source", type: STRING }], + returnType: VOID, + locals: [ + { id: "source", name: "source", type: STRING, mutable: false }, + { id: "result", name: "result", type: tuple, mutable: false }, + { id: "head", name: "head", type: STRING, mutable: false }, + { id: "tail", name: "tail", type: STRING, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "result", + init: call("split", [strRef("source"), call("effect", [num(1)])], tuple), + loc, + }, + ...after, + ], + loc, + }, + { + name: "effect", + params: [{ localId: "x", name: "x", type: F64 }], + returnType: F64, + locals: [{ id: "x", name: "x", type: F64, mutable: true }], + body: [{ kind: "return", value: ref("x"), loc }], + loc, + }, + ], + }; +} + +function literalTransformed(mod: IrModule): IrModule { + expect(validateModule(mod)).toEqual([]); + const out = scalarizeLiteralResults(mod); + expect(validateModule(out)).toEqual([]); + return out; +} + +test("declares destructured elements from a literal producer's fields in evaluation order", () => { + const mod = literalFixture([element("head", "0", STRING), element("tail", "1", STRING)]); + const snapshot = structuredClone(mod); + const out = literalTransformed(mod); + expect(mod).toEqual(snapshot); + const caller = main(out); + expect(nodes(caller.body, "recordGet")).toEqual([]); + expect(nodes(caller.body, "call").map((c) => c["callee"])).toEqual(["effect"]); + const declared = caller.body.map((s) => (s.kind === "varDecl" ? s.localId : s.kind)); + // Parameters, then every field in the literal's order: the unread third + // field still evaluates, into a binding of its own. + expect(declared.slice(2)).toEqual(["head", "tail", declared[4]]); + expect(caller.locals.find((l) => l.id === declared[4])?.type).toEqual(BOOL); + expect(caller.locals.some((l) => l.id === "result")).toBe(false); + // Slices stay slices of the renamed parameter. + expect(nodes(caller.body, "strIntrinsic").map((n) => (n["receiver"] as IrExpr).kind)).toEqual([ + "varRef", + "varRef", + ]); +}); + +test("keeps the call when the result is read other than by adjacent element declarations", () => { + const escaped = literalFixture([ + element("head", "0", STRING), + { kind: "exprStmt", expr: ref("result", tuple), loc }, + ]); + expect(literalTransformed(escaped)).toBe(escaped); + const apart = literalFixture([ + element("head", "0", STRING), + { kind: "exprStmt", expr: num(0), loc }, + element("tail", "1", STRING), + ]); + expect(literalTransformed(apart)).toBe(apart); +}); + +test("keeps the call when an argument holds a counted temporary of its own", () => { + const mod = literalFixture([element("head", "0", STRING)]); + const decl = main(mod).body[0] as IrStmt & { kind: "varDecl" }; + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: { kind: "strLit", value: "abc", type: STRING, loc }, + args: [], + type: F64, + loc, + }; + (decl.init as IrExpr & { kind: "call" }).args[1] = length; + expect(literalTransformed(mod)).toBe(mod); +}); diff --git a/packages/compiler/src/ir/scalar-records.ts b/packages/compiler/src/ir/scalar-records.ts index 0a5b0c80f..f75e522bf 100644 --- a/packages/compiler/src/ir/scalar-records.ts +++ b/packages/compiler/src/ir/scalar-records.ts @@ -10,6 +10,7 @@ import { } from "./ir.js"; import { everyExpr, + transformExpr, everyExprChild, everyStmtChild, everyStmtList, @@ -362,3 +363,323 @@ export function scalarizeNumericRecords(mod: IrModule): IrModule { }); return changed ? { ...mod, functions } : mod; } + +/** A producer whose whole body is `return `, with number, boolean + * or string fields, inlined where its result is destructured. */ +interface LiteralProducer { + fn: IrFunction; + shape: IrRecordShape; + literal: IrExpr & { kind: "recordLit" }; + size: number; +} + +/** Whether `value` holds no counted temporary of its own: every counted + * node in it only views a binding, so evaluating it in its own statement + * shortens no lifetime. A message argument of a throw counts as nothing: it + * lives until the throw, which ends the evaluation anyway. */ +function viewsOnly(value: IrExpr): boolean { + if (!isRefCounted(value.type)) return everyExprChild(value, viewsOnly, () => false); + switch (value.kind) { + case "varRef": + return true; + case "unionNarrow": + return viewsOnly(value.value); + case "ternary": + return viewsOnly(value.cond) && viewsOnly(value.then) && viewsOnly(value.else_); + case "libCall": + return value.fn === "error.nodeThrow"; + default: + return false; + } +} + +/** The node's own value may be counted (it moves into the binding that + * takes it); everything under it must hold no temporary (viewsOnly), as + * scalarTemporaries requires for the numeric pass. */ +function ownedTemporaries(value: IrExpr): boolean { + return everyExprChild(value, viewsOnly, () => false); +} + +const LITERAL_FIELD_KINDS: ReadonlySet = new Set(["f64", "bool", "string"]); + +function literalProducer( + fn: IrFunction, + shapes: ReadonlyMap, +): LiteralProducer | null { + if ( + fn.async || + fn.generator || + fn.captures?.length || + fn.classCaptures?.length || + fn.returnType.kind !== "record" || + fn.body.length !== 1 + ) + return null; + const shape = shapes.get(fn.returnType.shapeId); + if ( + !shape || + shape.indexValue || + shape.fields.length === 0 || + shape.fields.length > MAX_FIELDS || + shape.fields.some((f) => !LITERAL_FIELD_KINDS.has(f.type.kind)) + ) + return null; + // Every local is a parameter: the body declares nothing. + const params = new Set(fn.params.map((p) => p.localId)); + if (fn.locals.some((l) => l.boxed || l.tdz || !params.has(l.id))) return null; + const ret = fn.body[0]!; + const literal = ret.kind === "return" ? ret.value : null; + if ( + literal?.kind !== "recordLit" || + literal.type.kind !== "record" || + literal.type.shapeId !== shape.id || + literal.fields.length !== shape.fields.length || + !literal.fields.every( + (f) => + !f.overflow && + shape.fields.some((sf) => sf.name === f.name && sf.type.kind === f.value.type.kind) && + ownedTemporaries(f.value), + ) + ) + return null; + let size = 0; + const small = everyExpr(literal, { + expr: (node) => + ++size <= MAX_CALLEE_NODES && + node.kind !== "closure" && + node.kind !== "selfRef" && + node.kind !== "awaitExpr" && + node.kind !== "awaitUnionExpr" && + node.kind !== "yieldExpr", + stmt: () => false, + }); + return small ? { fn, shape, literal, size } : null; +} + +/** Rewrite every statement list under `body`, innermost first. */ +function mapStatementLists(body: IrStmt[], f: (list: IrStmt[]) => IrStmt[]): IrStmt[] { + const lists = (s: IrStmt): IrStmt => { + switch (s.kind) { + case "if": + return { + ...s, + then: mapStatementLists(s.then, f), + else_: s.else_ === null ? null : mapStatementLists(s.else_, f), + }; + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + return { ...s, body: mapStatementLists(s.body, f) }; + case "switch": + return { + ...s, + cases: s.cases.map((c) => ({ + ...c, + body: mapStatementLists(c.body, f), + })), + }; + case "tryCatch": + return { + ...s, + tryBody: mapStatementLists(s.tryBody, f), + catchBody: s.catchBody === null ? null : mapStatementLists(s.catchBody, f), + finallyBody: s.finallyBody === null ? null : mapStatementLists(s.finallyBody, f), + }; + default: + return s; + } + }; + return f(body.map(lists)); +} + +/** How often each local is read, anywhere in `fn`. */ +function localReads(fn: IrFunction): Map { + const reads = new Map(); + const count = (id: string): void => { + reads.set(id, (reads.get(id) ?? 0) + 1); + }; + everyStmtList(fn.body, { + expr: (node) => { + switch (node.kind) { + case "varRef": + case "incDec": + case "assignExpr": + count(node.localId); + break; + case "closure": + case "classRef": + for (const id of node.captures ?? []) count(id); + break; + } + return true; + }, + stmt: (node) => { + if (node.kind === "assign" || node.kind === "forOf" || node.kind === "rethrow") + count(node.localId); + return true; + }, + }); + return reads; +} + +/** Inline small literal producers where the result is only destructured. + * + * `const [head, tail] = split(text, at)`, where `split` is + * `return [text.slice(0, at), text.slice(at)]`, lowers to a result binding + * and one declaration per element read from it, right after the call. Each + * such declaration now takes its field's value directly: the parameters are + * declared from the arguments in order, then the fields in the literal's + * order (unread fields go to fresh bindings), then the destructured + * bindings, so evaluation order is unchanged and no record is built. A + * slice element read only through its length then needs no copy at all + * (findScalarStringSlices). The declarations stay in the caller's own + * statement list, so every binding keeps its scope; parameters live to the + * end of it, which only lengthens the arguments' lifetimes. Every other use + * of the result binding keeps the call. */ +export function scalarizeLiteralResults(mod: IrModule): IrModule { + const shapes = new Map((mod.records ?? []).map((s) => [s.id, s])); + const producers = new Map(); + for (const fn of mod.functions) { + const p = literalProducer(fn, shapes); + if (p) producers.set(fn.name, p); + } + if (producers.size === 0) return mod; + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((l) => [l.id, l])); + const used = new Set([...locals.keys(), ...(mod.globals ?? []).map((g) => g.id)]); + collectLabels(fn.body, used); + let next = 0; + const fresh = (): string => { + let id: string; + do { + id = `%literal.${next++}`; + } while (used.has(id)); + used.add(id); + return id; + }; + let reads: Map | null = null; + const added: IrLocal[] = []; + const removed = new Set(); + let budget = MAX_INLINE_NODES; + const rewrite = (list: IrStmt[]): IrStmt[] => { + const out: IrStmt[] = []; + for (let i = 0; i < list.length; i++) { + const decl = list[i]!; + const call = decl.kind === "varDecl" ? decl.init : null; + const p = call?.kind === "call" ? producers.get(call.callee) : undefined; + const local = decl.kind === "varDecl" ? locals.get(decl.localId) : undefined; + if ( + decl.kind !== "varDecl" || + call?.kind !== "call" || + !p || + !local || + p.fn.name === fn.name || + p.size > budget || + local.mutable || + local.boxed || + local.tdz || + local.type.kind !== "record" || + local.type.shapeId !== p.shape.id || + call.args.length !== p.fn.params.length || + !call.args.every(ownedTemporaries) + ) { + out.push(decl); + continue; + } + // The element declarations right after the call, one per field. + const elements = new Map(); + let j = i + 1; + for (; j < list.length; j++) { + const element = list[j]!; + const get = element.kind === "varDecl" ? element.init : null; + const target = element.kind === "varDecl" ? locals.get(element.localId) : undefined; + if ( + element.kind !== "varDecl" || + get?.kind !== "recordGet" || + get.obj.kind !== "varRef" || + get.obj.localId !== local.id || + get.shapeId !== p.shape.id || + elements.has(get.field) || + !target || + target.boxed || + target.tdz || + target.type.kind !== get.type.kind + ) + break; + elements.set(get.field, element); + } + reads ??= localReads(fn); + if (elements.size === 0 || reads.get(local.id) !== elements.size) { + out.push(decl); + continue; + } + budget -= p.size; + changed = true; + removed.add(local.id); + const renamed = new Map(p.fn.params.map((param) => [param.localId, fresh()])); + for (const l of p.fn.locals) { + // Copied field by field: the native compiler does not spread a + // record whose type has a union. + const copy: IrLocal = { + id: renamed.get(l.id)!, + name: l.name, + type: l.type, + mutable: false, + }; + if (l.source !== undefined) copy.source = l.source; + added.push(copy); + } + p.fn.params.forEach((param, index) => { + out.push({ + kind: "varDecl", + localId: renamed.get(param.localId)!, + init: call.args[index]!, + loc: decl.loc, + }); + }); + const rename = (value: IrExpr): IrExpr => + transformExpr(value, { + expr: (node) => + node.kind === "varRef" && renamed.has(node.localId) + ? { ...node, localId: renamed.get(node.localId)! } + : node, + stmt: (node) => node, + }); + for (const field of p.literal.fields) { + const element = elements.get(field.name); + let id = element?.localId; + if (id === undefined) { + id = fresh(); + const type = p.shape.fields.find((f) => f.name === field.name)!.type; + added.push({ + id, + name: `${local.name}.${field.name}`, + type, + mutable: false, + }); + } + out.push({ + kind: "varDecl", + localId: id, + init: rename(field.value), + loc: element?.loc ?? decl.loc, + }); + } + i = j - 1; + } + return out; + }; + const body = mapStatementLists(fn.body, rewrite); + if (!changed || removed.size === 0) return fn; + return { + ...fn, + locals: [...fn.locals.filter((l) => !removed.has(l.id)), ...added], + body, + }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json new file mode 100644 index 000000000..9cd832dca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8241-literal-result-slices.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 37b92e48c..2d7f46d85 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -2210,6 +2210,17 @@ static void scr_str_slice_bounds(ScrStr *s, ScrSidx *e, double start, double end void scr_str_slice_range(ScrStr *s, double start, double end, bool substring, ScrStringSlice *out) { + if (__builtin_expect(s->ascii, 1)) { + /* Units are bytes: no index entry, and no boundary splits a pair. */ + size_t length = s->len; + size_t from = scr_slice_unit(start, length, substring); + size_t to = scr_slice_unit(end, length, substring); + if (substring && from > to) { size_t swap = from; from = to; to = swap; } + out->start = from; + out->length = to > from ? to - from : 0; + out->split = 0; + return; + } ScrSidx *e = scr_sidx_for(s); size_t from, to; scr_str_slice_bounds(s, e, start, end, substring, &from, &to); diff --git a/tests/corpus/8241-literal-result-slices.ts b/tests/corpus/8241-literal-result-slices.ts new file mode 100644 index 000000000..11de82819 --- /dev/null +++ b/tests/corpus/8241-literal-result-slices.ts @@ -0,0 +1,81 @@ +// Destructured results of small literal producers: string slices, numbers +// and booleans, with evaluation order, unread fields, throws and every +// slice() and substring() bound kind. +let trace = ""; +function mark(tag: string, value: T): T { + trace += tag; + return value; +} + +function split(text: string, at: number): [string, string] { + return [text.slice(0, at), text.slice(at)]; +} +function cut(text: string, from: number, to: number): [string, number, boolean] { + return [text.substring(from, to), text.length, from > to]; +} +function divmod(a: number, b: number): [number, number] { + return [Math.floor(a / b), a % b]; +} +function pair(text: string): { head: string; size: number } { + return { size: mark("s", text.length), head: mark("h", text.slice(1, -1)) }; +} + +const bounds = [0, 1, 2, 3, -1, -2, -3, -10, 10, 0.5, -0.5, 1.9, -1.9, -0, NaN, Infinity, -Infinity]; +const texts = ["", "a", "ab", "abcdef", "héllo", "東京ok", "a\u{1F600}b"]; +let lengths = 0; +let units = 0; +for (const text of texts) { + for (const at of bounds) { + const [head, tail] = split(text, at); + lengths = lengths * 7 + head.length * 3 + tail.length; + lengths %= 1000000007; + if (head.length > 0) units += head.charCodeAt(head.length - 1); + if (tail.length > 0) units += tail.charCodeAt(0); + for (const to of bounds) { + const [part, size, swapped] = cut(text, at, to); + lengths = (lengths * 5 + part.length + size + (swapped ? 1 : 0)) % 1000000007; + } + } +} +console.log("lengths", lengths, "units", units); + +// The pieces themselves, as strings. +const [left, right] = split("scriptc", 3); +console.log(left, right, `${left}|${right}`); + +// Arguments, then fields in the literal's order, then the bindings. +trace = ""; +const { head, size } = pair(mark("a", "[inner]")); +console.log("order", trace, head.length, size); + +// Unread fields still run. +trace = ""; +const [onlyHead] = split(mark("x", "unread"), mark("y", 2)); +const { size: onlySize } = pair("<>"); +console.log("unread", trace, onlyHead, onlySize); + +// A mutable destructured binding. +let [first, rest] = split("mutable", 1); +first += "!"; +console.log("let", first, rest.length); + +// Numbers and a throw from an argument. +let sum = 0; +for (let i = 0; i < 50; i++) { + const [q, r] = divmod(i * 31, 17); + sum += q * 3 + r; +} +console.log("divmod", sum); +const list: (string | undefined)[] = ["xy", undefined]; +for (const item of list) { + try { + const [a, b] = split(item!, 1); + console.log("ok", a.length, b.length); + } catch (error) { + console.log("threw", error instanceof TypeError); + } +} + +// The producer keeps working for every other caller. +const whole = split("kept", 2); +console.log("kept", whole[0], whole[1], whole.length); diff --git a/tests/harness/corpus-costs/8241-literal-result-slices.ts.json b/tests/harness/corpus-costs/8241-literal-result-slices.ts.json new file mode 100644 index 000000000..be69de5f7 --- /dev/null +++ b/tests/harness/corpus-costs/8241-literal-result-slices.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2900 }