diff --git a/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts b/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts new file mode 100644 index 000000000..535d5b8f7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts @@ -0,0 +1,65 @@ +/* Array spreads whose elements nothing else can reach. */ +import * as ts from "../../ts7/adapter.js"; +import type { IrExpr, IrType, SrcLoc } from "../../../ir/ir.js"; +import type { Lowerer } from "../lowerer.js"; + +/** Whether the elements a spread inserts are reachable only through it: an + * array literal (`...(cond ? [{ ... }] : [])`), or a const initialized by + * one that this spread is the only reference to. Converting such elements + * into the destination's element layout copies them, which nothing can + * observe. */ +export function freshSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isArrayLiteralExpression(e)) + return e.elements.nodes.every( + (x) => !ts.isSpreadElement(x) && (ts.isObjectLiteralExpression(x) || !isReference(x)), + ); + if (ts.isConditionalExpression(e)) + return freshSpreadSource(lowerer, e.whenTrue) && freshSpreadSource(lowerer, e.whenFalse); + if (!ts.isIdentifier(e)) return false; + const symbol = lowerer.resolveValueSymbol(e); + const decl = symbol?.valueDeclaration; + if (!symbol || !decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return false; + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return false; + if (!freshSpreadSource(lowerer, decl.initializer)) return false; + // The binding's only use is this spread. + let scope: ts.Node = list; + while (scope.parent && !ts.isBlock(scope) && !ts.isSourceFile(scope)) scope = scope.parent; + let uses = 0; + const visit = (n: ts.Node): void => { + if (uses > 1) return; + if (ts.isIdentifier(n) && n !== decl.name && n.text === e.text) { + if (lowerer.resolveValueSymbol(n) === symbol) uses++; + } + ts.forEachChild(n, visit); + }; + visit(scope); + return uses === 1; +} + +function isReference(e: ts.Expression): boolean { + return !( + ts.isStringLiteral(e) || + ts.isNumericLiteral(e) || + e.kind === ts.SyntaxKind.TrueKeyword || + e.kind === ts.SyntaxKind.FalseKeyword || + e.kind === ts.SyntaxKind.NullKeyword + ); +} + +/** A fresh array spread converted into the destination's array layout by + * the structural width lift, or null when the layouts do not lift. */ +export function liftedFreshSpread( + lowerer: Lowerer, + node: ts.Expression, + source: IrExpr, + arrType: IrType, + loc: SrcLoc, +): IrExpr | null { + if (source.type.kind !== "array" || arrType.kind !== "array") return null; + if (!freshSpreadSource(lowerer, node)) return null; + const lift = lowerer.widthLiftPlan(source.type, arrType); + return lift ? lowerer.applyWidthLift(lift, source, arrType, loc) : null; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 6072b58b7..f30946328 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1,5 +1,6 @@ import { generatorDrain } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; +import { liftedFreshSpread } from "../coercions/fresh-spread.js"; import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, @@ -141,6 +142,16 @@ export function lowerArraySpreadItems( if (source.type.kind === "record" && lowerer.shapes.get(source.type.shapeId)?.tuple) { source = lowerer.widthCoerce(source, arrType) ?? source; } + if ( + !typeEquals(source.type, arrType) && + !( + source.type.kind === "array" && + identityPreservingWidening(lowerer, source.type.elem, elem) + ) + ) { + // Fresh elements (an array literal) convert into the element layout. + source = liftedFreshSpread(lowerer, node.expression, source, arrType, loc) ?? source; + } if (!typeEquals(source.type, arrType)) { if ( source.type.kind !== "array" || diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 7ef96457c..4a92e0106 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -1,3 +1,4 @@ +import { liftedFreshSpread } from "../coercions/fresh-spread.js"; import { lowerArrayIteratorValue } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { arrayConcatHelper } from "./array-concat.js"; @@ -793,6 +794,10 @@ export function lowerArrayMethodCall( loc, }; } + if (!typeEquals(src.type, receiverIr)) { + // Fresh elements (an array literal) convert into the element layout. + src = liftedFreshSpread(lowerer, spreadArg.expression, src, receiverIr, loc) ?? src; + } if (!typeEquals(src.type, receiverIr)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 55baa8014..1f30228a0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1251,6 +1251,24 @@ export function lowerInExpression( if (key === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.right))) { refuseExecExceptionCode(lowerer, expr); } + // A literal key that names no declared member of an index-signature + // record (`"id" in match.groups`): the same keyed presence probe a + // runtime key takes — declared fields by name, then the overflow keys. + { + const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); + const shape = recvT?.kind === "record" ? lowerer.shapes.get(recvT.shapeId) : undefined; + if ( + shape?.indexValue && + !shape.tuple && + !OBJECT_PROTOTYPE_KEYS.has(key) && + !shape.fields.some( + (f) => f.name === key || f.name === `%get:${key}` || f.name === `%set:${key}`, + ) + ) { + const probed = lowerRuntimeKeyIn(lowerer, expr, loc); + if (probed) return probed; + } + } let recv = lowerer.lowerExpr(expr.right); const classInfo = recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 9866fba8a..dc132d67b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5331,7 +5331,29 @@ export function lowerVarDecl( // `let` may be reassigned a different shape later; genuine `any` // only — every other unmappable keeps its own diagnostic. const checkerAny = (lowerer.typeOf(decl.name).flags & ts.TypeFlags.Any) !== 0; + // An annotation the checker could not resolve (a package type alias that + // reads as `any`) over an empty object literal: the binding is a + // dictionary the code fills by key (`files[name] = entry`), which the + // empty struct the literal would adopt cannot hold. It holds the + // checked-dynamic object instead. + let emptyAnyDictionary = false; + if (checkerAny && decl.type && !isLet && init.type.kind === "record") { + let lit: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(lit)) lit = lit.expression; + const shape = lowerer.shapes.get(init.type.shapeId); + if ( + ts.isObjectLiteralExpression(lit) && + lit.properties.nodes.length === 0 && + shape && + shape.fields.length === 0 && + !shape.indexValue + ) { + type = DYN; + emptyAnyDictionary = true; + } + } if ( + !emptyAnyDictionary && (type === null || (type.kind === "dyn" && checkerAny)) && !isLet && (checkerAny || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json new file mode 100644 index 000000000..c030a0076 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6476-in-literal-key-index-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json new file mode 100644 index 000000000..3e3775faa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6477-fresh-array-spread-insertion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json new file mode 100644 index 000000000..8925564a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6478-any-annotated-dictionary.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6476-in-literal-key-index-records.ts b/tests/corpus/6476-in-literal-key-index-records.ts new file mode 100644 index 000000000..5866c3ed1 --- /dev/null +++ b/tests/corpus/6476-in-literal-key-index-records.ts @@ -0,0 +1,18 @@ +// `"k" in record` with a literal key over an index-signature record (a +// match's named groups, a dictionary) answers the record's own keys. +function source(header: string): string | undefined { + const result = /^x-meta-source: (?dpl_.*)/m.exec(header); + if (result?.groups && "id" in result.groups) { + return result.groups.id; + } + return undefined; +} +console.log(source("x-meta-source: dpl_123\nother: 1"), source("nothing")); +const both = /(?\w+)=(?\w*)/.exec("mode=fast"); +if (both?.groups) { + console.log("key" in both.groups, "value" in both.groups, "other" in both.groups); +} +const dict: Record = { a: 1 }; +dict["late"] = 2; +delete dict["a"]; +console.log("a" in dict, "late" in dict, "missing" in dict, "toString" in dict); diff --git a/tests/corpus/6477-fresh-array-spread-insertion.ts b/tests/corpus/6477-fresh-array-spread-insertion.ts new file mode 100644 index 000000000..55d4561ac --- /dev/null +++ b/tests/corpus/6477-fresh-array-spread-insertion.ts @@ -0,0 +1,13 @@ +// Spreading a fresh array literal whose elements are typed by their own +// literal (an omitted member reads `undefined`) into an array of the +// declared element type converts the elements; nothing else can reach +// them. +type Result = { message: string; file: { path: string } | undefined; noisy: boolean }; +const results: Result[] = [{ message: "first", file: { path: "/a" }, noisy: false }]; +const spam = results.length > 0 ? [{ message: "spam", file: undefined, noisy: true }] : []; +const more: Result[] = [{ message: "m", file: undefined, noisy: false }]; +results.unshift(...spam, ...more); +const tail = results.length > 5 ? [] : [{ message: "tail", file: undefined, noisy: false }]; +results.push(...tail); +console.log(results.map((r) => `${r.message}:${r.file?.path ?? "-"}:${r.noisy}`).join(" ")); +console.log(JSON.stringify(results[1])); diff --git a/tests/corpus/6478-any-annotated-dictionary.ts b/tests/corpus/6478-any-annotated-dictionary.ts new file mode 100644 index 000000000..6a66f0662 --- /dev/null +++ b/tests/corpus/6478-any-annotated-dictionary.ts @@ -0,0 +1,24 @@ +// A binding annotated with a type the checker reads as `any`, initialized +// with an empty object literal, is a dictionary the code fills by key. +class Ref { + digest: string; + constructor(digest: string) { + this.digest = digest; + } +} +class Blob { + data: string; + constructor(data: string) { + this.data = data; + } +} +function collect(entries: { name: string; sha?: string }[]): unknown { + const files: any = {}; + for (const entry of entries) { + files[entry.name] = entry.sha ? new Ref(`sha:${entry.sha}`) : new Blob(""); + } + files["count"] = entries.length; + return files; +} +const out = collect([{ name: "a.txt", sha: "abc" }, { name: "dir" }]) as Record; +console.log(Object.keys(out), out["a.txt"] instanceof Ref, out["dir"] instanceof Blob, out["count"]);