diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index f3e226078..e566ed292 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -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; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json new file mode 100644 index 000000000..3662dd97d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tuple-length-computed.ts" + ], + "diags": [] +} diff --git a/tests/corpus/tuple-length-computed.ts b/tests/corpus/tuple-length-computed.ts new file mode 100644 index 000000000..6a6eeab6d --- /dev/null +++ b/tests/corpus/tuple-length-computed.ts @@ -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(" | ")); diff --git a/tests/diagnostics/tuples.ts b/tests/diagnostics/tuples.ts index e5cba6aa2..9d8a14ad0 100644 --- a/tests/diagnostics/tuples.ts +++ b/tests/diagnostics/tuples.ts @@ -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(",")); diff --git a/tests/harness/__snapshots__/tuples.ts.txt b/tests/harness/__snapshots__/tuples.ts.txt index 600345989..e61606911 100644 --- a/tests/harness/__snapshots__/tuples.ts.txt +++ b/tests/harness/__snapshots__/tuples.ts.txt @@ -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. \ No newline at end of file + 32 | // Map elements compile through the same REF storage as function elements. \ No newline at end of file diff --git a/tests/harness/corpus-costs/tuple-length-computed.ts.json b/tests/harness/corpus-costs/tuple-length-computed.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/tuple-length-computed.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 }