diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index afc14196e..687ce9739 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -10632,6 +10632,72 @@ function isUntypedIterable(lowerer: Lowerer, node: ts.Expression): boolean { return lowerer.mapTypeOf(type)?.kind === "dyn"; } +/** `for (const x of map.get(k) ?? [])`: the receiver's checker type is one + * non-array iterable beside `never[]` — the array literal's type, whose only + * value is the empty array (`never` is uninhabited), so that arm yields + * nothing at all. The loop walks the iterable arm under its tag test and the + * empty-array arm runs zero iterations. An ARRAY iterable never needs this: + * `T[] | never[]` reduces in the checker to `T[]`, never being assignable to + * T. A union of two inhabited iterables has no single element type and keeps + * its existing fence. */ +function lowerForOfEmptyArrayArm( + lowerer: Lowerer, + stmt: ts.ForOfStatement, + iterable: IrExpr, + iterSrc: ts.Expression, + labels?: string[], +): IrStmt | null { + if (iterable.type.kind !== "union" || stmt.awaitModifier !== undefined) return null; + const { checker } = lowerer; + const widened = lowerer.typeOf(iterSrc); + if (!widened.isUnionType()) return null; + const parts = ts.constituentTypes(widened); + if (parts.length !== 2) return null; + const neverArrays = parts.filter((part) => { + if (!checker.isArrayType(part)) return false; + const elem = checker.getTypeArguments(part as ts.TypeReference)[0]; + return elem !== undefined && (elem.flags & ts.TypeFlags.Never) !== 0; + }); + if (neverArrays.length !== 1) return null; + const other = parts.find((part) => part !== neverArrays[0]); + if (other === undefined) return null; + const armT = lowerer.mapTypeOf(other); + if (armT === null) return null; + if (armT.kind !== "set" && armT.kind !== "map" && armT.kind !== "string") return null; + const tag = lowerer.armTag(iterable.type.unionId, armT); + if (tag < 0) return null; + const loc = locOf(stmt); + const narrowed: IrExpr = { + kind: "unionNarrow", + unionId: iterable.type.unionId, + tag, + value: iterable, + type: armT, + loc, + }; + const loop: IrStmt = + armT.kind === "set" + ? lowerForOfSet(lowerer, stmt, narrowed, armT) + : armT.kind === "map" + ? lowerForOfMap(lowerer, stmt, narrowed, armT) + : lowerForOfString(lowerer, stmt, narrowed, labels); + return { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: iterable.type.unionId, + tag, + negated: false, + value: iterable, + type: BOOL, + loc, + }, + then: [loop], + else_: null, + loc, + }; +} + export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // Labels consume HERE (nested statements must never see them). The // plain array/string paths carry them; the container/matchAll/stdin @@ -10915,6 +10981,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { const iterableUnion = iterableUnionOf(lowerer, iterable.type); if (iterableUnion) return lowerForOfIterable(lowerer, stmt, iterable, iterableUnion, labels); } + { + const emptyArrayArm = lowerForOfEmptyArrayArm(lowerer, stmt, iterable, iterSrc, labels); + if (emptyArrayArm) return emptyArrayArm; + } let tupleSource: { shapeId: string; fields: { name: string; type: IrType }[]; diff --git a/packages/compiler/test/iterable-union-forof.test.ts b/packages/compiler/test/iterable-union-forof.test.ts new file mode 100644 index 000000000..9d6651864 --- /dev/null +++ b/packages/compiler/test/iterable-union-forof.test.ts @@ -0,0 +1,84 @@ +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, validateModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +/** Lower one program to IR; a fence answers its diagnostic codes instead. */ +async function lower( + source: string, +): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-iterable-union-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) }; + return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Every IR node kind present in the module, as a set. */ +function nodeKinds(module: IrModule): Set { + const kinds = new Set(); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown }; + if (typeof node.kind === "string") kinds.add(node.kind); + for (const child of Object.values(value)) visit(child); + }; + visit(module.functions); + return kinds; +} + +test("a for-of over an iterable beside never[] walks the iterable on its tag", async () => { + const result = await lower(` +function setTotal(registry: Map>, key: string): number { + let total = 0; + for (const name of registry.get(key) ?? []) total += name.length; + return total; +} +function mapTotal(registry: Map>, key: string): number { + let total = 0; + for (const [name, value] of registry.get(key) ?? []) total += name.length + value; + return total; +} +function stringTotal(registry: Map, key: string): number { + let total = 0; + for (const ch of registry.get(key) ?? []) total += ch.length; + return total; +} +console.log(setTotal(new Map(), "k"), mapTotal(new Map(), "k"), stringTotal(new Map(), "k")); +export {}; +`); + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); + const kinds = nodeKinds(result.module); + // The loop is guarded by the arm's tag; the empty-array arm yields nothing. + expect(kinds.has("unionIsTag")).toBe(true); + expect(kinds.has("unionNarrow")).toBe(true); +}); + +test("a union of two inhabited iterables still has no common iteration", async () => { + const result = await lower(` +function total(registry: Map | string[]>, key: string): number { + let sum = 0; + for (const item of registry.get(key) ?? []) sum += item.length; + return sum; +} +console.log(total(new Map(), "k")); +export {}; +`); + // Both arms can hold values, and the two walks yield at different element + // kinds through different protocols: the union keeps its fence. + expect(result.ok).toBe(false); + if (!result.ok) expect(result.codes).toEqual(["SC2001"]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json new file mode 100644 index 000000000..c4335540f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8419-iterable-never-array-arm.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8419-iterable-never-array-arm.ts b/tests/corpus/8419-iterable-never-array-arm.ts new file mode 100644 index 000000000..0955d9f71 --- /dev/null +++ b/tests/corpus/8419-iterable-never-array-arm.ts @@ -0,0 +1,43 @@ +// `for (const x of registry.get(key) ?? [])`: the receiver's type is the +// iterable beside `never[]` — the array literal's type, whose only value is +// the empty array, so that arm runs zero iterations. +function setLength(registry: Map>, key: string): number { + let total = 0; + for (const name of registry.get(key) ?? []) total += name.length; + return total; +} + +function mapTotal(registry: Map>, key: string): number { + let total = 0; + for (const [name, value] of registry.get(key) ?? []) total += name.length + value; + return total; +} + +function stringLength(registry: Map, key: string): number { + let total = 0; + for (const ch of registry.get(key) ?? []) total += ch.length; + return total; +} + +function main(): void { + const sets = new Map>(); + sets.set("a", new Set(["xy", "z"])); + console.log("set", setLength(sets, "a"), setLength(sets, "missing")); + sets.get("a")?.add("q"); + console.log("set live", setLength(sets, "a")); + + const maps = new Map>(); + maps.set("a", new Map([["xy", 3]])); + console.log("map", mapTotal(maps, "a"), mapTotal(maps, "missing")); + + const strings = new Map(); + strings.set("a", "xyz"); + console.log("string", stringLength(strings, "a"), stringLength(strings, "missing")); + + // The same shape through an explicit union annotation. + const empty: Set | never[] = sets.get("missing") ?? []; + let seen = 0; + for (const item of empty) seen += item.length; + console.log("annotated", seen); +} +main(); diff --git a/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json b/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json new file mode 100644 index 000000000..578be4ce6 --- /dev/null +++ b/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4100 }