Add static fixed-tuple call spreads

This commit is contained in:
Chris Tate
2026-09-18 23:26:38 -05:00
parent a11fd6c881
commit b8469c1d8e
10 changed files with 252 additions and 49 deletions
+1
View File
@@ -13,6 +13,7 @@ These are rejected at compile time with an `SC` code, a code frame, and usually
- Typed async generator functions and methods compile with lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also consumes Node `Readable` streams, Web `ReadableStream` values, and statically represented class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record; abrupt completion runs the iterator's cleanup, including literal `destroyOnReturn` and `preventCancel` options. Async `yield*` delegation, stored Web/Node iterator handles, and other structural async-iterator objects remain fenced. The `node:timers/promises` `setInterval(delay, value)` async iterator compiles, while its AbortSignal options remain fenced.
- Generics monomorphize when the target resolves statically. The remaining edges include generic functions declared inside another function, generic class expressions, generic classes whose base depends on their own type parameters, and generic methods that would require dynamic dispatch.
- Generic function values must be pinned at use to a concrete signature and come from a never-reassigned binding; unpinned or rebound values remain fenced. Immutable aliases of overloaded program functions retain per-call overload resolution and function identity. Immutable aliases of table-backed Node builtins can be called directly, while escaping builtin values such as `array.map(path.basename)` remain fenced; `util.promisify` has compile-time projections for `child_process.execFile` and utf8 `fs.readFile`.
- Spread arguments compile when their arity is static: non-empty fixed tuples flatten into fixed signatures with left-to-right, evaluate-once semantics, while arrays, Sets, and statically represented class iterables spread into typed rest parameters. Runtime-length spreads into fixed signatures remain on the checked-dynamic or island paths in JavaScript and otherwise receive a compile-time diagnostic.
**Types and shapes**
@@ -148,6 +148,12 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
for (const table of [UNSUPPORTED_STMT, UNSUPPORTED_EXPR]) {
for (const entry of Object.values(table)) {
const name = entry.feature ?? UNSUPPORTED[entry.code]!.feature;
// Spread arguments have a positive static surface: fixed tuples
// flatten into fixed signatures, while typed arrays/Sets/class
// iterables already pack into rest parameters. The fallback table
// remains the per-site fence for genuinely runtime-arity static
// calls that reach no supported lane.
if (name === "spread arguments") continue;
add({
id: `syntax.${kebab(name)}`,
kind: "syntax",
@@ -178,6 +184,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
["await-using-declarations", "block- and function-scoped await using declarations", "LIFO asynchronous disposal through [Symbol.asyncDispose], with synchronous fallback"],
["for-using-of", "for (using ... of ...) over arrays", "each array element is disposed at the end of its iteration, including break and continue paths"],
["finally-abrupt-completions", "abrupt completions through finally", "return, throw, break, continue, and labeled jumps run crossed finally blocks; a finally completion replaces the pending one"],
["spread-arguments", "spread arguments", "non-empty fixed tuples flatten into fixed signatures with evaluate-once ordering; arrays, Sets, and statically represented class iterables spread into typed rest parameters"],
] as const) {
add({ id: `syntax.${id}`, kind: "syntax", name, status: "static", note });
}
@@ -34,7 +34,7 @@ import { isSafeToDiscard } from "./expressions/evaluation-safety.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js";
import { HTTP2_CONSTANTS } from "./http2-constants.js";
import { CRYPTO_CIPHERS, CRYPTO_CONSTANTS, CRYPTO_CURVES, CRYPTO_HASHES } from "./crypto-tables.js";
import { generatorMeta, timerStyleCallback } from "./lower-calls.js";
import { generatorMeta, timerStyleCallback, type ParamShape } from "./lower-calls.js";
import { registerHttpClientFnBinding, voidizedCallback } from "./lower-server.js";
import { pairsSnapshotHelper } from "./pairs-snapshot.js";
import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, FSWATCHER_T, PROCSTREAM_T, IrExpr, IrFunction, IrLibFn, IrLocal, IrStmt, IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/ir.js";
@@ -1985,7 +1985,26 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
}
}
const required = fn.params.length - (fn.defaults?.length ?? 0);
if (expr.arguments.length < required || expr.arguments.length > fn.params.length) {
const hasSpread = expr.arguments.some(ts.isSpreadElement);
if (hasSpread && bi.module === "url" && bi.member === "fileURLToPath") {
lowerer.noLowering(
"fileURLToPath with spread arguments",
expr,
"call fileURLToPath(value) directly so the URL-or-string overload remains statically visible",
);
}
if (
hasSpread &&
((bi.module === "fs" && bi.member === "readFileSync") ||
(bi.module === "fs/promises" && bi.member === "readFile"))
) {
lowerer.noLowering(
`${bi.member} with spread arguments`,
expr,
`call ${bi.member}(path, "utf8") directly so the required literal encoding remains statically visible`,
);
}
if (!hasSpread && (expr.arguments.length < required || expr.arguments.length > fn.params.length)) {
lowerer.noLowering(
`${name} with ${expr.arguments.length} argument${expr.arguments.length === 1 ? "" : "s"}`,
expr,
@@ -1994,7 +2013,7 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
: `the supported form takes ${fn.params.length} argument${fn.params.length === 1 ? "" : "s"} (no options objects)`,
);
}
if (bi.module === "url" && bi.member === "fileURLToPath") {
if (!hasSpread && bi.module === "url" && bi.member === "fileURLToPath") {
// Node accepts a URL value or a URL string — one libFn per receiver
// form, picked by the argument's static type. Unions (URL |
// undefined, ...) must narrow first, like everywhere else.
@@ -2012,8 +2031,8 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
"pass a URL value or a URL string (narrow unions first)",
);
}
if ((bi.module === "fs" && bi.member === "readFileSync") ||
(bi.module === "fs/promises" && bi.member === "readFile")) {
if (!hasSpread && ((bi.module === "fs" && bi.member === "readFileSync") ||
(bi.module === "fs/promises" && bi.member === "readFile"))) {
// The runtime reads utf8 unconditionally; any other encoding would
// silently decode wrong, so the ARGUMENT'S TYPE must be the literal
// "utf8" — or Node's "utf-8" alias, the same decoder (the fallback
@@ -2028,10 +2047,28 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
);
}
}
const args = expr.arguments.map((a, i) => lowerer.lowerExprExpecting(a, fn.params[i]));
for (let i = args.length; i < fn.params.length; i++) {
const dflt = fn.defaults![i - required]!;
args.push({ kind: "strLit", value: dflt, type: STRING, loc });
let args: IrExpr[];
if (hasSpread) {
const shapes = fn.params.map((type, i): ParamShape =>
i < required
? { type, mode: "required" }
: {
type,
mode: "omittable",
callDefault: {
kind: "strLit",
value: fn.defaults![i - required]!,
type: STRING,
loc,
},
});
args = lowerer.completeArgs(expr.arguments, shapes, loc, expr);
} else {
args = expr.arguments.map((a, i) => lowerer.lowerExprExpecting(a, fn.params[i]));
for (let i = args.length; i < fn.params.length; i++) {
const dflt = fn.defaults![i - required]!;
args.push({ kind: "strLit", value: dflt, type: STRING, loc });
}
}
return { kind: "libCall", fn: fn.fn, args, type: fn.result, loc };
}
@@ -49,12 +49,15 @@ export type ParamMode = "required" | "omittable" | "rest" | "dynRest" | "islandR
* `type` is the ABI type — what the emitted C parameter carries: the
* checker's `T | undefined` union for `x?: T`, a synthesized `T | undefined`
* union for `x: T = e`, `T[]` for `...xs: T[]`, the plain declared type
* otherwise. `bodyType` is present exactly for DEFAULTED params: the plain T
* the body sees after the prologue applies the default (see declareParams). */
* otherwise. `bodyType` is present exactly for DEFAULTED program params:
* the plain T the body sees after the prologue applies the default (see
* declareParams). `callDefault` is the compile-time default value for a
* table-backed builtin's omittable slot. */
export interface ParamShape {
type: IrType;
mode: ParamMode;
bodyType?: IrType;
callDefault?: IrExpr;
}
export interface FnSig {
@@ -429,6 +432,20 @@ export interface GenericInstance {
* explicitly-passed `undefined` wraps to — both trigger a default, JS-
* exact); a rest param packs the surplus args (possibly zero) into one
* array literal, evaluated in source order at the call site. */
function fixedTupleSpreadInfo(
lowerer: Lowerer,
node: ts.Expression,
): { type: IrType & { kind: "record" }; fields: { name: string; type: IrType }[] } | null {
const type = lowerer.mapTypeOf(lowerer.typeOf(node));
if (type?.kind !== "record") return null;
const shape = lowerer.shapes.get(type.shapeId);
if (!shape?.tuple || shape.fields.length === 0) return null;
return {
type,
fields: [...shape.fields].sort((a, b) => Number(a.name) - Number(b.name)),
};
}
export function completeArgs(lowerer: Lowerer, argNodes: readonly ts.Expression[],
shapes: readonly ParamShape[],
loc: SrcLoc,
@@ -442,8 +459,60 @@ export interface GenericInstance {
type ArgSource = ts.Expression | { ir: IrExpr };
const isIr = (s: ArgSource | undefined): s is { ir: IrExpr } =>
s !== undefined && !("kind" in s);
const sources: readonly ArgSource[] =
leading && leading.length > 0 ? [...leading.map((ir) => ({ ir })), ...argNodes] : argNodes;
const sources: ArgSource[] = leading?.map((ir) => ({ ir })) ?? [];
for (const arg of argNodes) {
if (!ts.isSpreadElement(arg)) {
sources.push(arg);
continue;
}
const tuple = fixedTupleSpreadInfo(lowerer, arg.expression);
if (!tuple) {
sources.push(arg);
continue;
}
// A fixed tuple has a compile-time argument count, so flatten it into
// the callee's ordinary positional ABI. The operand itself still
// evaluates exactly once and at the spread's source-order position:
// the first extracted field carries a seqExpr that initializes one
// hidden tuple local, and every later field reads that same local.
// Tuple fields are owned reads, so the call receives the same +1
// values as separately written arguments while the saved tuple stays
// alive through the call.
const spreadLoc = locOf(arg);
const value = lowerer.coerceInto(
arg.expression,
lowerer.lowerExpr(arg.expression),
tuple.type,
);
const saved = lowerer.declareHiddenLocal("%callSpread", tuple.type);
const savedRef = (): IrExpr => ({
kind: "varRef",
localId: saved.id,
type: tuple.type,
loc: spreadLoc,
});
tuple.fields.forEach((field, i) => {
const read: IrExpr = {
kind: "recordGet",
obj: savedRef(),
shapeId: tuple.type.shapeId,
field: field.name,
type: field.type,
loc: spreadLoc,
};
sources.push({
ir: i === 0
? {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: saved.id, init: value, loc: spreadLoc }],
result: read,
type: field.type,
loc: spreadLoc,
}
: read,
});
});
}
const restAt = shapes.findIndex((s) => s.mode === "rest" || s.mode === "dynRest" || s.mode === "islandRest");
const positional = restAt >= 0 ? shapes.slice(0, restAt) : [...shapes];
const out: IrExpr[] = positional.map((shape, i) => {
@@ -474,6 +543,7 @@ export interface GenericInstance {
// reaching here means a call form we don't model — defensive.
lowerer.unsupported("SC1090", blame, "this call form");
}
if (shape.callDefault) return shape.callDefault;
return lowerer.undefinedArgFor(shape.type, loc, blame);
});
if (restAt >= 0 && shapes[restAt]!.mode === "islandRest") {
@@ -4267,7 +4337,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// list — the value path's boxed thunk delivers JS arity instead.
if (generic && !(jsSpreadArgs && generic.implicitParams)) return lowerer.lowerGenericCall(expr, generic);
const sig = generic ? null : lowerer.fnSigOf(expr.expression);
if (sig && !(jsSpreadArgs && spreadNeedsRuntimeArity(sig.params, expr.arguments))) {
if (sig && !(jsSpreadArgs && spreadNeedsRuntimeArity(lowerer, sig.params, expr.arguments))) {
lowerer.noteEdge(sig.name);
const args = lowerer.completeArgs(expr.arguments, sig.params, loc, expr);
return reconcileOverloadReturn(lowerer, expr, { kind: "call", callee: sig.name, args, type: sig.returnType, loc });
@@ -5060,12 +5130,25 @@ function lowerOptionalStringNumber(
* (dynRest/islandRest, whose packs are built per-argument): the shapes
* the runtime-arity lane (lowerSpreadArgsCall) serves. Typed `rest`
* slots keep completeArgs' same-element spread packing. */
function spreadNeedsRuntimeArity(shapes: readonly ParamShape[], argNodes: readonly ts.Expression[]): boolean {
function spreadNeedsRuntimeArity(
lowerer: Lowerer,
shapes: readonly ParamShape[],
argNodes: readonly ts.Expression[],
): boolean {
const restAt = shapes.findIndex((s) => s.mode === "rest" || s.mode === "dynRest" || s.mode === "islandRest");
return argNodes.some(
(a, i) =>
ts.isSpreadElement(a) && (restAt < 0 || i < restAt || shapes[restAt]!.mode !== "rest"),
);
let position = 0;
for (const arg of argNodes) {
if (ts.isSpreadElement(arg)) {
const tuple = fixedTupleSpreadInfo(lowerer, arg.expression);
if (tuple) {
position += tuple.fields.length;
continue;
}
if (restAt < 0 || position < restAt || shapes[restAt]!.mode !== "rest") return true;
}
position++;
}
return false;
}
/** METHOD calls on dyn receivers (`pkg.name.replace(...)`, `rawName.split`,
+2 -2
View File
@@ -3725,8 +3725,8 @@
"id": "syntax.spread-arguments",
"kind": "syntax",
"name": "spread arguments",
"status": "unsupported",
"code": "SC1090"
"status": "static",
"note": "non-empty fixed tuples flatten into fixed signatures with evaluate-once ordering; arrays, Sets, and statically represented class iterables spread into typed rest parameters"
},
{
"id": "syntax.typeof-expressions",
@@ -6325,6 +6325,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2914-fixed-tuple-call-spread.ts": {
"order": [
"<repo>/tests/corpus/2914-fixed-tuple-call-spread.ts"
],
"diags": []
},
"<repo>/tests/corpus/300-if-else.ts": {
"order": [
"<repo>/tests/corpus/300-if-else.ts"
@@ -0,0 +1,82 @@
// Fixed-length tuple spreads flatten into ordinary static call ABIs. The
// tuple expression evaluates once, mixed/multiple spreads retain source
// order, and the same completion serves defaults, rest packs, overloads,
// methods, constructors, generics, and immutable callable aliases.
import { basename } from "node:path";
function describe(label: string, count: number, suffix = "!"): string {
return `${label}:${count}${suffix}`;
}
const basic: readonly [string, number] = ["basic", 2];
console.log(describe(...basic));
let evaluations = 0;
function makePair(): [string, number] {
evaluations = evaluations + 1;
return ["once", 7];
}
console.log(describe(...makePair()), evaluations);
function mixed(a: string, b: number, c: boolean, d: string, e: number): string {
return `${a}:${b}:${c}:${d}:${e}`;
}
const left: [string, number] = ["left", 1];
const right: [string, number] = ["right", 2];
console.log(mixed(...left, true, ...right));
const order: string[] = [];
function orderedPair(label: string, value: number): [string, number] {
order.push(label);
return [label, value];
}
function orderedMiddle(): boolean {
order.push("middle");
return true;
}
console.log(mixed(...orderedPair("first", 1), orderedMiddle(), ...orderedPair("last", 2)));
console.log(order.join(","));
function collect(head: string, ...values: number[]): string {
return `${head}:${values.join(",")}`;
}
const rest: [number, number, number] = [3, 4, 5];
console.log(collect("rest", ...rest));
function pick(kind: "s", value: string): string;
function pick(kind: "n", value: number): number;
function pick(kind: "s" | "n", value: string | number): string | number {
if (typeof value === "string") return value.toUpperCase();
return value + 1;
}
const choose = pick;
const stringArgs: ["s", string] = ["s", "alias"];
const numberArgs: ["n", number] = ["n", 40];
console.log(choose(...stringArgs), choose(...numberArgs));
function genericPair<T, U>(a: T, b: U): string {
return `${a}:${b}`;
}
const genericArgs: [string, number] = ["generic", 9];
console.log(genericPair(...genericArgs));
const pathArgs: [string, string] = ["/tmp/static-spread.ts", ".ts"];
const base = basename;
console.log(base(...pathArgs));
const pathOnly: [string] = ["/tmp/default-suffix.ts"];
console.log(base(...pathOnly));
class Box {
value: string;
constructor(prefix: string, value: number) {
this.value = `${prefix}:${value}`;
}
add(prefix: string, value: number): string {
return `${this.value}:${prefix}:${value}`;
}
}
const ctorArgs: [string, number] = ["box", 10];
const methodArgs: [string, number] = ["method", 11];
const box = new Box(...ctorArgs);
console.log(box.add(...methodArgs));
+6 -13
View File
@@ -1,15 +1,9 @@
// Spread fences — what stays out and why: spreads into FIXED parameter
// positions (arity must complete at compile time), non-identifier object
// spread sources (a conditional spread has no fixed shape), spreads after
// explicit properties (the overwrite direction the desugar doesn't model),
// and spreading anything but a same-element-type (or element-LIFTABLE —
// string[] into (string | number)[] wraps per element now) array.
function two(a: number, b: number): number {
return a + b;
}
const pair: number[] = [1, 2];
console.log(two(...(pair as [number, number])));
// Spread fences — what stays out and why: non-identifier object spread
// sources (a conditional spread has no fixed shape), spreads after explicit
// properties (the overwrite direction the desugar doesn't model), and
// spreading anything but a same-element-type (or element-LIFTABLE —
// string[] into (string | number)[] wraps per element now) array. Fixed
// tuple call spreads compile and are covered by the differential corpus.
interface Shape {
x: number;
@@ -23,7 +17,6 @@ interface YOnly {
}
const yPart: YOnly = { y: 0 };
const s2: Shape = { x: 5, ...yPart };
// A spread field the target keeps (no later override) must match exactly —
// and the diagnostic shows the SOURCE shape, where the difference lives
// (the literal's own type would already have overrides applied and could
@@ -1,20 +1,13 @@
spread-fences.ts:12:17 - error SC1090: spread arguments into fixed parameter positions (a spread can only fill a rest parameter) are not supported yet
spread-fences.ts:14:21 - error SC1090: conditional spreads beyond `...(c ? { field: v } : {})` (exactly one property against an empty arm — spell other shapes as optional fields) is not supported yet
11 | const pair: number[] = [1, 2];
12 | console.log(two(...(pair as [number, number])));
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13 |
spread-fences.ts:20:21 - error SC1090: conditional spreads beyond `...(c ? { field: v } : {})` (exactly one property against an empty arm — spell other shapes as optional fields) is not supported yet
19 | const cond = true;
20 | const s1: Shape = { ...(cond ? { x: 1, y: 2 } : { x: 3, y: 4 }) };
13 | const cond = true;
14 | const s1: Shape = { ...(cond ? { x: 1, y: 2 } : { x: 3, y: 4 }) };
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
21 | interface YOnly {
15 | interface YOnly {
spread-fences.ts:25:27 - error SC1090: object spread after explicit properties (spreads must come first — a later spread would overwrite them with JS semantics the desugar does not model) are not supported yet
spread-fences.ts:19:27 - error SC1090: object spread after explicit properties (spreads must come first — a later spread would overwrite them with JS semantics the desugar does not model) are not supported yet
24 | const yPart: YOnly = { y: 0 };
25 | const s2: Shape = { x: 5, ...yPart };
18 | const yPart: YOnly = { y: 0 };
19 | const s2: Shape = { x: 5, ...yPart };
| ^~~~~~~~
26 |
20 | // A spread field the target keeps (no later override) must match exactly —
+1
View File
@@ -124,6 +124,7 @@ interface Probe {
const PROBES: Probe[] = [
// status static — these must compile to a binary
{ id: "syntax.compound-assignment.plus", source: 'let x = 1;\nx += 2;\nconsole.log(x);\n' },
{ id: "syntax.spread-arguments", source: 'function add(a: number, b: number): number { return a + b; }\nconst args: [number, number] = [1, 2];\nconsole.log(add(...args));\n' },
{ id: "stdlib.string.charCodeAt", source: 'console.log("abc".charCodeAt(0));\n' },
{ id: "stdlib.array.push", source: "const xs: number[] = [1];\nxs.push(2);\nconsole.log(xs.length);\n" },
{ id: "stdlib.array.unshift", source: "const xs: number[] = [2];\nconsole.log(xs.unshift(1), xs[0]);\n" },