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);