Compile assignment and update targets wrapped in parentheses or non-null assertions (#1665)

This commit is contained in:
Chris Tate
2026-10-21 10:58:44 -05:00
committed by GitHub
5 changed files with 203 additions and 7 deletions
@@ -101,6 +101,8 @@ const cases: Record<string, string> = {
'/** @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][] = [];
+102 -4
View File
@@ -60,6 +60,18 @@ export class AstModifierList<T extends AstNode = AstNode> extends AstNodeList<T>
* 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<AstNode>(this.wire.nodeCount);
this.lists = new Array<AstNode[]>(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");
@@ -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);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/wrapped-assignment-targets.ts"
],
"diags": []
}
@@ -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<string, number[]>([["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);