diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7f4b7e48a..0fb9ea4ff 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -547,6 +547,19 @@ export function emitDynamicExpr( B.br(join); return; } + // A declared-only arm without the literal key: the object has no + // such own property, so the read answers undefined. + if (literal !== null && !shape.indexValue) { + const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1; + if (tag < 0 || e.type.kind !== "union") { + throw new InternalCompilerError( + "llvm emitter bug: unionKeyGet missing key without an undefined result arm", + ); + } + B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`); + B.br(join); + return; + } // Index-signature arm (or declared-only shape under a runtime // key): the shared keyed-read chain — a literal key naming no // declared field touches only the overflow map. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..0e20791fd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -2485,6 +2486,7 @@ export function lowerDestructuringDecl( } let init = importMetaBindingSource(lowerer, decl.name, decl.initializer) ?? + nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ?? lowerer.lowerExpr(decl.initializer); const parseArgsDynObject = init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts new file mode 100644 index 000000000..66c1f00e7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts @@ -0,0 +1,136 @@ +import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +function stripParens(node: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; +} + +/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options + * record has a member that cannot be absent. The pattern destructures + * either the record or an empty object, which has no own keys, so the + * source lowers to a union of the record and the empty record: the union + * pattern lowering reads each key from the arm that holds it and answers + * undefined from the empty arm, and a rest element packs the record's + * remaining fields or nothing. The options operand is evaluated once. + * + * Null (the caller keeps its current lowering) for JavaScript files, other + * initializer forms, an operand the checker does not type as one record + * plus nullish arms, and a record whose every field may be absent: the + * existing lowering already builds the empty object as that record with + * every key absent. */ +export function nullishEmptyPatternSource( + lowerer: Lowerer, + pattern: ts.BindingName, + initializer: ts.Expression, +): IrExpr | null { + if (!ts.isObjectBindingPattern(pattern)) return null; + if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null; + const init = stripParens(initializer); + if ( + !ts.isBinaryExpression(init) || + (init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + init.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return null; + const right = stripParens(init.right); + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null; + const loc = locOf(init); + // Decide from the checker's view of the operand before lowering it once. + const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left)); + if (declared?.kind !== "union") return null; + const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? []; + const declaredRecords = declaredArms.filter((arm) => arm.kind === "record"); + if ( + declaredRecords.length !== 1 || + !declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return null; + const declaredShape = lowerer.shapes.get( + (declaredRecords[0] as IrType & { kind: "record" }).shapeId, + ); + if ( + !declaredShape || + declaredShape.tuple || + declaredShape.indexValue || + declaredShape.fields.length === 0 || + declaredShape.fields.every( + (field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent", + ) + ) + return null; + const left = lowerer.lowerExpr(init.left); + // The stored operand can differ from the checker's view. A stored record + // is never nullish (the fallback is dead); any other stored form must be + // the same one record plus nullish arms. + if (left.type.kind === "record") return left; + if (left.type.kind !== "union") { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const sourceId = left.type.unionId; + const arms = lowerer.unions.get(sourceId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const record = records[0] as IrType & { kind: "record" }; + const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) }; + const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) }; + const sourceTag = arms.indexOf(record); + const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type); + const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: sourceId, + tag: sourceTag, + negated: false, + value: ref, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(record), + value: { + kind: "unionNarrow", + unionId: sourceId, + tag: sourceTag, + value: ref, + type: record, + loc, + }, + type: resultType, + loc, + }, + else_: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(empty), + value: { kind: "recordLit", fields: [], type: empty, loc }, + type: resultType, + loc, + }, + type: resultType, + loc, + }, + type: resultType, + loc, + }; +} diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 83e0f9a31..727d350af 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", ( 'in main: assign to catch binding "%error" (frontend must reject)', ]); }); + +test("a record arm without the literal key answers undefined in unionKeyGet", () => { + const uX: IrType = { kind: "union", unionId: "uX" }; + const uS: IrType = { kind: "union", unionId: "uS" }; + const rA: IrType = { kind: "record", shapeId: "rA" }; + const module = (answer: IrType): IrModule => ({ + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { id: "rA", fields: [{ name: "x", type: uX }] }, + { id: "rE", fields: [] }, + ], + unions: [ + { id: "uX", arms: [STRING, UNDEFINED_T] }, + { id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] }, + ], + functions: [ + { + name: "main", + params: [{ localId: "p", name: "p", type: uS }], + locals: [{ id: "p", name: "p", type: uS, mutable: false }], + returnType: answer, + body: [ + { + kind: "return", + value: { + kind: "unionKeyGet", + unionId: "uS", + key: { kind: "strLit", value: "x", type: STRING, loc }, + value: { kind: "varRef", localId: "p", type: uS, loc }, + type: answer, + loc, + }, + loc, + }, + ], + loc, + }, + ], + }); + // The empty arm lacks "x": it has no such own property, so it answers + // the result's undefined arm. + expect(validateModule(module(uX))).toEqual([]); + // Without an undefined arm in the result there is no answer to give. + expect( + validateModule(module(STRING)).some((d) => + d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"), + ), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..4c563d729 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3869,6 +3869,14 @@ function validateFunction( } return; } + // A declared-only arm that lacks a literal key has no such own + // property: it answers undefined, like a unit arm. + if ( + literal !== null && + !shape.indexValue && + resultUnion?.arms.some((a) => a.kind === "undefinedT") + ) + return; if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) { err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..e7da7aa73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-nullish-empty-destructure.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-nullish-empty-destructure.ts b/tests/corpus/record-nullish-empty-destructure.ts new file mode 100644 index 000000000..aff75af76 --- /dev/null +++ b/tests/corpus/record-nullish-empty-destructure.ts @@ -0,0 +1,83 @@ +// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern +// destructures either the options record or an empty object. Each key reads +// undefined from the empty object, a default applies to it, and the rest +// element packs the record's remaining own keys or nothing. The options +// expression is evaluated once. + +interface EditorOptions { + description: string; + paddingX: number; + label?: string; +} + +function editor(options?: EditorOptions): string { + const { description, ...editorOptions } = options ?? {}; + return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`; +} +console.log(editor()); +console.log(editor({ description: "d", paddingX: 2 })); +console.log(editor({ description: "d", paddingX: 2, label: "l" })); + +// Every member optional: the empty object is the same layout with every +// key absent. +interface StreamOptions { + apiKey?: string; + env?: string; + signal?: string; + transformHeaders?: (headers: string) => string; +} + +function stream(options?: StreamOptions): string { + const { transformHeaders, apiKey, ...rest } = options ?? {}; + const headers = transformHeaders ? transformHeaders("h") : "none"; + return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`; +} +console.log(stream()); +console.log(stream({ apiKey: "k", signal: "s" })); +console.log(stream({ transformHeaders: (h) => h + "!", env: "e" })); + +// A nested optional record, renamed keys and a default. +interface Entry { + options?: { deferred: boolean; signal?: string; step: number }; +} + +function submit(entry: Entry): string { + const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {}; + return `${step} ${JSON.stringify(submission)}`; +} +console.log(submit({})); +console.log(submit({ options: { deferred: true, step: 4 } })); +console.log(submit({ options: { deferred: false, signal: "x", step: 0 } })); + +// `||` and a null arm. +interface Retry { + headers: string; + retries: number; +} + +function retry(value: Retry | null): string { + const { headers, retries = 3 } = value || {}; + return `${headers} ${retries} ${typeof retries}`; +} +console.log(retry(null)); +console.log(retry({ headers: "h", retries: 2 })); + +// A generic options parameter. +function generic(options?: TOptions): string { + const { apiKey, ...providerOptions } = options ?? {}; + return `${apiKey} ${JSON.stringify(providerOptions)}`; +} +console.log(generic()); +console.log(generic({ maxTokens: 3, apiKey: "k" })); + +// The options expression runs once. +let calls = 0; +function current(): EditorOptions | undefined { + calls++; + return calls === 1 ? undefined : { description: "second", paddingX: 1 }; +} +const { description: first } = current() ?? {}; +const { description: second, paddingX } = current() ?? {}; +console.log(first, second, paddingX, calls); + +export {}; diff --git a/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..c56636be7 --- /dev/null +++ b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json @@ -0,0 +1 @@ +{ "plain": 6298, "san": 72106 }