diff --git a/packages/compiler/src/frontend/lowering/containers/array-concat.ts b/packages/compiler/src/frontend/lowering/containers/array-concat.ts index a7ce0a106..e3164689e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-concat.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-concat.ts @@ -3,6 +3,7 @@ import { identityPreservingWidening } from "../coercions/identity.js"; import { countedFor, numLit, varRef } from "../../../ir/build.js"; import { BOOL, + DYN, F64, arrayOf, typeEquals, @@ -18,15 +19,18 @@ import type { Lowerer } from "../lowerer.js"; /** Copy into fresh storage after every argument has evaluated. Array * arguments retain their own element ABI; only copied values are widened. - * Missing slots stay holes and present undefined stays present. */ + * Missing slots stay holes and present undefined stays present. A shape + * entry is an array argument's element type, null for an element value, or + * "dyn" for a checked-dynamic argument, which JS spreads when it is an + * array at run time (IsConcatSpreadable) and appends otherwise. */ export function arrayConcatHelper( lowerer: Lowerer, elem: IrType, - shape: (IrType | null)[], + shape: (IrType | null | "dyn")[], node: ts.CallExpression, loc: SrcLoc, ): string { - const key = `concat:${typeKey(elem)}:${shape.map((type) => (type ? `a:${typeKey(type)}` : "e")).join(";")}`; + const key = `concat:${typeKey(elem)}:${shape.map((type) => (type === "dyn" ? "d" : type ? `a:${typeKey(type)}` : "e")).join(";")}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%arr.concat", key); @@ -36,7 +40,7 @@ export function arrayConcatHelper( ...shape.map((type, i) => ({ localId: `x.${i}`, name: `x${i}`, - type: type ? arrayOf(type) : elem, + type: type === "dyn" ? DYN : type ? arrayOf(type) : elem, })), ]; const locals: IrLocal[] = params.map((p) => ({ @@ -146,6 +150,43 @@ export function arrayConcatHelper( }, ]; }; + // A checked-dynamic argument: an array spreads its elements, converted + // to the element type; any other value is appended as one element. + const appendDyn = (source: IrExpr, site: ts.Node): IrStmt[] => [ + { + kind: "if", + cond: { kind: "dynTest", test: "array", value: source, type: BOOL, loc }, + then: [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "concatSpread", + receiver: out, + args: [lowerer.coerceInto(site, source, arrT)], + type: F64, + loc, + }, + loc, + }, + ], + else_: [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: out, + args: [lowerer.coerceInto(site, source, elem)], + type: F64, + loc, + }, + loc, + }, + ], + loc, + }, + ]; const body: IrStmt[] = [ { kind: "varDecl", @@ -155,11 +196,17 @@ export function arrayConcatHelper( }, ...append(varRef("a.0", arrT, loc), elem, node.expression), ...shape.flatMap((type, i) => - append(varRef(`x.${i}`, type ? arrayOf(type) : elem, loc), type, node.arguments.nodes[i]!), + type === "dyn" + ? appendDyn(varRef(`x.${i}`, DYN, loc), node.arguments.nodes[i]!) + : append( + varRef(`x.${i}`, type ? arrayOf(type) : elem, loc), + type, + node.arguments.nodes[i]!, + ), ), { kind: "return", value: out, loc }, ]; - if (shape.some((type) => type && !typeEquals(type, elem))) { + if (shape.some((type) => type && type !== "dyn" && !typeEquals(type, elem))) { for (const id of ["i.0", "offset.0", "count.0"]) locals.push({ id, name: id, type: F64, mutable: true }); } diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 4ef203edd..7f0cd6396 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -924,7 +924,7 @@ export function lowerArrayMethodCall( // an array-of-arrays receiver given a bare inner array, where TS // types it as an element but JS would SPREAD it — is fenced. const receiver = lowerer.lowerExpr(access.expression); - const shape: (IrType | null)[] = []; + const shape: (IrType | null | "dyn")[] = []; const args: IrExpr[] = []; for (const argNode of call.arguments.nodes) { if (ts.isSpreadElement(argNode)) { @@ -958,6 +958,16 @@ export function lowerArrayMethodCall( args.push(arg); continue; } + // A checked-dynamic value: whether JS spreads it is decided at run + // time (an array spreads one level, anything else is one element). + if ( + (argIr === null || argIr.kind === "dyn") && + (probedArg ?? tryLowerExpression(lowerer, argNode))?.type.kind === "dyn" + ) { + shape.push("dyn"); + args.push(lowerer.lowerExpr(argNode)); + continue; + } // An element value — union elements wrap exactly like a push. shape.push(null); args.push(lowerer.lowerExprExpecting(argNode, elem)); diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 00a23caf5..8d6181a6e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -2280,6 +2280,33 @@ export function lowerStrictEquality( negated: boolean, loc: SrcLoc, ): IrExpr { + // JavaScript: a checked-dynamic name the checker's flow analysis narrowed + // (`let c; ... (c = s[i]) === " "`) is compared unchecked. Strict + // equality never throws, and the flow type is the checker's inference + // (an indexed read past the end is undefined whatever it says). + const flowNarrowed = (n: ts.Node, e: IrExpr): IrExpr | null => + ts.isIdentifier(n) && + e.kind === "dynCheck" && + e.value.type.kind === "dyn" && + isJsSourceFile(ts.getSourceFileOfNode(n)) + ? e.value + : null; + const scalar = (e: IrExpr): boolean => + e.type.kind === "string" || e.type.kind === "f64" || e.type.kind === "bool"; + { + const l = flowNarrowed(leftNode, left); + const r = flowNarrowed(rightNode, right); + if ((l !== null && (scalar(right) || r !== null)) || (r !== null && scalar(left))) { + const cmp: IrExpr = { + kind: "dynScalarEq", + left: l ?? lowerer.coerceInto(leftNode, left, DYN), + right: r ?? lowerer.coerceInto(rightNode, right, DYN), + type: BOOL, + loc, + }; + return negated ? { kind: "unary", op: "!", operand: cmp, type: BOOL, loc } : cmp; + } + } const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; const bothStr = left.type.kind === "string" && right.type.kind === "string"; const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 01cab9901..3bedb7acb 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -513,6 +513,27 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx ) ) return expr; + // A `let c;` binding without a type takes its flow types from what + // the code assigns, and the checker's types for those values can omit + // undefined (`c = s[i]` past the end): reads keep the actual value. + if ( + symbol && + symbol.declarations.some( + (decl) => + ts.isVariableDeclaration(decl) && + decl.initializer === undefined && + decl.type === undefined && + ts.isIdentifier(decl.name) && + decl.parent?.parent !== undefined && + ts.isVariableStatement(decl.parent.parent) && + !/@type\b/.test( + ts + .getSourceFileOfNode(decl) + .text.slice(decl.parent?.parent?.pos ?? decl.pos, ts.tokenStartOfNode(decl)), + ), + ) + ) + return expr; if ( symbol && symbol.declarations.some( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 96d185b44..7feb2d351 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5367,6 +5367,14 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) invalid = true; return "stop"; } + // An index read's checker type omits the undefined a read past the + // end (or of an absent key) answers. + let right = node.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (ts.isElementAccessExpression(right)) { + invalid = true; + return "stop"; + } const mapped = lowerer.mapTypeOf(lowerer.typeOf(node.right)); if (!mapped || mapped.kind === "dyn" || mapped.kind === "void" || isUnitType(mapped)) { invalid = true; diff --git a/tests/fixtures/npm/cases/concat-dynamic-args/main.ts b/tests/fixtures/npm/cases/concat-dynamic-args/main.ts new file mode 100644 index 000000000..35d6e0268 --- /dev/null +++ b/tests/fixtures/npm/cases/concat-dynamic-args/main.ts @@ -0,0 +1,3 @@ +// concat of checked-dynamic values decides spreading at run time. +import { run } from "concat-dynamic"; +console.log(run(JSON.stringify({ text: "desc", list: ["a", "b"] }))); diff --git a/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.d.ts b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.d.ts new file mode 100644 index 000000000..615876884 --- /dev/null +++ b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.d.ts @@ -0,0 +1 @@ +export declare function run(raw: string): string; diff --git a/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.js b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.js new file mode 100644 index 000000000..e2bc8066f --- /dev/null +++ b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/index.js @@ -0,0 +1,12 @@ +'use strict'; +// Array.prototype.concat spreads an argument that is an array at run time and +// appends any other value, whatever the static type says. +function run(raw) { + const parsed = JSON.parse(raw); + let output = ['Usage:', '']; + output = output.concat([parsed.text, '']); + output = output.concat(parsed.list); + output = output.concat(parsed.text); + return output.join('|') + ' ' + output.length; +} +module.exports = { run }; diff --git a/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/package.json b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/package.json new file mode 100644 index 000000000..afd15f22b --- /dev/null +++ b/tests/fixtures/npm/cases/concat-dynamic-args/node_modules/concat-dynamic/package.json @@ -0,0 +1 @@ +{ "name": "concat-dynamic", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/concat-dynamic-args/package.json b/tests/fixtures/npm/cases/concat-dynamic-args/package.json new file mode 100644 index 000000000..55b029ac8 --- /dev/null +++ b/tests/fixtures/npm/cases/concat-dynamic-args/package.json @@ -0,0 +1 @@ +{ "name": "concat-dynamic-args-case", "private": true, "type": "module" } diff --git a/tests/fixtures/npm/cases/evolving-let-index/main.ts b/tests/fixtures/npm/cases/evolving-let-index/main.ts new file mode 100644 index 000000000..f34197343 --- /dev/null +++ b/tests/fixtures/npm/cases/evolving-let-index/main.ts @@ -0,0 +1,4 @@ +// Reads of a `let` binding whose checker type comes from string index reads +// see undefined past the end. +import { countUntilBracket, skipSpaces } from "evolving-let"; +console.log(skipSpaces("ab ", 2), skipSpaces("a b", 1), countUntilBracket("abc"), countUntilBracket("ab]c")); diff --git a/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.d.ts b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.d.ts new file mode 100644 index 000000000..51158faab --- /dev/null +++ b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.d.ts @@ -0,0 +1 @@ +export declare function skipSpaces(str: string, ptr: number): number; export declare function countUntilBracket(str: string): number; diff --git a/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.js b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.js new file mode 100644 index 000000000..37cbcac70 --- /dev/null +++ b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/index.js @@ -0,0 +1,24 @@ +'use strict'; +// `let c;` takes what an index read past the end answers: undefined. +/** + * @param {string} str + * @param {number} ptr + * @returns {number} + */ +function skipSpaces(str, ptr) { + let c; + while ((c = str[ptr]) === ' ' || c === '\t') ptr++; + return ptr; +} +/** + * @param {string} str + * @returns {number} + */ +function countUntilBracket(str) { + let c; + let ptr = 0; + let n = 0; + while ((c = str[ptr++]) !== ']' && c) n++; + return n; +} +module.exports = { skipSpaces, countUntilBracket }; diff --git a/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/package.json b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/package.json new file mode 100644 index 000000000..02f3315c7 --- /dev/null +++ b/tests/fixtures/npm/cases/evolving-let-index/node_modules/evolving-let/package.json @@ -0,0 +1 @@ +{ "name": "evolving-let", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/evolving-let-index/package.json b/tests/fixtures/npm/cases/evolving-let-index/package.json new file mode 100644 index 000000000..ec244a62b --- /dev/null +++ b/tests/fixtures/npm/cases/evolving-let-index/package.json @@ -0,0 +1 @@ +{ "name": "evolving-let-index-case", "private": true, "type": "module" }