Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions packages/compiler/src/frontend/lowering/lower-exprs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3704,7 +3704,10 @@ function lowerPromiseThenPresence(
expected?: (IrType & { kind: "array" }) | (IrType & { kind: "record" }),): IrExpr {
const loc = locOf(expr);
const ctxType = lowerer.checker.getContextualType(expr);
const tsType = ctxType ?? lowerer.typeOf(expr);
// `as const satisfies readonly T[]` checks against an array but keeps
// the literal's inferred tuple type. Build that shape; an actual array
// destination can use the ordinary tuple-to-array coercion afterward.
const tsType = underConstAssertion(expr) ? lowerer.typeOf(expr) : ctxType ?? lowerer.typeOf(expr);
let mapped = expected ?? lowerer.mapTypeOf(tsType);
// A JS literal whose OWN inferred type is never-tainted
// (neverTaintedJsType — the evolving `const gb = []`, the mixed
Expand Down Expand Up @@ -3827,17 +3830,13 @@ function lowerPromiseThenPresence(
// literal constructs the tuple's record shape — one positional field
// per element, source order (which IS index order, so evaluation order
// is JS-exact). tsc has already checked the arity; the recount below
// backstops `as` smuggling. Spreads have no fixed positions — fenced.
// backstops `as` smuggling. Fixed tuple spreads have known positions.
if (mapped?.kind === "record") {
const shape = lowerer.shapes.get(mapped.shapeId);
if (shape?.tuple) {
const spread = expr.elements.find(ts.isSpreadElement);
if (spread) {
lowerer.unsupported(
"SC1090",
spread,
"spread elements in tuple literals (positions must be spelled out)",
);
return lowerTupleSpreadLiteral(lowerer, expr, mapped, shape);
}
if (expr.elements.length !== shape.fields.length) {
// tsc padded an UNDER-LENGTH literal against an optional-element
Expand Down Expand Up @@ -4109,6 +4108,65 @@ function lowerPromiseThenPresence(
};
}

/** Build a fixed tuple from positional elements and fixed tuple spreads.
* Capture each value in source order: deferring the reads until recordLit
* would observe a later element's mutations of the spread source. */
function lowerTupleSpreadLiteral(
lowerer: Lowerer,
expr: ts.ArrayLiteralExpression,
type: IrType & { kind: "record" },
shape: IrRecordShape,
): IrExpr {
const loc = locOf(expr);
const byName = new Map(shape.fields.map((field) => [field.name, field.type]));
const stmts: IrStmt[] = [];
const fields: { name: string; value: IrExpr }[] = [];
const append = (node: ts.Expression, value: IrExpr): void => {
const name = String(fields.length);
const expected = byName.get(name);
if (!expected) lowerer.badType(expr, lowerer.typeOf(expr));
const coerced = lowerer.coerceInto(node, value, expected);
const temp = lowerer.declareHiddenLocal("%tupleElement", expected);
const at = locOf(node);
stmts.push({ kind: "varDecl", localId: temp.id, init: coerced, loc: at });
fields.push({ name, value: varRef(temp.id, expected, at) });
};
for (const element of expr.elements) {
if (!ts.isSpreadElement(element)) {
const expected = byName.get(String(fields.length));
if (!expected) lowerer.badType(element, lowerer.typeOf(element));
append(element, lowerer.lowerExprExpecting(element, expected));
continue;
}
const source = lowerer.lowerExpr(element.expression);
const sourceShape = source.type.kind === "record" ? lowerer.shapes.get(source.type.shapeId) : undefined;
if (!sourceShape?.tuple) {
// Empty tuples use a zero-length array representation. Preserve a
// producing call's effects even though there are no positions to copy.
const sourceTs = lowerer.typeOf(element.expression);
if (source.type.kind === "array" && lowerer.checker.isTupleType(sourceTs) &&
lowerer.checker.getTypeArguments(sourceTs as ts.TypeReference).length === 0) {
stmts.push({ kind: "exprStmt", expr: source, loc: locOf(element) });
continue;
}
lowerer.unsupported("SC1090", element, "spreading a variable-length value into a fixed tuple literal");
}
const temp = lowerer.declareHiddenLocal("%tupleSpread", source.type);
const at = locOf(element);
stmts.push({ kind: "varDecl", localId: temp.id, init: source, loc: at });
const receiver = varRef(temp.id, source.type, at);
const positions = [...sourceShape.fields].sort((a, b) => Number(a.name) - Number(b.name));
for (const field of positions) {
append(element.expression, {
kind: "recordGet", obj: receiver, shapeId: sourceShape.id,
field: field.name, type: field.type, loc: at,
});
}
}
if (fields.length !== shape.fields.length) lowerer.badType(expr, lowerer.typeOf(expr));
return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc };
}


/** Convert an undefined-armed numeric value at an arithmetic use. Ordinary
* reads preserve their tagged value; JavaScript's ToNumber(undefined) is NaN,
Expand Down
89 changes: 89 additions & 0 deletions packages/compiler/src/frontend/type-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import { F64, STRING, VOID, type IrType } from "../ir/ir.js";
import { formatIrType, ShapeRegistry, UnionRegistry } from "./type-mapper.js";

describe("IR type diagnostics", () => {
test("preserves small types, repeated sibling shapes, and array precedence", () => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
const record: IrType = { kind: "record", shapeId: shapes.intern([{ name: "value", type: F64 }]) };
const union: IrType = { kind: "union", unionId: unions.intern([F64, STRING]) };
const callback: IrType = { kind: "func", params: [record, record], ret: union };
expect(formatIrType({ kind: "array", elem: callback }, shapes, unions))
.toBe("(({ value: number }, { value: number }) => number | string)[]");
expect(formatIrType({ kind: "array", elem: union }, shapes, unions)).toBe("(number | string)[]");
expect(formatIrType({ kind: "map", key: STRING, value: { kind: "set", elem: F64 } }, shapes, unions))
.toBe("Map<string, Set<number>>");
expect(formatIrType({ kind: "generator", async: true, yieldT: record, retT: VOID, nextT: F64 }, shapes, unions))
.toBe("AsyncGenerator<{ value: number }, void, number>");
});

test("breaks recursive record/union paths without hiding later siblings", () => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
const record: IrType = { kind: "record", shapeId: shapes.intern([]) };
const union: IrType = { kind: "union", unionId: unions.intern([record, STRING]) };
shapes.get(record.shapeId)!.fields.push({ name: "next", type: union });
const seen = new Set<string>();
expect(formatIrType({ kind: "func", params: [record, record], ret: VOID }, shapes, unions, seen))
.toBe("({ next: ... | string }, { next: ... | string }) => void");
expect(seen.size).toBe(0);
});

test("keeps numeric tuple order, accessor spelling, and index signatures", () => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
const tuple = shapes.intern([
{ name: "10", type: STRING }, { name: "2", type: F64 }, { name: "0", type: VOID },
], true);
expect(formatIrType({ kind: "record", shapeId: tuple }, shapes, unions)).toBe("[void, number, string]");
const shapeId = shapes.intern([
{ name: "%get:value", type: { kind: "func", params: [], ret: F64 } },
{ name: "%set:value", type: { kind: "func", params: [F64], ret: VOID } },
], false, STRING);
expect(formatIrType({ kind: "record", shapeId }, shapes, unions))
.toBe("{ get value(): number; set value(number); [key: string]: string }");
});

test("bounds expansion of a shared acyclic type graph", () => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
let type: IrType = STRING;
// Only sixteen shapes, but naive expansion repeats the leaf 65,536 times.
for (let i = 0; i < 16; i++) {
type = { kind: "record", shapeId: shapes.intern([{ name: "left", type }, { name: "right", type }]) };
}
const seen = new Set<string>();
const text = formatIrType(type, shapes, unions, seen);
expect(text.length).toBeLessThanOrEqual(4096);
expect(text).toMatch(/^\{ left: \{ left:/);
expect(text.endsWith("...")).toBe(true);
expect(seen.size).toBe(0);
expect(formatIrType(type, shapes, unions, seen)).toBe(text);
});

test("bounds deeply nested wrappers without overflowing the stack", () => {
let type: IrType = STRING;
for (let i = 0; i < 10_000; i++) type = { kind: "promise", inner: type };
const text = formatIrType(type, new ShapeRegistry(), new UnionRegistry());
expect(text.startsWith("Promise<Promise<")).toBe(true);
expect(text).toContain("...");
expect(text.length).toBeLessThanOrEqual(4096);
});

test("stops visiting wide unions and bounds long names", () => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
const id = unions.intern([]);
const arms = unions.get(id)!.arms;
for (let i = 0; i < 2000; i++) arms.push(STRING);
// Formatting must stop before reaching this arm, not slice a completed string.
Object.defineProperty(arms, 1999, { get() { throw new Error("visited after output was full"); } });
const text = formatIrType({ kind: "union", unionId: id }, shapes, unions);
expect(text.length).toBeLessThanOrEqual(4096);
expect(text.endsWith("...")).toBe(true);
const longName = formatIrType({ kind: "object", className: "x".repeat(10_000) }, shapes, unions);
expect(longName.length).toBeLessThanOrEqual(4096);
expect(longName.endsWith("...")).toBe(true);
});
});
Loading
Loading