From 45431aec320c53e12e9b664e80d8d48ffbb4f547 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:01:48 -0500 Subject: [PATCH] Choose comptime union arms by discriminant and refuse recursive records A comptime record value whose union has a discriminant takes the arm its discriminant field names, so a value that fits two arms with overlapping shapes gets that arm's tag and layout instead of the first arm it fits. comptimeBakeable reports a record type reachable from its own fields as not bakeable instead of recursing without end, so such a comptime result reports SC1090, and the message names unions and that limit. --- .../src/frontend/lowering/lower-comptime.ts | 54 ++++++++++++++----- tests/corpus/1052-comptime-records.ts | 17 ++++++ tests/diagnostics/comptime.ts | 7 +++ tests/harness/__snapshots__/comptime.ts.txt | 9 +++- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 31fa5afe0..bdd020127 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr "SC1090", expr, `baking comptime results of type '${lowerer.fmt(target)}' into the binary ` + - `(only number, string, boolean, arrays, and records lower to literals)`, + `(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`, ); } // 5. The host executes the extracted callback under a finite budget. @@ -112,25 +112,36 @@ function comptimeFailureDetail(error: unknown): string { * expression: number/string/boolean, arrays and record shapes of those, * and unions of those with null or undefined (the mixed rows of a JSON * table: `(string | number)[][]`), nested. Closures, class instances, - * promises, dyn, and void cannot be baked. */ + * promises, dyn, void, and recursive record types (a record reachable + * from its own fields) cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { + return bakeableWithin(lowerer, t, new Set()); +} + +/** comptimeBakeable below the records in `open`, whose fields are being + * checked: reaching one of them again means the type is recursive. */ +function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean { switch (t.kind) { case "f64": case "string": case "bool": return true; case "array": - return lowerer.comptimeBakeable(t.elem); + return bakeableWithin(lowerer, t.elem, open); case "record": { const shape = lowerer.shapes.get(t.shapeId); - return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type)); + if (!shape || open.has(t.shapeId)) return false; + open.add(t.shapeId); + const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open)); + open.delete(t.shapeId); + return bakeable; } case "union": { const arms = lowerer.unions.get(t.unionId)?.arms; return ( arms !== undefined && arms.every( - (arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)), + (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), ) ); } @@ -139,6 +150,30 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { } } +/** The tag of the arm of union `unionId` that `value` bakes into, or -1. + * A record value whose discriminant field holds one of the union's + * discriminant values takes that case's arm, since arms with overlapping + * shapes can both fit it. Any other value takes the first arm it fits, in + * the union's arm order: a JSON value fits exactly one scalar arm, and + * array or record arms are told apart by their contents. */ +function comptimeUnionArm(lowerer: Lowerer, unionId: string, value: unknown): number { + const def = lowerer.unions.get(unionId); + if (!def) return -1; + const discriminant = def.discriminant; + if ( + discriminant !== undefined && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const key = (value as Record)[discriminant.field]; + const named = discriminant.cases.find((c) => c.values.some((v) => v === key)); + if (named !== undefined) + return comptimeFits(lowerer, value, def.arms[named.tag]!) ? named.tag : -1; + } + return def.arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); +} + /** Whether `value` bakes into the bakeable type `t` without a mismatch: * the arm choice of a union literal. Records are exact, as in * comptimeValueToIr; an absent field reads as undefined. */ @@ -168,9 +203,7 @@ function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean { ); } case "union": - return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => - comptimeFits(lowerer, value, arm), - ); + return comptimeUnionArm(lowerer, t.unionId, value) >= 0; default: return false; } @@ -313,11 +346,8 @@ export function comptimeValueToIr( return { kind: "recordLit", fields, type: expected, loc }; } case "union": { - // The first arm the value fits, in the union's arm order: a JSON - // value fits exactly one scalar arm, and array or record arms are - // told apart by their contents. const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked - const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); + const tag = comptimeUnionArm(lowerer, expected.unionId, value); if (tag < 0) fail(describeComptimeValue(value)); const arm = arms[tag]!; const payload: IrExpr = isUnitType(arm) diff --git a/tests/corpus/1052-comptime-records.ts b/tests/corpus/1052-comptime-records.ts index 47cccf7b3..658aaed32 100644 --- a/tests/corpus/1052-comptime-records.ts +++ b/tests/corpus/1052-comptime-records.ts @@ -43,3 +43,20 @@ const meta = comptime(() => { return { kind: "meta", tags, count: tags.length }; }); console.log(meta.kind, meta.tags.join("|"), meta.count); + +// A discriminated union whose arms both fit a "labeled" value without a +// label: the discriminant picks the arm. +type Item = { kind: "plain"; v: number } | { kind: "labeled"; v: number; label?: string }; +const items: Item[] = comptime( + () => + [ + { kind: "plain", v: 1 }, + { kind: "labeled", v: 2 }, + { kind: "labeled", v: 3, label: "x" }, + ] as Item[], +); +for (const item of items) { + if (item.kind === "labeled") console.log("labeled", item.v, item.label); + else console.log("plain", item.v); +} +console.log(JSON.stringify(items)); diff --git a/tests/diagnostics/comptime.ts b/tests/diagnostics/comptime.ts index 9ba882aaf..9c8239464 100644 --- a/tests/diagnostics/comptime.ts +++ b/tests/diagnostics/comptime.ts @@ -20,3 +20,10 @@ const nested = comptime(() => comptime(() => 1)); const eventually = comptime(async () => 1); const lied = comptime(() => 1 as unknown as string); + +// A record type reachable from its own fields has no literal form. +interface Chain { + value: number; + next: Chain | null; +} +const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); diff --git a/tests/harness/__snapshots__/comptime.ts.txt b/tests/harness/__snapshots__/comptime.ts.txt index 2b1411ae5..603c297a8 100644 --- a/tests/harness/__snapshots__/comptime.ts.txt +++ b/tests/harness/__snapshots__/comptime.ts.txt @@ -45,4 +45,11 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string' 21 | 22 | const lied = comptime(() => 1 as unknown as string); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 23 | \ No newline at end of file + 23 | + +comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet + + 28 | } + 29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 30 | \ No newline at end of file