From e41858c7dfa521e387f1ddfef57632a117514540 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 08:58:51 -0500 Subject: [PATCH] Compile assignment and update targets wrapped in parentheses or non-null assertions TypeScript accepts parentheses and non-null assertions around the reference an assignment, a `++`/`--` update or a for-in/of head writes (`(x) = 1`, `h.sums[k]! += 10`), and both are erased at run time. The compiler now reads its source files with the AstFile writeTargets option: the writer's `left`, `operand` or `initializer` is the reference itself and the reference's `parent` is the writer, so lowering and every analysis of a binding's or member's writes see one shape. The reference keeps its own source span. Files read without the option, such as type nodes from the checker and the AST conformance tests, keep TypeScript's exact tree. Fixes #1625 Co-authored-by: ejc3 <1694850+ejc3@users.noreply.github.com> --- .../src/frontend/ts7/ast-node.test.ts | 34 ++++++ .../compiler/src/frontend/ts7/ast-node.ts | 106 +++++++++++++++++- packages/compiler/src/frontend/ts7/session.ts | 10 +- .../corpus/wrapped-assignment-targets.ts.json | 6 + tests/corpus/wrapped-assignment-targets.ts | 54 +++++++++ 5 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json create mode 100644 tests/corpus/wrapped-assignment-targets.ts diff --git a/packages/compiler/src/frontend/ts7/ast-node.test.ts b/packages/compiler/src/frontend/ts7/ast-node.test.ts index b9b8f0e7b..60bfaa005 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.test.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.test.ts @@ -101,6 +101,8 @@ const cases: Record = { '/** @typedef {Object} Options\n * @property {string} name\n * @property {number} count\n */\n/** @type {Options} */ const options = { name: "x", count: 2 };', "missing.ts": "const incomplete = ;\nfunction f( {\n", "empty.ts": "", + "targets.ts": + "let x: number | undefined = 1;\nconst a = [1];\n(x) = 2;\nx! = 3;\na[0]! += 1;\n(a[0])!++;\n--(x!);\nfor ((x) of a) {}\nconst y = (x!) + 1;\n", "lists.ts": "const trailing = [1, 2 /* last */, ];\nconst plain = [1, 2];\nf(1, 2,);\nexport abstract class Flags { protected static readonly value = 1; }\n", }; @@ -167,6 +169,38 @@ function ids( return nodes?.map((n) => (n as OracleNode).index); } +test("the writeTargets option answers write targets without their wrappers", () => { + const { bytes, file: plain } = decoded.get("targets.ts")!; + const file = new AstFile(bytes, undefined, { writeTargets: true }); + const statements = (root: AstNode) => + (root.childList("statements") as AstNodeList).nodes.map((statement) => + statement.kind === SyntaxKind.ExpressionStatement ? statement.expression : statement, + ); + const text = (node: AstNode | undefined) => node?.getText(); + const writes = statements(file.root); + const raw = statements(plain.root); + // Assignment left sides, update operands and a for-of head. + const targets = [writes[2]!.left, writes[3]!.left, writes[4]!.left, writes[5]!.operand]; + expect(targets.map(text)).toEqual(["x", "x", "a[0]", "a[0]"]); + expect(text(writes[6]!.operand)).toBe("x"); + expect(text(writes[7]!.initializer)).toBe("x"); + for (const [index, target] of targets.entries()) + expect(target!.parent, `target ${index}`).toBe(writes[index + 2]); + expect(writes[7]!.initializer!.parent).toBe(writes[7]); + // The reference keeps its own source span. + expect(writes[4]!.left!.getStart()).toBe(raw[4]!.left!.getStart()); + expect(writes[4]!.left!.end).toBe(raw[4]!.left!.end - 1); + // A wrapper outside a write target is kept. + const init = (writes[8]!.childNode("declarationList")!.childList("declarations") as AstNodeList) + .nodes[0]!.initializer!; + expect(text(init.left)).toBe("(x!)"); + expect(init.left!.kind).toBe(SyntaxKind.ParenthesizedExpression); + // Without the option, the file is TypeScript's exact tree. + expect(raw[3]!.left!.kind).toBe(SyntaxKind.NonNullExpression); + expect(raw[2]!.left!.kind).toBe(SyntaxKind.ParenthesizedExpression); + expect(raw[5]!.operand!.kind).toBe(SyntaxKind.NonNullExpression); +}); + test("node lists report a trailing comma and modifier lists their flags", () => { const { file } = decoded.get("lists.ts")!; const lists: [SyntaxKind, boolean][] = []; diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index 8eaa87c72..5fef740ae 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -60,6 +60,18 @@ export class AstModifierList extends AstNodeList * list. * Parent access is lazy, so resolving a checker handle near the bottom * of a deep tree never recursively constructs its ancestors. */ +export interface AstFileOptions { + /** Answer the reference an assignment, a `++`/`--` update or a for-in/of + * head writes without the parentheses and non-null assertions around it. + * TypeScript accepts both around a target (`(x) = 1`, `a[i]! += 1`) and + * both are erased at run time. With this option the writer's `left`, + * `operand` or `initializer` is the reference itself and the reference's + * `parent` is the writer, so every reader of a write target sees one + * shape. Positions stay the source's: the reference keeps its own span. + * Files read for other purposes keep TypeScript's exact tree. */ + writeTargets?: boolean; +} + export class AstFile { readonly wire: AstWireFile; readonly root: AstNode; @@ -73,10 +85,16 @@ export class AstFile { private lines: number[] | undefined; private childIndices: Uint32Array | undefined; + /** Whether nodes answer write targets without their wrappers (see + * AstFileOptions.writeTargets). */ + readonly writeTargets: boolean; + constructor( bytes: Uint8Array, private readonly materialized?: () => void, + options: AstFileOptions = {}, ) { + this.writeTargets = options.writeTargets === true; this.wire = new AstWireFile(bytes); this.nodes = new Array(this.wire.nodeCount); this.lists = new Array(this.wire.nodeCount); @@ -192,6 +210,73 @@ export class AstFile { /** Concrete native layout shared by every syntax kind. Kind-specific * properties are views over the wire; they never copy identity-bearing * nodes into structural records. */ +/* Write targets without their wrappers (AstFileOptions.writeTargets). */ + +function isTargetWrapper(node: AstNode): boolean { + return node.kind === AstKind.ParenthesizedExpression || node.kind === AstKind.NonNullExpression; +} + +function unwrapTarget(node: AstNode): AstNode { + let target = node; + while (isTargetWrapper(target)) { + const inner = target.childNode("expression"); + if (inner === undefined) break; + target = inner; + } + return target; +} + +/** Whether `field` of `node` is a write target: an assignment's left side, + * a `++`/`--` operand, or a for-in/of head that declares nothing. */ +function writesTarget(node: AstNode, field: "left" | "operand" | "initializer"): boolean { + switch (node.kind) { + case AstKind.BinaryExpression: { + if (field !== "left") return false; + const operator = node.childNode("operatorToken")?.kind; + return ( + operator !== undefined && + operator >= AstKind.FirstAssignment && + operator <= AstKind.LastAssignment + ); + } + case AstKind.PrefixUnaryExpression: + case AstKind.PostfixUnaryExpression: + return ( + field === "operand" && + (node.operator === AstKind.PlusPlusToken || node.operator === AstKind.MinusMinusToken) + ); + case AstKind.ForInStatement: + case AstKind.ForOfStatement: + return ( + field === "initializer" && + node.childNode("initializer")?.kind !== AstKind.VariableDeclarationList + ); + default: + return false; + } +} + +/** The parent of `node`, whose syntactic parent `wrapper` is a parenthesis + * or a non-null assertion: the writer when the wrappers around `node` are + * the whole target of an assignment, update or for-in/of head, and + * `wrapper` otherwise. */ +function writerOf(node: AstNode, wrapper: AstNode): AstNode { + let outer = wrapper; + for (;;) { + const up = outer.parent; + if (up === undefined) return wrapper; + if (isTargetWrapper(up)) { + outer = up; + continue; + } + for (const field of ["left", "operand", "initializer"] as const) { + if (writesTarget(up, field) && up.childNode(field) === outer && unwrapTarget(outer) === node) + return up; + } + return wrapper; + } +} + export class AstNode { readonly kind: SyntaxKind; readonly pos: number; @@ -221,7 +306,11 @@ export class AstNode { get parent(): AstNode | undefined { if (!this.parentResolved) { const index = this.file.wire.semanticParent(this.index); - this.parentCache = index === 0 || index === this.index ? undefined : this.file.node(index); + const parent = index === 0 || index === this.index ? undefined : this.file.node(index); + this.parentCache = + parent !== undefined && this.file.writeTargets && isTargetWrapper(parent) + ? writerOf(this, parent) + : parent; this.parentResolved = true; } return this.parentCache; @@ -612,7 +701,10 @@ export class AstNode { return this.childNode("indexType"); } get initializer(): AstNode | undefined { - return this.childNode("initializer"); + const initializer = this.childNode("initializer"); + return initializer !== undefined && this.file.writeTargets && writesTarget(this, "initializer") + ? unwrapTarget(initializer) + : initializer; } get jsdocPropertyTags(): AstNode | undefined { return this.childNode("jsdocPropertyTags"); @@ -621,7 +713,10 @@ export class AstNode { return this.childNode("label"); } get left(): AstNode | undefined { - return this.childNode("left"); + const left = this.childNode("left"); + return left !== undefined && this.file.writeTargets && writesTarget(this, "left") + ? unwrapTarget(left) + : left; } get literal(): AstNode | undefined { return this.childNode("literal"); @@ -663,7 +758,10 @@ export class AstNode { return this.childNode("openingFragment"); } get operand(): AstNode | undefined { - return this.childNode("operand"); + const operand = this.childNode("operand"); + return operand !== undefined && this.file.writeTargets && writesTarget(this, "operand") + ? unwrapTarget(operand) + : operand; } get operatorToken(): AstNode | undefined { return this.childNode("operatorToken"); diff --git a/packages/compiler/src/frontend/ts7/session.ts b/packages/compiler/src/frontend/ts7/session.ts index c58588f6d..d3f2ae9fe 100644 --- a/packages/compiler/src/frontend/ts7/session.ts +++ b/packages/compiler/src/frontend/ts7/session.ts @@ -298,9 +298,13 @@ export class Ts7SessionProgram { if (source === undefined) { const bytes = this.session.binary("getSourceFile", JSON.stringify(this.query(file))); if (bytes.length !== 0) { - const ast = new AstFile(bytes, () => { - this.session.timing.materialized(); - }); + const ast = new AstFile( + bytes, + () => { + this.session.timing.materialized(); + }, + { writeTargets: true }, + ); this.session.timing.fetched(Math.max(0, ast.wire.nodeCount - 2)); source = this.session.cache.set(path, ast.sourceFile, this.snapshot.id, this.project.id); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json new file mode 100644 index 000000000..6a4a8b936 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/wrapped-assignment-targets.ts" + ], + "diags": [] +} diff --git a/tests/corpus/wrapped-assignment-targets.ts b/tests/corpus/wrapped-assignment-targets.ts new file mode 100644 index 000000000..203a29429 --- /dev/null +++ b/tests/corpus/wrapped-assignment-targets.ts @@ -0,0 +1,54 @@ +// Assignment, update and for-of targets wrapped in parentheses or non-null +// assertions write the reference inside them, as in JavaScript, and the +// writes count for every analysis of the binding or member they write. +const h = { sums: [1, 2, 3] }; +const k = 1; +h.sums[k]! += 10; +console.log(h.sums.join(",")); +let x = 1; +(x) = 2; +console.log(x); +let y: number | undefined = 1; +y! = 5; +console.log(y); +const a = [1, 2]; +(a[0]) += 1; +a[1]! = 7; +a[0]!++; +++(a[1]!); +console.log(a.join(",")); +const o: { n?: number } = { n: 1 }; +o.n! = 4; +(o.n)! *= 2; +console.log(o.n); +let counter = 0; +for ((counter) of [3, 4]) {} +console.log(counter); +const m = new Map([["a", [1]]]); +m.get("a")![0]! += 4; +console.log(m.get("a")!.join(",")); +const r = (a[0]! += 2); +console.log(r, a[0]); +let limit = 3; +function bump(): void { + (limit) = 9; +} +bump(); +console.log(limit); +class Counter { + count: number | undefined = 0; + readonly fixed = 1; + inc(): void { + this.count! += 1; + (this.count)!++; + } +} +const c = new Counter(); +c.inc(); +console.log(c.count, c.fixed); +let flag = false; +const set = (): void => { + flag! = true; +}; +set(); +console.log(flag);