Compile .length of computed tuple expressions (#2482)

This commit is contained in:
Chris Tate
2026-10-28 17:51:45 -05:00
committed by GitHub
6 changed files with 182 additions and 33 deletions
@@ -3234,6 +3234,22 @@ export function lowerGroupsProjection(
/** Field reads use the shared FieldTarget union. Native method reads select
* an interned unbound callable; invocation supplies its receiver separately. */
/** Whether a receiver is a binding or `this`, or a chain of property reads
* from one whose every member is a plain property (no accessor), so that
* reading it has no effect. */
function plainReadChain(lowerer: Lowerer, node: ts.Expression): boolean {
let current = node;
while (ts.isPropertyAccessExpression(current)) {
if (current.questionDotToken) return false;
const member = lowerer.checker.getSymbolAtLocation(current.name);
if (member === undefined || (member.flags & ts.SymbolFlags.Property) === 0) return false;
if ((member.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0)
return false;
current = current.expression;
}
return ts.isIdentifier(current) || current.kind === ts.SyntaxKind.ThisKeyword;
}
export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null {
// A checked receiver already owns its property table. An ambient
// generic method signature must not divert its value into native
@@ -3388,22 +3404,71 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi
}
}
// `t.length` on a tuple: the arity CONSTANT (tuples are fixed-shape —
// the checker types it as the literal arity too). Folding discards the
// receiver's evaluation, so only side-effect-free receivers fold;
// anything else (a call result) binds to a const first.
// the checker types it as the literal arity too). A binding or `this`,
// or a chain of plain property reads from one, folds; any other receiver
// (a call result, a ternary, an assertion, a chain through an accessor)
// is evaluated first, for its side effects and exceptions, and its value
// discarded.
if (receiverIr?.kind === "record" && expr.name.text === "length") {
const shape = lowerer.shapes.get(receiverIr.shapeId);
if (shape?.tuple) {
let root: ts.Expression = expr.expression;
while (ts.isPropertyAccessExpression(root)) root = root.expression;
if (!ts.isIdentifier(root) && root.kind !== ts.SyntaxKind.ThisKeyword) {
lowerer.unsupported(
"SC1090",
expr,
"'.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first)",
);
const arity: IrExpr = {
kind: "numLit",
value: shape.fields.length,
type: F64,
loc: locOf(expr),
};
if (plainReadChain(lowerer, expr.expression)) return arity;
const receiver = checkerArray ?? lowerer.lowerExpr(expr.expression);
const loc = locOf(expr);
// An unchecked element read (`rows[i].length` with `rows: T[]`) answers
// the element's optional value: a missing element is undefined, and
// reading `length` from it throws Node's TypeError.
if (receiver.type.kind === "union") {
const unionId = receiver.type.unionId;
const held = lowerer.declareHiddenLocal("%tupleLengthReceiver", receiver.type);
let result: IrExpr = arity;
for (const unit of ["undefined", "null"] as const) {
const tag = lowerer.armTag(unionId, unit === "undefined" ? UNDEFINED_T : NULL_T);
if (tag < 0) continue;
result = {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId,
tag,
negated: false,
value: varRef(held.id, receiver.type, loc),
type: BOOL,
loc,
},
then: nodeThrowExpr(
1,
"",
`Cannot read properties of ${unit} (reading 'length')`,
F64,
loc,
),
else_: result,
type: F64,
loc,
};
}
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: held.id, init: receiver, loc }],
result,
type: F64,
loc,
};
}
return { kind: "numLit", value: shape.fields.length, type: F64, loc: locOf(expr) };
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: receiver, loc: locOf(expr.expression) }],
result: arity,
type: F64,
loc,
};
}
}
return null;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/tuple-length-computed.ts"
],
"diags": []
}
+90
View File
@@ -0,0 +1,90 @@
// `.length` of computed tuple expressions: call results, ternaries, `as`
// assertions, `as const` literals, member reads (fields and accessors) and
// element reads. The receiver is evaluated once, in order, for its side
// effects and exceptions, and the length is the tuple's arity.
const log: string[] = [];
function pair(tag: string): [number, string] {
log.push(`pair ${tag}`);
return [1, tag];
}
function triple(): [number, string, boolean] {
log.push("triple");
return [1, "x", true];
}
function failing(): [number, number] {
log.push("failing");
throw new Error("no tuple");
}
class Holder {
plain: [string, string] = ["a", "b"];
reads = 0;
get viaAccessor(): [number, number, number] {
this.reads++;
log.push("accessor");
return [this.reads, 2, 3];
}
ownLength(): number {
return this.plain.length + this.viaAccessor.length;
}
}
const nested: [[number, number], [string, string, string]] = [
[1, 2],
["a", "b", "c"],
];
let flip = 0;
function pick(): boolean {
flip++;
return flip % 2 === 1;
}
console.log(pair("call").length);
console.log((pick() ? triple() : ([0, "", false] as [number, string, boolean])).length);
console.log((pick() ? triple() : ([0, "", false] as [number, string, boolean])).length);
console.log(([1, 2, 3] as const).length);
console.log(([flip, "y"] as [number, string]).length);
const h = new Holder();
console.log(h.plain.length, h.viaAccessor.length, h.viaAccessor.length, h.reads);
console.log(h.ownLength(), h.reads);
console.log(nested[0].length, nested[1].length, nested.length);
// Evaluation order inside one expression, and in a loop condition.
const sum = pair("left").length + triple().length * 10 + pair("right").length * 100;
console.log(sum);
let rounds = 0;
for (let i = 0; i < pair(`loop ${i}`).length; i++) rounds++;
console.log(rounds);
console.log(`${pair("template").length}:${typeof triple().length}`);
// An exception in the receiver propagates; no length is produced.
try {
console.log(failing().length);
} catch (e) {
console.log("caught", (e as Error).message);
}
// An unchecked element read past the end is undefined, so its `length`
// throws, also through a field holding the array.
const rows: [number, string][] = [[1, "a"]];
let missing = 5;
try {
console.log(rows[missing].length);
} catch (e) {
console.log("missing", String(e));
}
console.log("present", rows[0].length);
const table: { rows: [number, number][] } = { rows: [] };
try {
console.log(table.rows[missing - 3].length);
} catch (e) {
console.log("missing field", String(e));
}
console.log(log.join(" | "));
-6
View File
@@ -24,12 +24,6 @@ for (const part of pair) {
// An array's runtime length cannot determine a fixed tuple's positions.
const copy = [...(["a", 1] as (string | number)[])] as [string, number];
// The arity constant folds only off side-effect-free receivers.
function mk(): [string, number] {
return ["z", 9];
}
console.log(mk().length);
// join on ref-element arrays: JS would recursively toString — dishonest.
const recs: { id: number }[] = [{ id: 1 }];
console.log(recs.join(","));
+8 -15
View File
@@ -26,23 +26,16 @@ tuples.ts:25:15 - error SC1090: spreading a variable-length value into a fixed t
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26 |
tuples.ts:31:13 - error SC1090: '.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first) is not supported yet
tuples.ts:29:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet
30 | }
31 | console.log(mk().length);
| ^~~~~~~~~~~
32 |
tuples.ts:35:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet
34 | const recs: { id: number }[] = [{ id: 1 }];
35 | console.log(recs.join(","));
28 | const recs: { id: number }[] = [{ id: 1 }];
29 | console.log(recs.join(","));
| ^~~~~~~~~~~~~~
36 | const tuples: [string, string][] = [["k", "v"]];
30 | const tuples: [string, string][] = [["k", "v"]];
tuples.ts:37:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet
tuples.ts:31:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet
36 | const tuples: [string, string][] = [["k", "v"]];
37 | console.log(tuples.join(";"));
30 | const tuples: [string, string][] = [["k", "v"]];
31 | console.log(tuples.join(";"));
| ^~~~~~~~~~~~~~~~
38 | // Map elements compile through the same REF storage as function elements.
32 | // Map elements compile through the same REF storage as function elements.
@@ -0,0 +1 @@
{ "plain": 2500, "san": 3000 }