diff --git a/_packages/native-preview/src/api/node/node.generated.ts b/_packages/native-preview/src/api/node/node.generated.ts index 47a367e4563..ff74677b3fa 100644 --- a/_packages/native-preview/src/api/node/node.generated.ts +++ b/_packages/native-preview/src/api/node/node.generated.ts @@ -1,6 +1,7 @@ // Code generated by _scripts/generate-encoder.ts. DO NOT EDIT. import { + getTokenPosOfNode, ModifierFlags, type Node, type NodeArray, @@ -254,6 +255,39 @@ export class RemoteNode extends RemoteNodeBase implements Node { return this.sourceFile as unknown as SourceFile; } + getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number { + return getTokenPosOfNode(this as unknown as Node, sourceFile ?? this.getSourceFile(), includeJsDocComment); + } + + getFullStart(): number { + return this.pos; + } + + getEnd(): number { + return this.end; + } + + getWidth(sourceFile?: SourceFile): number { + return this.getEnd() - this.getStart(sourceFile); + } + + getFullWidth(): number { + return this.end - this.pos; + } + + getLeadingTriviaWidth(sourceFile?: SourceFile): number { + return this.getStart(sourceFile) - this.pos; + } + + getFullText(sourceFile?: SourceFile): string { + return (sourceFile ?? this.getSourceFile()).text.substring(this.pos, this.end); + } + + getText(sourceFile?: SourceFile): string { + sourceFile ??= this.getSourceFile(); + return sourceFile.text.substring(this.getStart(sourceFile), this.end); + } + protected getString(index: number): string { const offsetStringTableOffsets = this.sourceFile._offsetStringTableOffsets; const start = this.view.getUint32(offsetStringTableOffsets + index * 4, true); diff --git a/_packages/native-preview/src/ast/ast.ts b/_packages/native-preview/src/ast/ast.ts index ae0231ae44b..c12a7953e4e 100644 --- a/_packages/native-preview/src/ast/ast.ts +++ b/_packages/native-preview/src/ast/ast.ts @@ -48,6 +48,14 @@ export interface Node extends ReadonlyTextRange { readonly jsDoc?: readonly Node[]; forEachChild(visitor: (node: Node) => T, visitArray?: (nodes: NodeArray) => T): T | undefined; getSourceFile(): SourceFile; + getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number; + getFullStart(): number; + getEnd(): number; + getWidth(sourceFile?: SourceFile): number; + getFullWidth(): number; + getLeadingTriviaWidth(sourceFile?: SourceFile): number; + getFullText(sourceFile?: SourceFile): string; + getText(sourceFile?: SourceFile): string; } export interface FileReference extends TextRange { diff --git a/_packages/native-preview/src/ast/factory.generated.ts b/_packages/native-preview/src/ast/factory.generated.ts index afb2859f8e2..3181d175715 100644 --- a/_packages/native-preview/src/ast/factory.generated.ts +++ b/_packages/native-preview/src/ast/factory.generated.ts @@ -257,6 +257,7 @@ import type { WithStatement, YieldExpression, } from "./ast.ts"; +import { getTokenPosOfNode } from "./astnav.ts"; import { forEachChildOfJSDocParameterTag, forEachChildOfJSDocPropertyTag, @@ -667,6 +668,39 @@ export class NodeObject { while (node.parent) node = node.parent; return node as unknown as SourceFile; } + + getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number { + return getTokenPosOfNode(this as unknown as Node, sourceFile ?? this.getSourceFile(), includeJsDocComment); + } + + getFullStart(): number { + return this.pos; + } + + getEnd(): number { + return this.end; + } + + getWidth(sourceFile?: SourceFile): number { + return this.getEnd() - this.getStart(sourceFile); + } + + getFullWidth(): number { + return this.end - this.pos; + } + + getLeadingTriviaWidth(sourceFile?: SourceFile): number { + return this.getStart(sourceFile) - this.pos; + } + + getFullText(sourceFile?: SourceFile): string { + return (sourceFile ?? this.getSourceFile()).text.substring(this.pos, this.end); + } + + getText(sourceFile?: SourceFile): string { + sourceFile ??= this.getSourceFile(); + return sourceFile.text.substring(this.getStart(sourceFile), this.end); + } } function isNodeArray(array: readonly T[]): array is NodeArray { diff --git a/_packages/native-preview/test/sync/ast.test.ts b/_packages/native-preview/test/sync/ast.test.ts index 5ce07c3de19..076345e3c44 100644 --- a/_packages/native-preview/test/sync/ast.test.ts +++ b/_packages/native-preview/test/sync/ast.test.ts @@ -3,7 +3,9 @@ import type { Identifier, Node, NodeArray, + SourceFile, StringLiteralLikeNode, + VariableStatement, } from "@typescript/native-preview/unstable/ast"; import { isImportDeclaration, @@ -662,3 +664,187 @@ describe("RemoteNode + getSynthesizedDeepClone", () => { } }); }); + +// --------------------------------------------------------------------------- +// RemoteNode: position and text getters +// --------------------------------------------------------------------------- + +// Relationships that must hold between the position/text getters on any node. +function assertGetterInvariants(node: Node, sf: SourceFile) { + const fullStart = node.getFullStart(); + const start = node.getStart(sf); + const end = node.getEnd(); + + assert.strictEqual(fullStart, node.pos); + assert.strictEqual(end, node.end); + assert.ok(start >= fullStart, `getStart (${start}) must be >= getFullStart (${fullStart})`); + assert.ok(end >= start, `getEnd (${end}) must be >= getStart (${start})`); + + assert.strictEqual(node.getFullWidth(), end - fullStart); + assert.strictEqual(node.getWidth(sf), end - start); + assert.strictEqual(node.getLeadingTriviaWidth(sf), start - fullStart); + + const fullText = node.getFullText(sf); + const text = node.getText(sf); + assert.strictEqual(fullText.length, node.getFullWidth()); + assert.strictEqual(text, fullText.slice(node.getLeadingTriviaWidth(sf))); + + // No-argument variants resolve the source file themselves and must agree. + assert.strictEqual(node.getStart(), start); + assert.strictEqual(node.getFullText(), fullText); + assert.strictEqual(node.getText(), text); + + node.forEachChild(child => { + assertGetterInvariants(child, sf); + return undefined; + }); +} + +describe("RemoteNode + position/text getters", () => { + const source = "/* lead */ const value = 123;"; + const files = { + "/tsconfig.json": "{}", + "/src/getters.ts": source, + }; + + test("position and text getters on a parsed statement", () => { + const api = spawnAPI(files); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/getters.ts"); + const stmt = sf.statements[0]; + assert.ok(stmt); + + const tokenStart = source.indexOf("const"); + + // Full start includes leading trivia; start skips it. + assert.strictEqual(stmt.getFullStart(), 0); + assert.strictEqual(stmt.getStart(), tokenStart); + assert.strictEqual(stmt.getStart(sf), tokenStart); + assert.strictEqual(stmt.getLeadingTriviaWidth(), tokenStart); + + // End and widths. + assert.strictEqual(stmt.getEnd(), source.length); + assert.strictEqual(stmt.getFullWidth(), source.length); + assert.strictEqual(stmt.getWidth(), source.length - tokenStart); + + // Text slices, with and without leading trivia. + assert.strictEqual(stmt.getFullText(), source); + assert.strictEqual(stmt.getText(), source.slice(tokenStart)); + } + finally { + api.close(); + } + }); + + test("getText/getFullText on a nested node", () => { + const api = spawnAPI(files); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/getters.ts"); + const stmt = sf.statements[0] as VariableStatement; + const name = stmt.declarationList.declarations[0].name; + + assert.strictEqual(name.getText(), "value"); + // getFullText keeps the leading whitespace trivia before the identifier. + assert.strictEqual(name.getFullText().trimStart(), "value"); + assert.ok(name.getFullText().endsWith("value")); + } + finally { + api.close(); + } + }); + + test("getStart can include leading JSDoc comments", () => { + const docSource = "/** doc */\nfunction f() {}\n"; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/doc.ts": docSource, + }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/doc.ts"); + const fn = sf.statements[0]; + assert.ok(fn); + + // By default the JSDoc comment is treated as leading trivia and skipped. + assert.strictEqual(fn.getStart(), docSource.indexOf("function")); + + // With includeJsDocComment, the start moves back to the JSDoc comment. + assert.ok(fn.jsDoc && fn.jsDoc.length > 0, "function declaration should have attached JSDoc"); + assert.strictEqual(fn.getStart(sf, /*includeJsDocComment*/ true), 0); + } + finally { + api.close(); + } + }); + + test("a node without leading trivia has zero leading trivia width", () => { + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/plain.ts": "const x = 1;\n" }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/plain.ts"); + const stmt = sf.statements[0]; + assert.ok(stmt); + + assert.strictEqual(stmt.getFullStart(), 0); + assert.strictEqual(stmt.getStart(), 0); + assert.strictEqual(stmt.getLeadingTriviaWidth(), 0); + assert.strictEqual(stmt.getText(), "const x = 1;"); + assert.strictEqual(stmt.getText(), stmt.getFullText()); + } + finally { + api.close(); + } + }); + + test("the SourceFile node spans the whole file text", () => { + const text = "/* head */ const y = 2;\n"; + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/whole.ts": text }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/whole.ts"); + + assert.strictEqual(sf.getFullStart(), 0); + assert.strictEqual(sf.getFullText(), sf.text); + assert.strictEqual(sf.getEnd(), sf.text.length); + // getStart skips the file's leading comment trivia. + assert.strictEqual(sf.getStart(), text.indexOf("const")); + } + finally { + api.close(); + } + }); + + test("getter invariants hold for every node in a representative tree", () => { + const treeSource = [ + `import { foo } from "./foo";`, + ``, + `/** docs */`, + `export function add(a: number, b: number): number {`, + ` // body comment`, + ` return a + b;`, + `}`, + ``, + `const obj = { x: 1, y: "two" };`, + ``, + ].join("\n"); + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/tree.ts": treeSource }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/tree.ts"); + assertGetterInvariants(sf, sf); + } + finally { + api.close(); + } + }); + + test("getter invariants hold even for malformed source with missing nodes", () => { + // Error recovery produces zero-width / missing nodes; the getters must + // still satisfy their invariants and must not throw. + const malformed = "const a = b +;\nfunction ("; + const api = spawnAPI({ "/tsconfig.json": "{}", "/src/broken.ts": malformed }); + try { + const sf = getRemoteSourceFile(api, "/tsconfig.json", "/src/broken.ts"); + assertGetterInvariants(sf, sf); + } + finally { + api.close(); + } + }); +}); diff --git a/_scripts/generate-encoder.ts b/_scripts/generate-encoder.ts index 64fd89f2ea8..caddf3356ae 100644 --- a/_scripts/generate-encoder.ts +++ b/_scripts/generate-encoder.ts @@ -1474,6 +1474,7 @@ function generateTSNodeGenerated(): string { function emitNodeGeneratedImports(w: CodeWriter) { w.write(`import {`); + w.write(` getTokenPosOfNode,`); w.write(` ModifierFlags,`); w.write(` type Node,`); w.write(` type NodeArray,`); @@ -1691,6 +1692,39 @@ function emitRemoteNodeClassOpen(w: CodeWriter) { w.write(` return this.sourceFile as unknown as SourceFile;`); w.write(` }`); w.write(``); + w.write(` getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number {`); + w.write(` return getTokenPosOfNode(this as unknown as Node, sourceFile ?? this.getSourceFile(), includeJsDocComment);`); + w.write(` }`); + w.write(``); + w.write(` getFullStart(): number {`); + w.write(` return this.pos;`); + w.write(` }`); + w.write(``); + w.write(` getEnd(): number {`); + w.write(` return this.end;`); + w.write(` }`); + w.write(``); + w.write(` getWidth(sourceFile?: SourceFile): number {`); + w.write(` return this.getEnd() - this.getStart(sourceFile);`); + w.write(` }`); + w.write(``); + w.write(` getFullWidth(): number {`); + w.write(` return this.end - this.pos;`); + w.write(` }`); + w.write(``); + w.write(` getLeadingTriviaWidth(sourceFile?: SourceFile): number {`); + w.write(` return this.getStart(sourceFile) - this.pos;`); + w.write(` }`); + w.write(``); + w.write(` getFullText(sourceFile?: SourceFile): string {`); + w.write(` return (sourceFile ?? this.getSourceFile()).text.substring(this.pos, this.end);`); + w.write(` }`); + w.write(``); + w.write(` getText(sourceFile?: SourceFile): string {`); + w.write(` sourceFile ??= this.getSourceFile();`); + w.write(` return sourceFile.text.substring(this.getStart(sourceFile), this.end);`); + 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/_scripts/generate-ts-ast.ts b/_scripts/generate-ts-ast.ts index 00c8a3e80b6..73e621b07fe 100644 --- a/_scripts/generate-ts-ast.ts +++ b/_scripts/generate-ts-ast.ts @@ -517,6 +517,7 @@ function generateFactory(): string { out.push(` ${t},`); } out.push(`} from "./ast.ts";`); + out.push(`import { getTokenPosOfNode } from "./astnav.ts";`); // Import hand-written forEachChild functions const handWrittenForEachChildImports: string[] = []; @@ -573,6 +574,39 @@ function generateFactory(): string { out.push(` while (node.parent) node = node.parent;`); out.push(` return node as unknown as SourceFile;`); out.push(` }`); + out.push(``); + out.push(` getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number {`); + out.push(` return getTokenPosOfNode(this as unknown as Node, sourceFile ?? this.getSourceFile(), includeJsDocComment);`); + out.push(` }`); + out.push(``); + out.push(` getFullStart(): number {`); + out.push(` return this.pos;`); + out.push(` }`); + out.push(``); + out.push(` getEnd(): number {`); + out.push(` return this.end;`); + out.push(` }`); + out.push(``); + out.push(` getWidth(sourceFile?: SourceFile): number {`); + out.push(` return this.getEnd() - this.getStart(sourceFile);`); + out.push(` }`); + out.push(``); + out.push(` getFullWidth(): number {`); + out.push(` return this.end - this.pos;`); + out.push(` }`); + out.push(``); + out.push(` getLeadingTriviaWidth(sourceFile?: SourceFile): number {`); + out.push(` return this.getStart(sourceFile) - this.pos;`); + out.push(` }`); + out.push(``); + out.push(` getFullText(sourceFile?: SourceFile): string {`); + out.push(` return (sourceFile ?? this.getSourceFile()).text.substring(this.pos, this.end);`); + out.push(` }`); + out.push(``); + out.push(` getText(sourceFile?: SourceFile): string {`); + out.push(` sourceFile ??= this.getSourceFile();`); + out.push(` return sourceFile.text.substring(this.getStart(sourceFile), this.end);`); + out.push(` }`); out.push(`}`); out.push(``);