From a03087cde679f2fa91f2e2f84a3b24aec52e7380 Mon Sep 17 00:00:00 2001 From: Matheus Mol Date: Thu, 20 Aug 2026 02:24:55 -0300 Subject: [PATCH 1/3] API: add getChildren and token getters to Node Adds the remaining child/token getters to the API: getChildren, getChildCount, getChildAt, getFirstToken, getLastToken. --- .../typescript/src/api/node/node.generated.ts | 23 ++ packages/typescript/src/ast/ast.ts | 7 + packages/typescript/src/ast/astnav.ts | 141 +++++++++- .../typescript/src/ast/factory.generated.ts | 27 +- packages/typescript/test/sync/ast.test.ts | 247 ++++++++++++++++++ tools/scripts/tsc/generate-encoder.ts | 23 ++ tools/scripts/tsc/generate-ts-ast.ts | 22 +- 7 files changed, 487 insertions(+), 3 deletions(-) diff --git a/packages/typescript/src/api/node/node.generated.ts b/packages/typescript/src/api/node/node.generated.ts index d9a8092261627..10b3a59a91370 100644 --- a/packages/typescript/src/api/node/node.generated.ts +++ b/packages/typescript/src/api/node/node.generated.ts @@ -1,6 +1,9 @@ // Code generated by tools/scripts/tsc/generate-encoder.ts. DO NOT EDIT. import { + getChildren, + getFirstToken, + getLastToken, getTokenPosOfNode, ModifierFlags, type Node, @@ -319,6 +322,26 @@ export class RemoteNode extends RemoteNodeBase implements Node { return sourceFile.text.substring(this.getStart(sourceFile), this.end); } + getChildCount(sourceFile?: SourceFile): number { + return this.getChildren(sourceFile).length; + } + + getChildAt(index: number, sourceFile?: SourceFile): Node { + return this.getChildren(sourceFile)[index]; + } + + getChildren(sourceFile?: SourceFile): readonly Node[] { + return getChildren(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } + + getFirstToken(sourceFile?: SourceFile): Node | undefined { + return getFirstToken(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } + + getLastToken(sourceFile?: SourceFile): Node | undefined { + return getLastToken(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } + protected getString(index: number): string { const offsetStringTableOffsets = this.sourceFile._offsetStringTableOffsets; const start = this.view.getUint32(offsetStringTableOffsets + index * 4, true); diff --git a/packages/typescript/src/ast/ast.ts b/packages/typescript/src/ast/ast.ts index 804f0c0c6a33d..9037a3db37746 100644 --- a/packages/typescript/src/ast/ast.ts +++ b/packages/typescript/src/ast/ast.ts @@ -100,6 +100,11 @@ export interface Node extends ReadonlyTextRange { getLeadingTriviaWidth(sourceFile?: SourceFile): number; getFullText(sourceFile?: SourceFile): string; getText(sourceFile?: SourceFile): string; + getChildCount(sourceFile?: SourceFile): number; + getChildAt(index: number, sourceFile?: SourceFile): Node; + getChildren(sourceFile?: SourceFile): readonly Node[]; + getFirstToken(sourceFile?: SourceFile): Node | undefined; + getLastToken(sourceFile?: SourceFile): Node | undefined; } export interface FileReference extends TextRange { @@ -159,6 +164,8 @@ export interface SourceFile extends Node { getPositionOfLineAndCharacter(line: number, character: number): number; /** @internal */ tokenCache?: Map; + /** @internal */ + childrenCache?: WeakMap; } // ── Token hierarchy ── diff --git a/packages/typescript/src/ast/astnav.ts b/packages/typescript/src/ast/astnav.ts index 96e4edecaed74..00acaf0761b17 100644 --- a/packages/typescript/src/ast/astnav.ts +++ b/packages/typescript/src/ast/astnav.ts @@ -6,7 +6,10 @@ import type { NodeArray, SourceFile, } from "./ast.ts"; -import { createToken } from "./factory.generated.ts"; +import { + createSyntaxList, + createToken, +} from "./factory.generated.ts"; import { isJSDocNodeKind, isKeywordKind, @@ -616,6 +619,142 @@ function getOrCreateToken(sourceFile: SourceFile, kind: SyntaxKind, pos: number, return token; } +const emptyArray: readonly Node[] = []; + +function assertHasRealPosition(node: Node): void { + if (node.pos < 0 || node.end < 0) { + throw new Error("Node without a real position cannot be scanned and thus has no token nodes - use forEachChild and collect the result if that's fine"); + } +} + +export function getChildren(node: Node, sourceFile: SourceFile = node.getSourceFile()): readonly Node[] { + // A SyntaxList already holds its (pre-materialized) children. + if (node.kind === SyntaxKind.SyntaxList) { + return (node as unknown as { children: readonly Node[]; }).children; + } + + if (isTokenKind(node.kind)) { + // EndOfFile may carry leading JSDoc; every other token has no children. + return node.kind === SyntaxKind.EndOfFile ? node.jsDoc ?? emptyArray : emptyArray; + } + + assertHasRealPosition(node); + const cache = (sourceFile.childrenCache ??= new WeakMap()); + const cached = cache.get(node); + + if (cached !== undefined) { + return cached; + } + + const children = createChildren(node, sourceFile); + cache.set(node, children); + return children; +} + +function createChildren(node: Node, sourceFile: SourceFile): readonly Node[] { + const children: Node[] = []; + + // Inside a JSDoc comment there are no real tokens to synthesize. + if (shouldSkipChild(node)) { + node.forEachChild(child => void children.push(child)); + return children; + } + + let pos = node.pos; + const consumed = new Set(); + const processNode = (child: Node): undefined => { + if (consumed.has(child)) { + return; + } + addSyntheticNodes(children, pos, child.pos, node, sourceFile); + children.push(child); + pos = child.end; + }; + const processNodes = (nodes: NodeArray): undefined => { + addSyntheticNodes(children, pos, nodes.pos, node, sourceFile); + children.push(createSyntaxListNode(nodes, node, sourceFile)); + pos = nodes.end; + for (const element of nodes) { + consumed.add(element); + } + }; + + // JSDoc attached to the node is leading content, processed first. + if (node.jsDoc) { + for (const jsDoc of node.jsDoc) { + processNode(jsDoc); + } + } + pos = node.pos; + node.forEachChild(processNode, processNodes); + addSyntheticNodes(children, pos, node.end, node, sourceFile); + return children; +} + +function addSyntheticNodes(children: Node[], pos: number, end: number, parent: Node, sourceFile: SourceFile): void { + if (pos >= end) { + return; + } + const scanner = getScannerForSourceFile(sourceFile, pos); + while (pos < end) { + const token = scanner.getToken(); + const tokenEnd = scanner.getTokenEnd(); + if (tokenEnd <= end) { + // An identifier should never appear as trivia between AST children; skip defensively. + if (token !== SyntaxKind.Identifier) { + children.push(getOrCreateToken(sourceFile, token, pos, tokenEnd, parent, scanner.getTokenFlags())); + } + } + pos = tokenEnd; + if (token === SyntaxKind.EndOfFile) { + break; + } + scanner.scan(); + } +} + +function createSyntaxListNode(nodes: NodeArray, parent: Node, sourceFile: SourceFile): Node { + const listChildren: Node[] = []; + let pos = nodes.pos; + for (const child of nodes) { + addSyntheticNodes(listChildren, pos, child.pos, parent, sourceFile); + listChildren.push(child); + pos = child.end; + } + addSyntheticNodes(listChildren, pos, nodes.end, parent, sourceFile); + const list = createSyntaxList(listChildren) as Mutable; + list.pos = nodes.pos; + list.end = nodes.end; + list.parent = parent; + return list as Node; +} + +export function getFirstToken(node: Node, sourceFile: SourceFile = node.getSourceFile()): Node | undefined { + if (isTokenKind(node.kind)) { + return undefined; + } + assertHasRealPosition(node); + const children = getChildren(node, sourceFile); + const child = children.find(kid => kid.kind < SyntaxKind.FirstJSDocNode || kid.kind > SyntaxKind.LastJSDocNode); + if (child === undefined) { + return undefined; + } + return child.kind < SyntaxKind.FirstNode ? child : getFirstToken(child, sourceFile); +} + +export function getLastToken(node: Node, sourceFile: SourceFile = node.getSourceFile()): Node | undefined { + if (isTokenKind(node.kind)) { + return undefined; + } + assertHasRealPosition(node); + const children = getChildren(node, sourceFile); + const child = children.length ? children[children.length - 1] : undefined; + if (child === undefined) { + return undefined; + } + return child.kind < SyntaxKind.FirstNode ? child : getLastToken(child, sourceFile); +} + /** Binary search a node list for the node containing position. */ function binarySearchNodeList( nodes: NodeArray, diff --git a/packages/typescript/src/ast/factory.generated.ts b/packages/typescript/src/ast/factory.generated.ts index 787aa5910c820..f2a547eb17de6 100644 --- a/packages/typescript/src/ast/factory.generated.ts +++ b/packages/typescript/src/ast/factory.generated.ts @@ -258,7 +258,12 @@ import type { WithStatement, YieldExpression, } from "./ast.ts"; -import { getTokenPosOfNode } from "./astnav.ts"; +import { + getChildren, + getFirstToken, + getLastToken, + getTokenPosOfNode, +} from "./astnav.ts"; import { cloneSourceFileData } from "./utils.ts"; import { forEachChildOfJSDocParameterTag, @@ -724,6 +729,26 @@ export class NodeObject { sourceFile ??= this.getSourceFile(); return sourceFile.text.substring(this.getStart(sourceFile), this.end); } + + getChildCount(sourceFile?: SourceFile): number { + return this.getChildren(sourceFile).length; + } + + getChildAt(index: number, sourceFile?: SourceFile): Node { + return this.getChildren(sourceFile)[index]; + } + + getChildren(sourceFile?: SourceFile): readonly Node[] { + return getChildren(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } + + getFirstToken(sourceFile?: SourceFile): Node | undefined { + return getFirstToken(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } + + getLastToken(sourceFile?: SourceFile): Node | undefined { + return getLastToken(this as unknown as Node, sourceFile ?? this.getSourceFile()); + } } function isNodeArray(array: readonly T[]): array is NodeArray { diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index 7754f97972a2c..986068a7ef42a 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -1008,3 +1008,250 @@ describe("RemoteNode + position/text getters", () => { } }); }); + +// --------------------------------------------------------------------------- +// RemoteNode: child/token getters +// --------------------------------------------------------------------------- + +describe("RemoteNode + child/token getters", () => { + function withFirstStatement(source: string, fn: (stmt: Node, sf: SourceFile) => void) { + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/children.ts": source }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/children.ts"); + fn(sf.statements[0], sf); + } + finally { + api.close(); + } + } + + function findFirstOfKind(node: Node, kind: SyntaxKind): Node | undefined { + let found: Node | undefined; + const walk = (n: Node): undefined => { + if (found) return; + if (n.kind === kind) { + found = n; + return; + } + n.forEachChild(walk); + }; + walk(node); + return found; + } + + test("getChildren materializes the punctuation/keyword tokens the AST omits", () => { + withFirstStatement("if (x) {}", stmt => { + const texts = stmt.getChildren().map(c => c.getText()); + assert.deepStrictEqual(texts, ["if", "(", "x", ")", "{}"]); + }); + }); + + test("getChildCount and getChildAt agree with getChildren", () => { + withFirstStatement("if (x) {}", stmt => { + const children = stmt.getChildren(); + assert.strictEqual(stmt.getChildCount(), children.length); + for (let i = 0; i < children.length; i++) { + assert.strictEqual(stmt.getChildAt(i), children[i]); + } + }); + }); + + test("getFirstToken and getLastToken descend to the edge tokens", () => { + withFirstStatement("if (x) {}", stmt => { + assert.strictEqual(stmt.getFirstToken()?.getText(), "if"); + assert.strictEqual(stmt.getLastToken()?.getText(), "}"); + }); + }); + + test("a token node has no children and no first/last token", () => { + withFirstStatement("if (x) {}", stmt => { + const ifToken = stmt.getFirstToken()!; + assert.strictEqual(ifToken.getChildCount(), 0); + assert.deepStrictEqual(ifToken.getChildren(), []); + assert.strictEqual(ifToken.getFirstToken(), undefined); + assert.strictEqual(ifToken.getLastToken(), undefined); + }); + }); + + test("NodeArrays are wrapped in a SyntaxList that holds the elements and separators", () => { + withFirstStatement("[1, 2, 3];", stmt => { + const arr = findFirstOfKind(stmt, SyntaxKind.ArrayLiteralExpression)!; + assert.ok(arr, "expected an array literal"); + const list = arr.getChildren().find(c => c.kind === SyntaxKind.SyntaxList); + assert.ok(list, "array literal children should include a SyntaxList"); + assert.deepStrictEqual(list!.getChildren().map(c => c.getText()), ["1", ",", "2", ",", "3"]); + }); + }); + + test("getChildren tiles [pos, end) contiguously, absorbing interior trivia into tokens", () => { + // The interior comment must be absorbed into a token's leading trivia, not dropped. + withFirstStatement("const a = /* c */ 1;", (stmt, sf) => { + const children = stmt.getChildren(); + assert.ok(children.length > 0); + assert.strictEqual(children[0].pos, stmt.pos); + assert.strictEqual(children[children.length - 1].end, stmt.end); + for (let i = 1; i < children.length; i++) { + assert.strictEqual(children[i].pos, children[i - 1].end, "children must be contiguous"); + } + assert.strictEqual(children.map(c => c.getFullText(sf)).join(""), stmt.getFullText(sf)); + }); + }); + + test("a JSDoc comment is exposed as the first child", () => { + // Per tsc, the JSDoc is both its own child node and the leading trivia of the first token. + withFirstStatement("/** doc */\nfunction f() {}", stmt => { + const first = stmt.getChildren()[0]; + assert.strictEqual(first.kind, SyntaxKind.JSDoc); + assert.strictEqual(first.getText().trim(), "/** doc */"); + }); + }); + + test("getChildren throws on a synthesized node without a real position", () => { + const synthesized = createBlock([]); // a non-token node with pos/end === -1 + assert.throws(() => synthesized.getChildren(), /real position/); + }); + + test("the else keyword is materialized as a synthetic token", () => { + withFirstStatement("if (a) {} else {}", stmt => { + const texts = stmt.getChildren().map(c => c.getText()); + assert.ok(texts.includes("else"), `expected an 'else' token, got ${JSON.stringify(texts)}`); + }); + }); + + test("getFirstToken skips leading JSDoc and returns the first real token", () => { + withFirstStatement("/** d */ export function f() {}", stmt => { + const first = stmt.getFirstToken()!; + assert.ok( + first.kind < SyntaxKind.FirstJSDocNode || first.kind > SyntaxKind.LastJSDocNode, + "getFirstToken should skip the JSDoc node", + ); + assert.strictEqual(first.getText(), "export"); + }); + }); + + test("SourceFile children are the statements SyntaxList and the EndOfFile token", () => { + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/eof.ts": "const x = 1;\n" }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/eof.ts"); + const children = sf.getChildren(); + assert.ok(children.some(c => c.kind === SyntaxKind.SyntaxList), "should contain a statements SyntaxList"); + assert.strictEqual(children[children.length - 1].kind, SyntaxKind.EndOfFile); + assert.strictEqual(sf.getLastToken()?.kind, SyntaxKind.EndOfFile); + } + finally { + api.close(); + } + }); + + test("getChildren is cached: repeat calls return the same array", () => { + withFirstStatement("const x = 1;", stmt => { + assert.strictEqual(stmt.getChildren(), stmt.getChildren()); + }); + }); + + const isJsDocKind = (n: Node) => n.kind >= SyntaxKind.FirstJSDocNode && n.kind <= SyntaxKind.LastJSDocNode; + + // Recursively asserts getChildren's invariants at every node: count/at agreement, caching, + // first/last token correctness, and contiguous tiling of [pos, end). + function assertChildInvariants(root: Node, sf: SourceFile): void { + const visit = (node: Node): void => { + const children = node.getChildren(sf); + + assert.strictEqual(node.getChildCount(sf), children.length); + for (let i = 0; i < children.length; i++) { + assert.strictEqual(node.getChildAt(i, sf), children[i]); + } + assert.strictEqual(node.getChildAt(children.length, sf), undefined); + assert.strictEqual(node.getChildren(sf), children, "getChildren should be cached"); + + // JSDoc nodes don't synthesize tokens, so the token/tiling invariants don't apply. + if (children.length > 0 && !isJsDocKind(node)) { + // first/last token can be undefined when an edge child is an empty list (e.g. an + // empty `case` clause) — same as tsc; when defined they must be aligned tokens. + const first = node.getFirstToken(sf); + const last = node.getLastToken(sf); + if (first) { + assert.ok(first.kind < SyntaxKind.FirstNode, `getFirstToken must be a token (kind ${node.kind})`); + assert.strictEqual(first.getStart(sf), node.getStart(sf), `firstToken start mismatch (kind ${node.kind})`); + } + if (last) { + assert.ok(last.kind < SyntaxKind.FirstNode, `getLastToken must be a token (kind ${node.kind})`); + assert.strictEqual(last.end, node.end, `lastToken end mismatch (kind ${node.kind})`); + } + + assert.strictEqual(children[0].pos, node.pos, `first child pos mismatch (kind ${node.kind})`); + assert.strictEqual(children[children.length - 1].end, node.end, `last child end mismatch (kind ${node.kind})`); + for (let i = 1; i < children.length; i++) { + if (isJsDocKind(children[i - 1])) continue; + assert.strictEqual(children[i].pos, children[i - 1].end, `gap/overlap between children (kind ${node.kind})`); + } + } + + for (const child of children) { + if (!isJsDocKind(child)) { + visit(child); + } + } + }; + visit(root); + } + + function checkSource(source: string, opts?: { jsx?: boolean; }): void { + const ext = opts?.jsx ? "tsx" : "ts"; + const tsconfig = opts?.jsx ? `{ "compilerOptions": { "jsx": "react-jsx" } }` : "{}"; + const api = spawnAPI({ "/tsconfig.json": tsconfig, [`/src/c.${ext}`]: source }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", `/src/c.${ext}`); + assertChildInvariants(sf, sf); + } + finally { + api.close(); + } + } + + test("structural invariants hold recursively across a rich tree", () => { + checkSource([ + "/** docs */", + "export function greet(name: string, count = 1): string {", + " const parts: string[] = [];", + " for (let i = 0; i < count; i++) {", + " parts.push(`hi ${name}`);", + " }", + " if (parts.length) {", + ' return parts.join(", ");', + " }", + " else {", + ' return "none";', + " }", + "}", + "", + ].join("\n")); + }); + + // Representative constructs, each exercising a distinct structural path of getChildren + // (token synthesis, SyntaxList wrapping, empty lists, decorator lists, JSDoc, JSX, nesting). + const corpus: Array<{ name: string; source: string; jsx?: boolean; }> = [ + { name: "variable declarations", source: "const a = 1; let b: number = 2; var c, d = 3;" }, + { name: "function with optional, default and rest params", source: "function f(a: number, b?: string, c = 1, ...d: any[]): void {}" }, + { name: "class with members", source: "class C { x = 1; #y = 2; static s = 3; readonly r: string; constructor(public p: number) {} m() {} get g() { return 1; } set v(x) {} static {} }" }, + { name: "class with decorators", source: "@dec class C { @prop x = 1; @meth() m(@param p: number) {} accessor a = 1; }" }, + { name: "interface with signature members", source: "interface I extends A, B { x: number; y?: string; readonly z: boolean; (a: number): void; new (): I; [k: string]: any; m(p: number): void; }" }, + { name: "generics with constraints and defaults", source: "function f(x: T): U { return x as any; }\nclass C {}" }, + { name: "enums", source: "enum E { A, B = 2, C = A | B } const enum CE { X = 'x', Y = 'y' }" }, + { name: "import declarations", source: "import d from 'a';\nimport { x, y as z } from 'b';\nimport * as ns from 'c';\nimport type { T } from 'd';\nimport 'e';\nimport def, { named } from 'f';" }, + { name: "if/else chains", source: "if (a) { x(); } else if (b) { y(); } else { z(); }" }, + { name: "for variants", source: "for (let i = 0; i < n; i++) {} for (const k in o) {} for (const v of a) {} for (;;) { break; }" }, + { name: "switch with an empty case clause", source: "switch (x) { case 1: y(); break; case 2: case 3: z(); default: w(); }" }, + { name: "object literal with all member kinds", source: "const o = { a: 1, b, [c]: 2, ...d, m() {}, get g() { return 1; }, set s(v) {}, async am() {}, *gm() {} };" }, + { name: "tagged and nested template literals", source: "const r = tag`a${b}c${`inner${d}`}e`;" }, + { name: "comments and jsdoc with tags", source: "// line\n/* block */\n/**\n * @param a the a\n * @returns nothing\n */\nfunction f(a: number) {} // trailing" }, + { name: "empty constructs", source: "function f() {} class C {} interface I {} enum E {} { } ; namespace N {}" }, + { name: "JSX element with attributes and children", source: 'const e =
hello {name}
;', jsx: true }, + ]; + + for (const entry of corpus) { + test(`invariants: ${entry.name}`, () => { + checkSource(entry.source, { jsx: entry.jsx }); + }); + } +}); diff --git a/tools/scripts/tsc/generate-encoder.ts b/tools/scripts/tsc/generate-encoder.ts index 6bf04ddbcb2d2..2383321a4ff59 100644 --- a/tools/scripts/tsc/generate-encoder.ts +++ b/tools/scripts/tsc/generate-encoder.ts @@ -1474,6 +1474,9 @@ function generateTSNodeGenerated(): string { function emitNodeGeneratedImports(w: CodeWriter) { w.write(`import {`); + w.write(` getChildren,`); + w.write(` getFirstToken,`); + w.write(` getLastToken,`); w.write(` getTokenPosOfNode,`); w.write(` ModifierFlags,`); w.write(` type Node,`); @@ -1756,6 +1759,26 @@ function emitRemoteNodeClassOpen(w: CodeWriter) { w.write(` return sourceFile.text.substring(this.getStart(sourceFile), this.end);`); w.write(` }`); w.write(``); + w.write(` getChildCount(sourceFile?: SourceFile): number {`); + w.write(` return this.getChildren(sourceFile).length;`); + w.write(` }`); + w.write(``); + w.write(` getChildAt(index: number, sourceFile?: SourceFile): Node {`); + w.write(` return this.getChildren(sourceFile)[index];`); + w.write(` }`); + w.write(``); + w.write(` getChildren(sourceFile?: SourceFile): readonly Node[] {`); + w.write(` return getChildren(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + w.write(` }`); + w.write(``); + w.write(` getFirstToken(sourceFile?: SourceFile): Node | undefined {`); + w.write(` return getFirstToken(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + w.write(` }`); + w.write(``); + w.write(` getLastToken(sourceFile?: SourceFile): Node | undefined {`); + w.write(` return getLastToken(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + w.write(` }`); + w.write(``); w.write(` protected getString(index: number): string {`); w.write(` const offsetStringTableOffsets = this.sourceFile._offsetStringTableOffsets;`); w.write(` const start = this.view.getUint32(offsetStringTableOffsets + index * 4, true);`); diff --git a/tools/scripts/tsc/generate-ts-ast.ts b/tools/scripts/tsc/generate-ts-ast.ts index 753c7286ae094..306b5a72eab9a 100644 --- a/tools/scripts/tsc/generate-ts-ast.ts +++ b/tools/scripts/tsc/generate-ts-ast.ts @@ -662,7 +662,7 @@ function generateFactory(): string { out.push(` ${t},`); } out.push(`} from "./ast.ts";`); - out.push(`import { getTokenPosOfNode } from "./astnav.ts";`); + out.push(`import { getChildren, getFirstToken, getLastToken, getTokenPosOfNode } from "./astnav.ts";`); if (handWrittenCloneHelpers.length > 0) { out.push(`import {`); for (const helperName of [...new Set(handWrittenCloneHelpers.map(h => h.helperName))].sort((a, b) => a.localeCompare(b))) { @@ -759,6 +759,26 @@ function generateFactory(): string { out.push(` sourceFile ??= this.getSourceFile();`); out.push(` return sourceFile.text.substring(this.getStart(sourceFile), this.end);`); out.push(` }`); + out.push(``); + out.push(` getChildCount(sourceFile?: SourceFile): number {`); + out.push(` return this.getChildren(sourceFile).length;`); + out.push(` }`); + out.push(``); + out.push(` getChildAt(index: number, sourceFile?: SourceFile): Node {`); + out.push(` return this.getChildren(sourceFile)[index];`); + out.push(` }`); + out.push(``); + out.push(` getChildren(sourceFile?: SourceFile): readonly Node[] {`); + out.push(` return getChildren(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + out.push(` }`); + out.push(``); + out.push(` getFirstToken(sourceFile?: SourceFile): Node | undefined {`); + out.push(` return getFirstToken(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + out.push(` }`); + out.push(``); + out.push(` getLastToken(sourceFile?: SourceFile): Node | undefined {`); + out.push(` return getLastToken(this as unknown as Node, sourceFile ?? this.getSourceFile());`); + out.push(` }`); out.push(`}`); out.push(``); From 2c717ae0c8c79977393af8c4666af23f01181860 Mon Sep 17 00:00:00 2001 From: Matheus Mol Date: Thu, 20 Aug 2026 02:53:15 -0300 Subject: [PATCH 2/3] Fix exactOptionalPropertyTypes error in corpus test options --- packages/typescript/test/sync/ast.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index 986068a7ef42a..5846710eaa54d 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -1251,7 +1251,7 @@ describe("RemoteNode + child/token getters", () => { for (const entry of corpus) { test(`invariants: ${entry.name}`, () => { - checkSource(entry.source, { jsx: entry.jsx }); + checkSource(entry.source, { jsx: entry.jsx ?? false }); }); } }); From f182435cdc1a77e3042808b82a2908fdaffa32dc Mon Sep 17 00:00:00 2001 From: Matheus Mol Date: Thu, 20 Aug 2026 03:30:51 -0300 Subject: [PATCH 3/3] Cache EndOfFile getChildren to keep identity for remote JSDoc arrays --- packages/typescript/src/ast/astnav.ts | 15 +++++++++++---- packages/typescript/test/sync/ast.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/typescript/src/ast/astnav.ts b/packages/typescript/src/ast/astnav.ts index 00acaf0761b17..95e54b0d0ada0 100644 --- a/packages/typescript/src/ast/astnav.ts +++ b/packages/typescript/src/ast/astnav.ts @@ -634,11 +634,16 @@ export function getChildren(node: Node, sourceFile: SourceFile = node.getSourceF } if (isTokenKind(node.kind)) { - // EndOfFile may carry leading JSDoc; every other token has no children. - return node.kind === SyntaxKind.EndOfFile ? node.jsDoc ?? emptyArray : emptyArray; + // EndOfFile may carry leading JSDoc; every other token has no children. The EndOfFile + // result must go through the cache: remote nodes rebuild .jsDoc on every access. + if (node.kind !== SyntaxKind.EndOfFile) { + return emptyArray; + } + } + else { + assertHasRealPosition(node); } - assertHasRealPosition(node); const cache = (sourceFile.childrenCache ??= new WeakMap()); const cached = cache.get(node); @@ -646,7 +651,9 @@ export function getChildren(node: Node, sourceFile: SourceFile = node.getSourceF return cached; } - const children = createChildren(node, sourceFile); + const children = node.kind === SyntaxKind.EndOfFile + ? node.jsDoc ?? emptyArray + : createChildren(node, sourceFile); cache.set(node, children); return children; } diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index 5846710eaa54d..396392b6c3a04 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -1149,6 +1149,25 @@ describe("RemoteNode + child/token getters", () => { }); }); + test("getChildren on an EndOfFile token carrying JSDoc is cached", () => { + // A trailing orphan JSDoc attaches to the EndOfFile token; remote nodes rebuild + // .jsDoc on every access, so this only holds if the EndOfFile branch is cached too. + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/eof.ts": "const x = 1;\n/** orphan */" }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/eof.ts"); + const eof = sf.getLastToken()!; + assert.strictEqual(eof.kind, SyntaxKind.EndOfFile); + const children = eof.getChildren(sf); + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].kind, SyntaxKind.JSDoc); + assert.strictEqual(eof.getChildren(sf), children, "EndOfFile children should be cached"); + assert.strictEqual(eof.getChildAt(0, sf), children[0]); + } + finally { + api.close(); + } + }); + const isJsDocKind = (n: Node) => n.kind >= SyntaxKind.FirstJSDocNode && n.kind <= SyntaxKind.LastJSDocNode; // Recursively asserts getChildren's invariants at every node: count/at agreement, caching,