From d68c512ba08fe3e1071be1b6910b252675f3d7d4 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 13 Aug 2026 09:06:44 -0700 Subject: [PATCH 1/2] [api] Move language service methods into dedicated namespace --- _packages/native-preview/src/api/async/api.ts | 131 +++++--- .../native-preview/src/api/async/types.ts | 6 +- _packages/native-preview/src/api/sync/api.ts | 125 +++++--- .../native-preview/src/api/sync/types.ts | 6 +- .../native-preview/test/async/api.test.ts | 280 +++++++++--------- .../native-preview/test/sync/api.test.ts | 280 +++++++++--------- internal/api/session.go | 15 +- internal/jsdoc/jsdoc.go | 198 +++++++++++++ internal/ls/jsdoc.go | 161 ---------- 9 files changed, 662 insertions(+), 540 deletions(-) create mode 100644 internal/jsdoc/jsdoc.go delete mode 100644 internal/ls/jsdoc.go diff --git a/_packages/native-preview/src/api/async/api.ts b/_packages/native-preview/src/api/async/api.ts index f63d500fb3a..54fb6c43a9c 100644 --- a/_packages/native-preview/src/api/async/api.ts +++ b/_packages/native-preview/src/api/async/api.ts @@ -1,4 +1,3 @@ -/// import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; @@ -703,6 +702,7 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly emitter: Emitter; + readonly languageService: LanguageService; private client: Client; private snapshotId: number; @@ -736,6 +736,40 @@ export class Project { objectRegistry, ); this.emitter = new Emitter(client); + this.languageService = new LanguageService(snapshotId, this, client, objectRegistry); + } + + /** @deprecated Use `languageService.getImportAdderEdits`. */ + getImportAdderEdits(file: DocumentIdentifier, actions: readonly ImportAdderAction[]): Promise { + return this.languageService.getImportAdderEdits(file, actions); + } + + /** @deprecated Use `languageService.getImportEditsForSymbols`. */ + getImportEditsForSymbols(file: DocumentIdentifier, symbols: readonly Symbol[], options: GetImportEditsForSymbolsOptions = {}): Promise { + return this.languageService.getImportEditsForSymbols(file, symbols, options); + } + + dispose(): void { + this.checker.dispose(); + } +} + +export class LanguageService { + private snapshotId: number; + private project: Project; + private client: Client; + private objectRegistry: ProjectObjectRegistry; + + constructor( + snapshotId: number, + project: Project, + client: Client, + objectRegistry: ProjectObjectRegistry, + ) { + this.snapshotId = snapshotId; + this.project = project; + this.client = client; + this.objectRegistry = objectRegistry; } async getImportAdderEdits(file: DocumentIdentifier, actions: readonly ImportAdderAction[]): Promise { @@ -757,7 +791,7 @@ export class Project { const data = await this.client.apiRequest("getImportAdderEdits", { snapshot: this.snapshotId, - project: this.id, + project: this.project.id, file, actions: requestActions, }); @@ -783,8 +817,49 @@ export class Project { ); } - dispose(): void { - this.checker.dispose(); + async getReferencedSymbolsForNode(node: Node, position: number): Promise { + const data = await this.client.apiRequest<{ definition: string; symbol?: SymbolResponse; references: string[]; }[] | null>("getReferencedSymbolsForNode", { + snapshot: this.snapshotId, + project: this.project.id, + node: getNodeId(node), + position, + }); + return (data ?? []).map(entry => ({ + definition: new NodeHandle(entry.definition, this.project), + symbol: entry.symbol ? this.objectRegistry.getOrCreateSymbol(entry.symbol) : undefined, + references: (entry.references ?? []).map(h => new NodeHandle(h, this.project)), + })); + } + + async getSignatureUsage(signatureDecl: Node): Promise { + const data = await this.client.apiRequest<{ name: string; call?: string; }[] | null>("getSignatureUsages", { + snapshot: this.snapshotId, + project: this.project.id, + signatureDecl: getNodeId(signatureDecl), + }); + return (data ?? []).map(entry => ({ + name: new NodeHandle(entry.name, this.project), + call: entry.call ? new NodeHandle(entry.call, this.project) : undefined, + })); + } + + async getCompletionsAtPosition(document: string, position: number, options?: CompletionOptions): Promise { + const data = await this.client.apiRequest("getCompletionsAtPosition", { + snapshot: this.snapshotId, + project: this.project.id, + file: document, + position, + triggerCharacter: options?.triggerCharacter, + includeSymbol: options?.includeSymbol, + }); + if (!data) return undefined; + return { + isIncomplete: data.isIncomplete, + entries: data.entries.map(e => ({ + ...e, + symbol: e.symbol ? this.objectRegistry.getOrCreateSymbol(e.symbol) : undefined, + })), + }; } } @@ -1207,49 +1282,19 @@ export class Checker { return (data ?? []).map(h => new NodeHandle(h, this.project)); } - async getReferencedSymbolsForNode(node: Node, position: number): Promise { - const data = await this.client.apiRequest<{ definition: string; symbol?: SymbolResponse; references: string[]; }[] | null>("getReferencedSymbolsForNode", { - snapshot: this.snapshotId, - project: this.project.id, - node: getNodeId(node), - position, - }); - return (data ?? []).map(entry => ({ - definition: new NodeHandle(entry.definition, this.project), - symbol: entry.symbol ? this.objectRegistry.getOrCreateSymbol(entry.symbol) : undefined, - references: (entry.references ?? []).map(h => new NodeHandle(h, this.project)), - })); + /** @deprecated Use `project.languageService.getReferencedSymbolsForNode`. */ + getReferencedSymbolsForNode(node: Node, position: number): Promise { + return this.project.languageService.getReferencedSymbolsForNode(node, position); } - async getSignatureUsage(signatureDecl: Node): Promise { - const data = await this.client.apiRequest<{ name: string; call?: string; }[] | null>("getSignatureUsages", { - snapshot: this.snapshotId, - project: this.project.id, - signatureDecl: getNodeId(signatureDecl), - }); - return (data ?? []).map(entry => ({ - name: new NodeHandle(entry.name, this.project), - call: entry.call ? new NodeHandle(entry.call, this.project) : undefined, - })); + /** @deprecated Use `project.languageService.getSignatureUsage`. */ + getSignatureUsage(signatureDecl: Node): Promise { + return this.project.languageService.getSignatureUsage(signatureDecl); } - async getCompletionsAtPosition(document: string, position: number, options?: CompletionOptions): Promise { - const data = await this.client.apiRequest("getCompletionsAtPosition", { - snapshot: this.snapshotId, - project: this.project.id, - file: document, - position, - triggerCharacter: options?.triggerCharacter, - includeSymbol: options?.includeSymbol, - }); - if (!data) return undefined; - return { - isIncomplete: data.isIncomplete, - entries: data.entries.map(e => ({ - ...e, - symbol: e.symbol ? this.objectRegistry.getOrCreateSymbol(e.symbol) : undefined, - })), - }; + /** @deprecated Use `project.languageService.getCompletionsAtPosition`. */ + getCompletionsAtPosition(document: string, position: number, options?: CompletionOptions): Promise { + return this.project.languageService.getCompletionsAtPosition(document, position, options); } /** diff --git a/_packages/native-preview/src/api/async/types.ts b/_packages/native-preview/src/api/async/types.ts index 5b5c4e6544e..a1cbd4c43cf 100644 --- a/_packages/native-preview/src/api/async/types.ts +++ b/_packages/native-preview/src/api/async/types.ts @@ -335,14 +335,14 @@ export interface CompletionEntryLabelDetails { description?: string | undefined; } -/** Options for {@link Checker.getCompletionsAtPosition}. */ +/** Options for {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionOptions { triggerCharacter?: string | undefined; /** Include a `symbol` property on each completion entry. Only populated for symbol-based completions (not keywords or literals). */ includeSymbol?: boolean | undefined; } -/** A single completion item returned by {@link Checker.getCompletionsAtPosition}. */ +/** A single completion item returned by {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionEntry { readonly name: string; readonly kind?: CompletionItemKind | undefined; @@ -355,7 +355,7 @@ export interface CompletionEntry { readonly symbol?: Symbol | undefined; } -/** The result of {@link Checker.getCompletionsAtPosition}. */ +/** The result of {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionInfo { readonly isIncomplete: boolean; readonly entries: readonly CompletionEntry[]; diff --git a/_packages/native-preview/src/api/sync/api.ts b/_packages/native-preview/src/api/sync/api.ts index 22aacc48737..d8d2fc2dacb 100644 --- a/_packages/native-preview/src/api/sync/api.ts +++ b/_packages/native-preview/src/api/sync/api.ts @@ -6,7 +6,6 @@ // Source: src/api/async/api.ts // Regenerate: npm run generate (from _packages/native-preview) // -/// import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; @@ -711,6 +710,7 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly emitter: Emitter; + readonly languageService: LanguageService; private client: Client; private snapshotId: number; @@ -744,6 +744,40 @@ export class Project { objectRegistry, ); this.emitter = new Emitter(client); + this.languageService = new LanguageService(snapshotId, this, client, objectRegistry); + } + + /** @deprecated Use `languageService.getImportAdderEdits`. */ + getImportAdderEdits(file: DocumentIdentifier, actions: readonly ImportAdderAction[]): readonly TextEdit[] { + return this.languageService.getImportAdderEdits(file, actions); + } + + /** @deprecated Use `languageService.getImportEditsForSymbols`. */ + getImportEditsForSymbols(file: DocumentIdentifier, symbols: readonly Symbol[], options: GetImportEditsForSymbolsOptions = {}): readonly TextEdit[] { + return this.languageService.getImportEditsForSymbols(file, symbols, options); + } + + dispose(): void { + this.checker.dispose(); + } +} + +export class LanguageService { + private snapshotId: number; + private project: Project; + private client: Client; + private objectRegistry: ProjectObjectRegistry; + + constructor( + snapshotId: number, + project: Project, + client: Client, + objectRegistry: ProjectObjectRegistry, + ) { + this.snapshotId = snapshotId; + this.project = project; + this.client = client; + this.objectRegistry = objectRegistry; } getImportAdderEdits(file: DocumentIdentifier, actions: readonly ImportAdderAction[]): readonly TextEdit[] { @@ -765,7 +799,7 @@ export class Project { const data = this.client.apiRequest("getImportAdderEdits", { snapshot: this.snapshotId, - project: this.id, + project: this.project.id, file, actions: requestActions, }); @@ -791,8 +825,49 @@ export class Project { ); } - dispose(): void { - this.checker.dispose(); + getReferencedSymbolsForNode(node: Node, position: number): ReferencedSymbolEntry[] { + const data = this.client.apiRequest<{ definition: string; symbol?: SymbolResponse; references: string[]; }[] | null>("getReferencedSymbolsForNode", { + snapshot: this.snapshotId, + project: this.project.id, + node: getNodeId(node), + position, + }); + return (data ?? []).map(entry => ({ + definition: new NodeHandle(entry.definition, this.project), + symbol: entry.symbol ? this.objectRegistry.getOrCreateSymbol(entry.symbol) : undefined, + references: (entry.references ?? []).map(h => new NodeHandle(h, this.project)), + })); + } + + getSignatureUsage(signatureDecl: Node): SignatureUsage[] { + const data = this.client.apiRequest<{ name: string; call?: string; }[] | null>("getSignatureUsages", { + snapshot: this.snapshotId, + project: this.project.id, + signatureDecl: getNodeId(signatureDecl), + }); + return (data ?? []).map(entry => ({ + name: new NodeHandle(entry.name, this.project), + call: entry.call ? new NodeHandle(entry.call, this.project) : undefined, + })); + } + + getCompletionsAtPosition(document: string, position: number, options?: CompletionOptions): CompletionInfo | undefined { + const data = this.client.apiRequest("getCompletionsAtPosition", { + snapshot: this.snapshotId, + project: this.project.id, + file: document, + position, + triggerCharacter: options?.triggerCharacter, + includeSymbol: options?.includeSymbol, + }); + if (!data) return undefined; + return { + isIncomplete: data.isIncomplete, + entries: data.entries.map(e => ({ + ...e, + symbol: e.symbol ? this.objectRegistry.getOrCreateSymbol(e.symbol) : undefined, + })), + }; } } @@ -1215,49 +1290,19 @@ export class Checker { return (data ?? []).map(h => new NodeHandle(h, this.project)); } + /** @deprecated Use `project.languageService.getReferencedSymbolsForNode`. */ getReferencedSymbolsForNode(node: Node, position: number): ReferencedSymbolEntry[] { - const data = this.client.apiRequest<{ definition: string; symbol?: SymbolResponse; references: string[]; }[] | null>("getReferencedSymbolsForNode", { - snapshot: this.snapshotId, - project: this.project.id, - node: getNodeId(node), - position, - }); - return (data ?? []).map(entry => ({ - definition: new NodeHandle(entry.definition, this.project), - symbol: entry.symbol ? this.objectRegistry.getOrCreateSymbol(entry.symbol) : undefined, - references: (entry.references ?? []).map(h => new NodeHandle(h, this.project)), - })); + return this.project.languageService.getReferencedSymbolsForNode(node, position); } + /** @deprecated Use `project.languageService.getSignatureUsage`. */ getSignatureUsage(signatureDecl: Node): SignatureUsage[] { - const data = this.client.apiRequest<{ name: string; call?: string; }[] | null>("getSignatureUsages", { - snapshot: this.snapshotId, - project: this.project.id, - signatureDecl: getNodeId(signatureDecl), - }); - return (data ?? []).map(entry => ({ - name: new NodeHandle(entry.name, this.project), - call: entry.call ? new NodeHandle(entry.call, this.project) : undefined, - })); + return this.project.languageService.getSignatureUsage(signatureDecl); } + /** @deprecated Use `project.languageService.getCompletionsAtPosition`. */ getCompletionsAtPosition(document: string, position: number, options?: CompletionOptions): CompletionInfo | undefined { - const data = this.client.apiRequest("getCompletionsAtPosition", { - snapshot: this.snapshotId, - project: this.project.id, - file: document, - position, - triggerCharacter: options?.triggerCharacter, - includeSymbol: options?.includeSymbol, - }); - if (!data) return undefined; - return { - isIncomplete: data.isIncomplete, - entries: data.entries.map(e => ({ - ...e, - symbol: e.symbol ? this.objectRegistry.getOrCreateSymbol(e.symbol) : undefined, - })), - }; + return this.project.languageService.getCompletionsAtPosition(document, position, options); } /** diff --git a/_packages/native-preview/src/api/sync/types.ts b/_packages/native-preview/src/api/sync/types.ts index 36c3658daaf..b5adb4c3933 100644 --- a/_packages/native-preview/src/api/sync/types.ts +++ b/_packages/native-preview/src/api/sync/types.ts @@ -343,14 +343,14 @@ export interface CompletionEntryLabelDetails { description?: string | undefined; } -/** Options for {@link Checker.getCompletionsAtPosition}. */ +/** Options for {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionOptions { triggerCharacter?: string | undefined; /** Include a `symbol` property on each completion entry. Only populated for symbol-based completions (not keywords or literals). */ includeSymbol?: boolean | undefined; } -/** A single completion item returned by {@link Checker.getCompletionsAtPosition}. */ +/** A single completion item returned by {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionEntry { readonly name: string; readonly kind?: CompletionItemKind | undefined; @@ -363,7 +363,7 @@ export interface CompletionEntry { readonly symbol?: Symbol | undefined; } -/** The result of {@link Checker.getCompletionsAtPosition}. */ +/** The result of {@link LanguageService.getCompletionsAtPosition}. */ export interface CompletionInfo { readonly isIncomplete: boolean; readonly entries: readonly CompletionEntry[]; diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index ce4e6f53af3..d4d01b8aa48 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -285,7 +285,9 @@ describe("Snapshot", () => { await api.close(); } }); +}); +describe("LanguageService - imports", () => { test("getImportEditsForSymbols adds a named import", async () => { const source = `const value = foo;\n`; const api = spawnAPI({ @@ -299,7 +301,7 @@ describe("Snapshot", () => { const symbol = await project.checker.getSymbolAtPosition("/src/foo.ts", "export const ".length); assert.ok(symbol); - const edits = await project.getImportEditsForSymbols("/src/index.ts", [await symbol.getExportSymbol()]); + const edits = await project.languageService.getImportEditsForSymbols("/src/index.ts", [await symbol.getExportSymbol()]); assert.equal(applyTextEdits(source, edits), `import { foo } from "./foo";\n\nconst value = foo;\n`); } @@ -323,7 +325,7 @@ describe("Snapshot", () => { assert.ok(foo); assert.ok(bar); - const edits = await project.getImportAdderEdits("/src/index.ts", [ + const edits = await project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol: await foo.getExportSymbol() }, { kind: "importSymbol", symbol: await bar.getExportSymbol() }, ]); @@ -348,7 +350,7 @@ describe("Snapshot", () => { const bar = await project.checker.getSymbolAtPosition("/src/foo.ts", "export const foo = 1;\nexport const ".length); assert.ok(bar); - const edits = await project.getImportAdderEdits("/src/index.ts", [ + const edits = await project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol: await bar.getExportSymbol() }, ]); @@ -372,7 +374,7 @@ describe("Snapshot", () => { const symbol = await project.checker.getSymbolAtPosition("/src/foo.ts", "const ".length); assert.ok(symbol); - const edits = await project.getImportAdderEdits("/src/index.ts", [ + const edits = await project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol }, ]); @@ -392,11 +394,11 @@ describe("Snapshot", () => { assert.ok(symbol); await assert.rejects( // @sync: assert.throws( - () => project.getImportAdderEdits("/src/index.ts", [{ kind: "unknown", symbol: symbol.id } as unknown as ImportAdderAction]), + () => project.languageService.getImportAdderEdits("/src/index.ts", [{ kind: "unknown", symbol: symbol.id } as unknown as ImportAdderAction]), /Debug Failure\. Illegal value: "unknown"/, ); await assert.rejects( // @sync: assert.throws( - () => project.getImportAdderEdits("/src/index.ts", [{ kind: "importSymbol", symbol: { ...symbol, id: 999_999_999 } } as unknown as ImportAdderAction]), + () => project.languageService.getImportAdderEdits("/src/index.ts", [{ kind: "importSymbol", symbol: { ...symbol, id: 999_999_999 } } as unknown as ImportAdderAction]), /symbol handle \d+ not found/, ); } @@ -406,6 +408,139 @@ describe("Snapshot", () => { }); }); +describe("LanguageService - getCompletionsAtPosition", () => { + test("returns member completions after a dot", async () => { + const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + // Position right after "obj." — member completion trigger + const pos = src.indexOf("obj.") + "obj.".length; + const completions = await project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); + assert.ok(completions, "Expected completions to be returned"); + assert.ok(completions.entries.length > 0, "Expected at least one completion entry"); + assert.ok(completions.entries.some(e => e.name === "name"), "Expected 'name' property in completions"); + assert.ok(completions.entries.some(e => e.name === "age"), "Expected 'age' property in completions"); + assert.ok(completions.entries.every(e => e.symbol === undefined), "Expected no symbol information"); + } + finally { + await api.close(); + } + }); + + test("completion entries include sortText", async () => { + const src = `\nconst obj = { value: 1 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const pos = src.indexOf("obj.") + "obj.".length; + const completions = await project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); + assert.ok(completions); + assert.ok(completions.entries.length > 0); + assert.ok(completions.entries.some(e => e.sortText !== undefined), "Expected sortText on all entries"); + } + finally { + await api.close(); + } + }); + + test("returns undefined for a non-existent file", async () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": `export {};`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const completions = await project.languageService.getCompletionsAtPosition("/src/does-not-exist.ts", 0); + assert.equal(completions, undefined, "Expected undefined for non-existent file"); + } + finally { + await api.close(); + } + }); + + test("includeSymbol: true populates symbol on property completions", async () => { + const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const pos = src.indexOf("obj.") + "obj.".length; + const completions = await project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: ".", includeSymbol: true }); + assert.ok(completions, "Expected completions"); + const nameEntry = completions.entries.find(e => e.name === "name"); + assert.ok(nameEntry, "Expected 'name' entry"); + assert.ok(nameEntry.symbol, "Expected symbol to be set on 'name' entry when includeSymbol: true"); + assert.equal(nameEntry.symbol.name, "name", "Symbol name should match completion name"); + } + finally { + await api.close(); + } + }); +}); + +describe("LanguageService - getReferencedSymbolsForNode", () => { + test("getReferencedSymbolsForNode", async () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); + const funcName = funcDecl.name!; + const refs = await project.languageService.getReferencedSymbolsForNode(funcName, funcName.pos); + assert.ok(refs.length > 0); + // Each entry should have a definition and references + const entry = refs[0]; + assert.ok(entry.definition); + assert.ok(entry.references.length > 0); + } + finally { + await api.close(); + } + }); +}); + +describe("LanguageService - getSignatureUsage", () => { + test("getSignatureUsage", async () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); + const usages = await project.languageService.getSignatureUsage(funcDecl); + assert.ok(usages.length > 0); + // The call site should have a call expression + const usage = usages.find(u => u.call !== undefined); + assert.ok(usage, "Expected at least one usage with a call expression"); + } + finally { + await api.close(); + } + }); +}); + describe("Checker - getApparentType", () => { test("returns the apparent type of a literal type", async () => { const api = spawnAPI({ @@ -4409,89 +4544,6 @@ describe("Checker - isTypeAssignableTo", () => { }); }); -describe("Checker - getCompletionsAtPosition", () => { - test("returns member completions after a dot", async () => { - const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - // Position right after "obj." — member completion trigger - const pos = src.indexOf("obj.") + "obj.".length; - const completions = await project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); - assert.ok(completions, "Expected completions to be returned"); - assert.ok(completions.entries.length > 0, "Expected at least one completion entry"); - assert.ok(completions.entries.some(e => e.name === "name"), "Expected 'name' property in completions"); - assert.ok(completions.entries.some(e => e.name === "age"), "Expected 'age' property in completions"); - assert.ok(completions.entries.every(e => e.symbol === undefined), "Expected no symbol information"); - } - finally { - await api.close(); - } - }); - - test("completion entries include sortText", async () => { - const src = `\nconst obj = { value: 1 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const pos = src.indexOf("obj.") + "obj.".length; - const completions = await project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); - assert.ok(completions); - assert.ok(completions.entries.length > 0); - assert.ok(completions.entries.some(e => e.sortText !== undefined), "Expected sortText on all entries"); - } - finally { - await api.close(); - } - }); - - test("returns undefined for a non-existent file", async () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": `export {};`, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const completions = await project.checker.getCompletionsAtPosition("/src/does-not-exist.ts", 0); - assert.equal(completions, undefined, "Expected undefined for non-existent file"); - } - finally { - await api.close(); - } - }); - - test("includeSymbol: true populates symbol on property completions", async () => { - const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const pos = src.indexOf("obj.") + "obj.".length; - const completions = await project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: ".", includeSymbol: true }); - assert.ok(completions, "Expected completions"); - const nameEntry = completions.entries.find(e => e.name === "name"); - assert.ok(nameEntry, "Expected 'name' entry"); - assert.ok(nameEntry.symbol, "Expected symbol to be set on 'name' entry when includeSymbol: true"); - assert.equal(nameEntry.symbol.name, "name", "Symbol name should match completion name"); - } - finally { - await api.close(); - } - }); -}); - describe("Emitter - printNode", () => { const emitterFiles = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), @@ -5386,56 +5438,6 @@ describe("Program - diagnostics", () => { }); }); -describe("Checker - getReferencedSymbolsForNode", () => { - test("getReferencedSymbolsForNode", async () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = await project.program.getSourceFile("/src/index.ts"); - assert.ok(sourceFile); - const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); - const funcName = funcDecl.name!; - const refs = await project.checker.getReferencedSymbolsForNode(funcName, funcName.pos); - assert.ok(refs.length > 0); - // Each entry should have a definition and references - const entry = refs[0]; - assert.ok(entry.definition); - assert.ok(entry.references.length > 0); - } - finally { - await api.close(); - } - }); -}); - -describe("Checker - getSignatureUsage", () => { - test("getSignatureUsage", async () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, - }); - try { - const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = await project.program.getSourceFile("/src/index.ts"); - assert.ok(sourceFile); - const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); - const usages = await project.checker.getSignatureUsage(funcDecl); - assert.ok(usages.length > 0); - // The call site should have a call expression - const usage = usages.find(u => u.call !== undefined); - assert.ok(usage, "Expected at least one usage with a call expression"); - } - finally { - await api.close(); - } - }); -}); - describe("getDefaultProjectForFile", () => { test("finds inferred project for d.ts in node_modules after openFiles", async () => { const api = spawnAPI({ diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index 9fe20fb9192..1c7c067c66a 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -293,7 +293,9 @@ describe("Snapshot", () => { api.close(); } }); +}); +describe("LanguageService - imports", () => { test("getImportEditsForSymbols adds a named import", () => { const source = `const value = foo;\n`; const api = spawnAPI({ @@ -307,7 +309,7 @@ describe("Snapshot", () => { const symbol = project.checker.getSymbolAtPosition("/src/foo.ts", "export const ".length); assert.ok(symbol); - const edits = project.getImportEditsForSymbols("/src/index.ts", [symbol.getExportSymbol()]); + const edits = project.languageService.getImportEditsForSymbols("/src/index.ts", [symbol.getExportSymbol()]); assert.equal(applyTextEdits(source, edits), `import { foo } from "./foo";\n\nconst value = foo;\n`); } @@ -331,7 +333,7 @@ describe("Snapshot", () => { assert.ok(foo); assert.ok(bar); - const edits = project.getImportAdderEdits("/src/index.ts", [ + const edits = project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol: foo.getExportSymbol() }, { kind: "importSymbol", symbol: bar.getExportSymbol() }, ]); @@ -356,7 +358,7 @@ describe("Snapshot", () => { const bar = project.checker.getSymbolAtPosition("/src/foo.ts", "export const foo = 1;\nexport const ".length); assert.ok(bar); - const edits = project.getImportAdderEdits("/src/index.ts", [ + const edits = project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol: bar.getExportSymbol() }, ]); @@ -380,7 +382,7 @@ describe("Snapshot", () => { const symbol = project.checker.getSymbolAtPosition("/src/foo.ts", "const ".length); assert.ok(symbol); - const edits = project.getImportAdderEdits("/src/index.ts", [ + const edits = project.languageService.getImportAdderEdits("/src/index.ts", [ { kind: "importSymbol", symbol }, ]); @@ -400,11 +402,11 @@ describe("Snapshot", () => { assert.ok(symbol); assert.throws( - () => project.getImportAdderEdits("/src/index.ts", [{ kind: "unknown", symbol: symbol.id } as unknown as ImportAdderAction]), + () => project.languageService.getImportAdderEdits("/src/index.ts", [{ kind: "unknown", symbol: symbol.id } as unknown as ImportAdderAction]), /Debug Failure\. Illegal value: "unknown"/, ); assert.throws( - () => project.getImportAdderEdits("/src/index.ts", [{ kind: "importSymbol", symbol: { ...symbol, id: 999_999_999 } } as unknown as ImportAdderAction]), + () => project.languageService.getImportAdderEdits("/src/index.ts", [{ kind: "importSymbol", symbol: { ...symbol, id: 999_999_999 } } as unknown as ImportAdderAction]), /symbol handle \d+ not found/, ); } @@ -414,6 +416,139 @@ describe("Snapshot", () => { }); }); +describe("LanguageService - getCompletionsAtPosition", () => { + test("returns member completions after a dot", () => { + const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + // Position right after "obj." — member completion trigger + const pos = src.indexOf("obj.") + "obj.".length; + const completions = project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); + assert.ok(completions, "Expected completions to be returned"); + assert.ok(completions.entries.length > 0, "Expected at least one completion entry"); + assert.ok(completions.entries.some(e => e.name === "name"), "Expected 'name' property in completions"); + assert.ok(completions.entries.some(e => e.name === "age"), "Expected 'age' property in completions"); + assert.ok(completions.entries.every(e => e.symbol === undefined), "Expected no symbol information"); + } + finally { + api.close(); + } + }); + + test("completion entries include sortText", () => { + const src = `\nconst obj = { value: 1 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const pos = src.indexOf("obj.") + "obj.".length; + const completions = project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); + assert.ok(completions); + assert.ok(completions.entries.length > 0); + assert.ok(completions.entries.some(e => e.sortText !== undefined), "Expected sortText on all entries"); + } + finally { + api.close(); + } + }); + + test("returns undefined for a non-existent file", () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": `export {};`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const completions = project.languageService.getCompletionsAtPosition("/src/does-not-exist.ts", 0); + assert.equal(completions, undefined, "Expected undefined for non-existent file"); + } + finally { + api.close(); + } + }); + + test("includeSymbol: true populates symbol on property completions", () => { + const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/main.ts": src, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const pos = src.indexOf("obj.") + "obj.".length; + const completions = project.languageService.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: ".", includeSymbol: true }); + assert.ok(completions, "Expected completions"); + const nameEntry = completions.entries.find(e => e.name === "name"); + assert.ok(nameEntry, "Expected 'name' entry"); + assert.ok(nameEntry.symbol, "Expected symbol to be set on 'name' entry when includeSymbol: true"); + assert.equal(nameEntry.symbol.name, "name", "Symbol name should match completion name"); + } + finally { + api.close(); + } + }); +}); + +describe("LanguageService - getReferencedSymbolsForNode", () => { + test("getReferencedSymbolsForNode", () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); + const funcName = funcDecl.name!; + const refs = project.languageService.getReferencedSymbolsForNode(funcName, funcName.pos); + assert.ok(refs.length > 0); + // Each entry should have a definition and references + const entry = refs[0]; + assert.ok(entry.definition); + assert.ok(entry.references.length > 0); + } + finally { + api.close(); + } + }); +}); + +describe("LanguageService - getSignatureUsage", () => { + test("getSignatureUsage", () => { + const api = spawnAPI({ + "/tsconfig.json": "{}", + "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); + const usages = project.languageService.getSignatureUsage(funcDecl); + assert.ok(usages.length > 0); + // The call site should have a call expression + const usage = usages.find(u => u.call !== undefined); + assert.ok(usage, "Expected at least one usage with a call expression"); + } + finally { + api.close(); + } + }); +}); + describe("Checker - getApparentType", () => { test("returns the apparent type of a literal type", () => { const api = spawnAPI({ @@ -4417,89 +4552,6 @@ describe("Checker - isTypeAssignableTo", () => { }); }); -describe("Checker - getCompletionsAtPosition", () => { - test("returns member completions after a dot", () => { - const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - // Position right after "obj." — member completion trigger - const pos = src.indexOf("obj.") + "obj.".length; - const completions = project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); - assert.ok(completions, "Expected completions to be returned"); - assert.ok(completions.entries.length > 0, "Expected at least one completion entry"); - assert.ok(completions.entries.some(e => e.name === "name"), "Expected 'name' property in completions"); - assert.ok(completions.entries.some(e => e.name === "age"), "Expected 'age' property in completions"); - assert.ok(completions.entries.every(e => e.symbol === undefined), "Expected no symbol information"); - } - finally { - api.close(); - } - }); - - test("completion entries include sortText", () => { - const src = `\nconst obj = { value: 1 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const pos = src.indexOf("obj.") + "obj.".length; - const completions = project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: "." }); - assert.ok(completions); - assert.ok(completions.entries.length > 0); - assert.ok(completions.entries.some(e => e.sortText !== undefined), "Expected sortText on all entries"); - } - finally { - api.close(); - } - }); - - test("returns undefined for a non-existent file", () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": `export {};`, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const completions = project.checker.getCompletionsAtPosition("/src/does-not-exist.ts", 0); - assert.equal(completions, undefined, "Expected undefined for non-existent file"); - } - finally { - api.close(); - } - }); - - test("includeSymbol: true populates symbol on property completions", () => { - const src = `\nconst obj = { name: "hello", age: 42 };\nobj.\n`; - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/main.ts": src, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const pos = src.indexOf("obj.") + "obj.".length; - const completions = project.checker.getCompletionsAtPosition("/src/main.ts", pos, { triggerCharacter: ".", includeSymbol: true }); - assert.ok(completions, "Expected completions"); - const nameEntry = completions.entries.find(e => e.name === "name"); - assert.ok(nameEntry, "Expected 'name' entry"); - assert.ok(nameEntry.symbol, "Expected symbol to be set on 'name' entry when includeSymbol: true"); - assert.equal(nameEntry.symbol.name, "name", "Symbol name should match completion name"); - } - finally { - api.close(); - } - }); -}); - describe("Emitter - printNode", () => { const emitterFiles = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), @@ -5394,56 +5446,6 @@ describe("Program - diagnostics", () => { }); }); -describe("Checker - getReferencedSymbolsForNode", () => { - test("getReferencedSymbolsForNode", () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = project.program.getSourceFile("/src/index.ts"); - assert.ok(sourceFile); - const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); - const funcName = funcDecl.name!; - const refs = project.checker.getReferencedSymbolsForNode(funcName, funcName.pos); - assert.ok(refs.length > 0); - // Each entry should have a definition and references - const entry = refs[0]; - assert.ok(entry.definition); - assert.ok(entry.references.length > 0); - } - finally { - api.close(); - } - }); -}); - -describe("Checker - getSignatureUsage", () => { - test("getSignatureUsage", () => { - const api = spawnAPI({ - "/tsconfig.json": "{}", - "/src/index.ts": `function greet(name: string) { return name; }\ngreet("world");`, - }); - try { - const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = project.program.getSourceFile("/src/index.ts"); - assert.ok(sourceFile); - const funcDecl = cast(sourceFile.statements[0], isFunctionDeclaration); - const usages = project.checker.getSignatureUsage(funcDecl); - assert.ok(usages.length > 0); - // The call site should have a call expression - const usage = usages.find(u => u.call !== undefined); - assert.ok(usage, "Expected at least one usage with a call expression"); - } - finally { - api.close(); - } - }); -}); - describe("getDefaultProjectForFile", () => { test("finds inferred project for d.ts in node_modules after openFiles", () => { const api = spawnAPI({ diff --git a/internal/api/session.go b/internal/api/session.go index 80b114c3748..ceeab0e696b 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/jsdoc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" @@ -3086,12 +3087,7 @@ func (s *Session) handleGetJSDocTags(ctx context.Context, params *CheckerSymbolP return nil, nil } - langSvc, err := s.setupLanguageService(setup.sd, setup.program, params.Project, "") - if err != nil { - return nil, err - } - - tags := langSvc.GetSymbolJSDocTags(symbol) + tags := jsdoc.GetSymbolTags(symbol) if len(tags) == 0 { return nil, nil } @@ -3118,12 +3114,7 @@ func (s *Session) handleGetDocumentationComment(ctx context.Context, params *Che return "", nil } - langSvc, err := s.setupLanguageService(setup.sd, setup.program, params.Project, "") - if err != nil { - return "", err - } - - return langSvc.GetSymbolDocumentationComment(setup.checker, symbol), nil + return jsdoc.GetSymbolDocumentationComment(symbol), nil } // handleGetTypeArguments returns the type arguments of a type reference. diff --git a/internal/jsdoc/jsdoc.go b/internal/jsdoc/jsdoc.go new file mode 100644 index 00000000000..2787f493a09 --- /dev/null +++ b/internal/jsdoc/jsdoc.go @@ -0,0 +1,198 @@ +package jsdoc + +import ( + "slices" + "strings" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/scanner" +) + +// TagInfo is a JSDoc tag with its text rendered as a plain string. +type TagInfo struct { + Name string + Text string +} + +// GetSymbolDocumentationComment renders a symbol's documentation comment as plain text. +func GetSymbolDocumentationComment(symbol *ast.Symbol) string { + if symbol == nil { + return "" + } + var parts []string + var seen collections.Set[*ast.Node] + for _, declaration := range symbol.Declarations { + if declaration == nil || !seen.AddIfAbsent(declaration) { + continue + } + for _, comment := range getJSDocComments(declaration) { + if shouldSkipComment(declaration, comment) { + continue + } + text := renderComments(comment.Comments()) + if text != "" && !slices.Contains(parts, text) { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") +} + +// GetSymbolTags collects a symbol's JSDoc tags with their text rendered as plain strings. +func GetSymbolTags(symbol *ast.Symbol) []TagInfo { + if symbol == nil { + return nil + } + var infos []TagInfo + var seen collections.Set[*ast.Node] + for _, declaration := range symbol.Declarations { + if declaration == nil || !seen.AddIfAbsent(declaration) { + continue + } + tags := declarationJSDocTags(declaration) + hasTypedef := core.Some(tags, func(tag *ast.Node) bool { + return tag.Kind == ast.KindJSDocTypedefTag || tag.Kind == ast.KindJSDocCallbackTag + }) + hasParamOrReturn := core.Some(tags, func(tag *ast.Node) bool { + return tag.Kind == ast.KindJSDocParameterTag || tag.Kind == ast.KindJSDocReturnTag + }) + if hasTypedef && !hasParamOrReturn { + continue + } + for _, tag := range tags { + infos = append(infos, TagInfo{Name: tag.TagName().Text(), Text: getTagText(tag)}) + } + } + return infos +} + +func getJSDocComments(node *ast.Node) []*ast.Node { + if node.Flags&ast.NodeFlagsJSDoc != 0 { + return []*ast.Node{node} + } + var comments []*ast.Node + for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { + comments = append(comments, current.JSDoc(nil)...) + } + return comments +} + +func shouldSkipComment(declaration *ast.Node, comment *ast.Node) bool { + if comment == nil || len(comment.Comments()) == 0 { + return true + } + if declaration.Kind == ast.KindJSDocTypedefTag || declaration.Kind == ast.KindJSDocCallbackTag || comment.Kind != ast.KindJSDoc { + return false + } + tags := comment.AsJSDoc().Tags + if tags == nil { + return false + } + hasTypedef := core.Some(tags.Nodes, func(tag *ast.Node) bool { + return tag.Kind == ast.KindJSDocTypedefTag || tag.Kind == ast.KindJSDocCallbackTag + }) + hasParamOrReturn := core.Some(tags.Nodes, func(tag *ast.Node) bool { + return tag.Kind == ast.KindJSDocParameterTag || tag.Kind == ast.KindJSDocReturnTag + }) + return hasTypedef && !hasParamOrReturn +} + +func renderComments(comments []*ast.Node) string { + var builder strings.Builder + for _, comment := range comments { + switch comment.Kind { + case ast.KindJSDocText: + builder.WriteString(comment.Text()) + case ast.KindJSDocLink, ast.KindJSDocLinkPlain, ast.KindJSDocLinkCode: + name := comment.Name() + text := strings.Trim(comment.Text(), " ") + if name == nil { + builder.WriteString(text) + } else if text == "" { + builder.WriteString(scanner.GetTextOfNode(name)) + } else { + builder.WriteString(strings.TrimLeft(strings.TrimPrefix(strings.TrimLeft(text, " "), "|"), " ")) + } + } + } + return builder.String() +} + +func declarationJSDocTags(node *ast.Node) []*ast.Node { + if node.Flags&ast.NodeFlagsJSDoc == 0 { + for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { + jsdocs := current.JSDoc(nil) + if len(jsdocs) == 0 { + continue + } + lastJSDoc := jsdocs[len(jsdocs)-1].AsJSDoc() + if lastJSDoc.Tags != nil { + return lastJSDoc.Tags.Nodes + } + } + } + return nil +} + +func getTagText(tag *ast.Node) string { + comment := scanner.GetTextOfJSDocComment(tag.CommentList()) + addComment := func(text string) string { + if comment == "" { + return text + } + return text + " " + comment + } + switch tag.Kind { + case ast.KindJSDocThrowsTag: + if typeExpression := tag.AsJSDocThrowsTag().TypeExpression; typeExpression != nil { + return addComment(scanner.GetTextOfNode(typeExpression)) + } + return comment + case ast.KindJSDocImplementsTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocImplementsTag().ClassName)) + case ast.KindJSDocAugmentsTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocAugmentsTag().ClassName)) + case ast.KindJSDocTemplateTag: + templateTag := tag.AsJSDocTemplateTag() + var builder strings.Builder + if templateTag.Constraint != nil { + builder.WriteString(scanner.GetTextOfNode(templateTag.Constraint)) + } + if templateTag.TypeParameters != nil { + for i, typeParameter := range templateTag.TypeParameters.Nodes { + if i == 0 && builder.Len() != 0 { + builder.WriteString(" ") + } + if i != 0 { + builder.WriteString(", ") + } + builder.WriteString(scanner.GetTextOfNode(typeParameter)) + } + } + if comment != "" { + if builder.Len() != 0 { + builder.WriteString(" ") + } + builder.WriteString(comment) + } + return builder.String() + case ast.KindJSDocTypeTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocTypeTag().TypeExpression)) + case ast.KindJSDocSatisfiesTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocSatisfiesTag().TypeExpression)) + case ast.KindJSDocSeeTag: + if nameExpression := tag.AsJSDocSeeTag().NameExpression; nameExpression != nil { + return addComment(scanner.GetTextOfNode(nameExpression)) + } + return comment + case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: + if name := tag.Name(); name != nil { + return addComment(scanner.GetTextOfNode(name)) + } + return comment + default: + return comment + } +} diff --git a/internal/ls/jsdoc.go b/internal/ls/jsdoc.go deleted file mode 100644 index a5992fa7358..00000000000 --- a/internal/ls/jsdoc.go +++ /dev/null @@ -1,161 +0,0 @@ -package ls - -import ( - "slices" - "strings" - - "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/checker" - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/scanner" -) - -// JSDocTagInfo mirrors Strada's `JSDocTagInfo`, but renders the tag's text as a -// plain string instead of `SymbolDisplayPart[]`. -type JSDocTagInfo struct { - Name string - Text string -} - -// GetSymbolDocumentationComment renders a symbol's documentation comment as plain text. -// It backs the API's Symbol.getDocumentationComment and mirrors Strada's -// getJsDocCommentsFromDeclarations: comments are gathered from each unique declaration, -// deduplicated, and joined with line breaks. Like Strada, it does not resolve aliases — -// consumers resolve aliases themselves (via getAliasedSymbol) and re-query if desired. -func (l *LanguageService) GetSymbolDocumentationComment(c *checker.Checker, symbol *ast.Symbol) string { - if symbol == nil { - return "" - } - var parts []string - var seen collections.Set[*ast.Node] - for _, decl := range symbol.Declarations { - if decl == nil { - continue - } - if !seen.AddIfAbsent(decl) { - continue - } - if doc := l.getDocumentationFromDeclaration(c, symbol, decl, decl, lsproto.MarkupKindPlainText, true /*commentOnly*/); doc != "" && !slices.Contains(parts, doc) { - parts = append(parts, doc) - } - } - return strings.Join(parts, "\n") -} - -// GetSymbolJSDocTags collects a symbol's JSDoc tags. It backs the API's Symbol.getJsDocTags -// and mirrors Strada's getJsDocTagsFromDeclarations, except each tag's text is rendered as a -// plain string rather than SymbolDisplayPart[]. Tags with no text have an empty Text field. -func (l *LanguageService) GetSymbolJSDocTags(symbol *ast.Symbol) []JSDocTagInfo { - if symbol == nil { - return nil - } - var infos []JSDocTagInfo - var seen collections.Set[*ast.Node] - for _, decl := range symbol.Declarations { - if decl == nil { - continue - } - if !seen.AddIfAbsent(decl) { - continue - } - tags := declarationJSDocTags(decl) - // Skip comments containing @typedef/@callback since they're not associated with a - // particular declaration, unless they also carry @param/@return (treated as local docs). - hasTypedef := core.Some(tags, func(t *ast.Node) bool { - return t.Kind == ast.KindJSDocTypedefTag || t.Kind == ast.KindJSDocCallbackTag - }) - hasParamOrReturn := core.Some(tags, func(t *ast.Node) bool { - return t.Kind == ast.KindJSDocParameterTag || t.Kind == ast.KindJSDocReturnTag - }) - if hasTypedef && !hasParamOrReturn { - continue - } - for _, tag := range tags { - infos = append(infos, JSDocTagInfo{Name: tag.TagName().Text(), Text: getJSDocTagText(tag)}) - } - } - return infos -} - -// declarationJSDocTags returns the JSDoc tags associated with a declaration, walking the -// JSDoc comment location chain like the checker's getAllJSDocTags. -func declarationJSDocTags(node *ast.Node) []*ast.Node { - if node.Flags&ast.NodeFlagsJSDoc == 0 { - for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { - jsdocs := current.JSDoc(nil) - if len(jsdocs) == 0 { - continue - } - lastJSDoc := jsdocs[len(jsdocs)-1].AsJSDoc() - if lastJSDoc.Tags != nil { - return lastJSDoc.Tags.Nodes - } - } - } - return nil -} - -// getJSDocTagText renders the text of a single JSDoc tag as a plain string, mirroring -// Strada's getCommentDisplayParts collapsed from SymbolDisplayPart[] to a string. -func getJSDocTagText(tag *ast.Node) string { - comment := scanner.GetTextOfJSDocComment(tag.CommentList()) - addComment := func(s string) string { - if comment == "" { - return s - } - return s + " " + comment - } - switch tag.Kind { - case ast.KindJSDocThrowsTag: - if te := tag.AsJSDocThrowsTag().TypeExpression; te != nil { - return addComment(scanner.GetTextOfNode(te)) - } - return comment - case ast.KindJSDocImplementsTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocImplementsTag().ClassName)) - case ast.KindJSDocAugmentsTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocAugmentsTag().ClassName)) - case ast.KindJSDocTemplateTag: - templateTag := tag.AsJSDocTemplateTag() - var b strings.Builder - if templateTag.Constraint != nil { - b.WriteString(scanner.GetTextOfNode(templateTag.Constraint)) - } - if templateTag.TypeParameters != nil { - for i, tp := range templateTag.TypeParameters.Nodes { - if i == 0 && b.Len() != 0 { - b.WriteString(" ") - } - if i != 0 { - b.WriteString(", ") - } - b.WriteString(scanner.GetTextOfNode(tp)) - } - } - if comment != "" { - if b.Len() != 0 { - b.WriteString(" ") - } - b.WriteString(comment) - } - return b.String() - case ast.KindJSDocTypeTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocTypeTag().TypeExpression)) - case ast.KindJSDocSatisfiesTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocSatisfiesTag().TypeExpression)) - case ast.KindJSDocSeeTag: - if ne := tag.AsJSDocSeeTag().NameExpression; ne != nil { - return addComment(scanner.GetTextOfNode(ne)) - } - return comment - case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: - if name := tag.Name(); name != nil { - return addComment(scanner.GetTextOfNode(name)) - } - return comment - default: - return comment - } -} From df5d5b1d3c3ab2405ece9ea3f1ddfd817bc91912 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 13 Aug 2026 10:52:54 -0700 Subject: [PATCH 2/2] Move ls/jsdoc back and restore lost fidelity --- internal/api/session.go | 5 +- internal/jsdoc/jsdoc.go | 198 ---------------------- internal/ls/hover.go | 192 +++------------------ internal/ls/jsdoc.go | 311 +++++++++++++++++++++++++++++++++++ internal/ls/signaturehelp.go | 4 +- 5 files changed, 338 insertions(+), 372 deletions(-) delete mode 100644 internal/jsdoc/jsdoc.go create mode 100644 internal/ls/jsdoc.go diff --git a/internal/api/session.go b/internal/api/session.go index ceeab0e696b..5f5bfcfa8b7 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -18,7 +18,6 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/jsdoc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" @@ -3087,7 +3086,7 @@ func (s *Session) handleGetJSDocTags(ctx context.Context, params *CheckerSymbolP return nil, nil } - tags := jsdoc.GetSymbolTags(symbol) + tags := ls.GetSymbolJSDocTags(symbol) if len(tags) == 0 { return nil, nil } @@ -3114,7 +3113,7 @@ func (s *Session) handleGetDocumentationComment(ctx context.Context, params *Che return "", nil } - return jsdoc.GetSymbolDocumentationComment(symbol), nil + return ls.GetSymbolDocumentationComment(setup.checker, symbol), nil } // handleGetTypeArguments returns the type arguments of a type reference. diff --git a/internal/jsdoc/jsdoc.go b/internal/jsdoc/jsdoc.go deleted file mode 100644 index 2787f493a09..00000000000 --- a/internal/jsdoc/jsdoc.go +++ /dev/null @@ -1,198 +0,0 @@ -package jsdoc - -import ( - "slices" - "strings" - - "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/scanner" -) - -// TagInfo is a JSDoc tag with its text rendered as a plain string. -type TagInfo struct { - Name string - Text string -} - -// GetSymbolDocumentationComment renders a symbol's documentation comment as plain text. -func GetSymbolDocumentationComment(symbol *ast.Symbol) string { - if symbol == nil { - return "" - } - var parts []string - var seen collections.Set[*ast.Node] - for _, declaration := range symbol.Declarations { - if declaration == nil || !seen.AddIfAbsent(declaration) { - continue - } - for _, comment := range getJSDocComments(declaration) { - if shouldSkipComment(declaration, comment) { - continue - } - text := renderComments(comment.Comments()) - if text != "" && !slices.Contains(parts, text) { - parts = append(parts, text) - } - } - } - return strings.Join(parts, "\n") -} - -// GetSymbolTags collects a symbol's JSDoc tags with their text rendered as plain strings. -func GetSymbolTags(symbol *ast.Symbol) []TagInfo { - if symbol == nil { - return nil - } - var infos []TagInfo - var seen collections.Set[*ast.Node] - for _, declaration := range symbol.Declarations { - if declaration == nil || !seen.AddIfAbsent(declaration) { - continue - } - tags := declarationJSDocTags(declaration) - hasTypedef := core.Some(tags, func(tag *ast.Node) bool { - return tag.Kind == ast.KindJSDocTypedefTag || tag.Kind == ast.KindJSDocCallbackTag - }) - hasParamOrReturn := core.Some(tags, func(tag *ast.Node) bool { - return tag.Kind == ast.KindJSDocParameterTag || tag.Kind == ast.KindJSDocReturnTag - }) - if hasTypedef && !hasParamOrReturn { - continue - } - for _, tag := range tags { - infos = append(infos, TagInfo{Name: tag.TagName().Text(), Text: getTagText(tag)}) - } - } - return infos -} - -func getJSDocComments(node *ast.Node) []*ast.Node { - if node.Flags&ast.NodeFlagsJSDoc != 0 { - return []*ast.Node{node} - } - var comments []*ast.Node - for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { - comments = append(comments, current.JSDoc(nil)...) - } - return comments -} - -func shouldSkipComment(declaration *ast.Node, comment *ast.Node) bool { - if comment == nil || len(comment.Comments()) == 0 { - return true - } - if declaration.Kind == ast.KindJSDocTypedefTag || declaration.Kind == ast.KindJSDocCallbackTag || comment.Kind != ast.KindJSDoc { - return false - } - tags := comment.AsJSDoc().Tags - if tags == nil { - return false - } - hasTypedef := core.Some(tags.Nodes, func(tag *ast.Node) bool { - return tag.Kind == ast.KindJSDocTypedefTag || tag.Kind == ast.KindJSDocCallbackTag - }) - hasParamOrReturn := core.Some(tags.Nodes, func(tag *ast.Node) bool { - return tag.Kind == ast.KindJSDocParameterTag || tag.Kind == ast.KindJSDocReturnTag - }) - return hasTypedef && !hasParamOrReturn -} - -func renderComments(comments []*ast.Node) string { - var builder strings.Builder - for _, comment := range comments { - switch comment.Kind { - case ast.KindJSDocText: - builder.WriteString(comment.Text()) - case ast.KindJSDocLink, ast.KindJSDocLinkPlain, ast.KindJSDocLinkCode: - name := comment.Name() - text := strings.Trim(comment.Text(), " ") - if name == nil { - builder.WriteString(text) - } else if text == "" { - builder.WriteString(scanner.GetTextOfNode(name)) - } else { - builder.WriteString(strings.TrimLeft(strings.TrimPrefix(strings.TrimLeft(text, " "), "|"), " ")) - } - } - } - return builder.String() -} - -func declarationJSDocTags(node *ast.Node) []*ast.Node { - if node.Flags&ast.NodeFlagsJSDoc == 0 { - for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { - jsdocs := current.JSDoc(nil) - if len(jsdocs) == 0 { - continue - } - lastJSDoc := jsdocs[len(jsdocs)-1].AsJSDoc() - if lastJSDoc.Tags != nil { - return lastJSDoc.Tags.Nodes - } - } - } - return nil -} - -func getTagText(tag *ast.Node) string { - comment := scanner.GetTextOfJSDocComment(tag.CommentList()) - addComment := func(text string) string { - if comment == "" { - return text - } - return text + " " + comment - } - switch tag.Kind { - case ast.KindJSDocThrowsTag: - if typeExpression := tag.AsJSDocThrowsTag().TypeExpression; typeExpression != nil { - return addComment(scanner.GetTextOfNode(typeExpression)) - } - return comment - case ast.KindJSDocImplementsTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocImplementsTag().ClassName)) - case ast.KindJSDocAugmentsTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocAugmentsTag().ClassName)) - case ast.KindJSDocTemplateTag: - templateTag := tag.AsJSDocTemplateTag() - var builder strings.Builder - if templateTag.Constraint != nil { - builder.WriteString(scanner.GetTextOfNode(templateTag.Constraint)) - } - if templateTag.TypeParameters != nil { - for i, typeParameter := range templateTag.TypeParameters.Nodes { - if i == 0 && builder.Len() != 0 { - builder.WriteString(" ") - } - if i != 0 { - builder.WriteString(", ") - } - builder.WriteString(scanner.GetTextOfNode(typeParameter)) - } - } - if comment != "" { - if builder.Len() != 0 { - builder.WriteString(" ") - } - builder.WriteString(comment) - } - return builder.String() - case ast.KindJSDocTypeTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocTypeTag().TypeExpression)) - case ast.KindJSDocSatisfiesTag: - return addComment(scanner.GetTextOfNode(tag.AsJSDocSatisfiesTag().TypeExpression)) - case ast.KindJSDocSeeTag: - if nameExpression := tag.AsJSDocSeeTag().NameExpression; nameExpression != nil { - return addComment(scanner.GetTextOfNode(nameExpression)) - } - return comment - case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: - if name := tag.Name(); name != nil { - return addComment(scanner.GetTextOfNode(name)) - } - return comment - default: - return comment - } -} diff --git a/internal/ls/hover.go b/internal/ls/hover.go index 304055628c7..bbd79b42c86 100644 --- a/internal/ls/hover.go +++ b/internal/ls/hover.go @@ -122,7 +122,7 @@ func (l *LanguageService) getQuickInfoAndDocumentationForSymbol(c *checker.Check } quickInfoRuns := info.displayParts.GetRuns() - documentation := l.getDocumentationForSymbol(c, symbol, node, info.declaration, contentFormat, false /*commentOnly*/) + documentation := getDocumentationForSymbol(l.getMappedLocation, c, symbol, node, info.declaration, contentFormat, false /*commentOnly*/) // VS's rich hover (_vs_rawContent) renders documentation as plain colorized text with no Markdown // parser, so it can't use the tag section (@param/@returns/@example/@see, etc.) that @@ -134,7 +134,7 @@ func (l *LanguageService) getQuickInfoAndDocumentationForSymbol(c *checker.Check // comment-only, plain-text documentation for the VS path instead of reusing `documentation`. var vsDocumentation string if vsCapability { - vsDocumentation = l.getDocumentationForSymbol(c, symbol, node, info.declaration, lsproto.MarkupKindPlainText, true /*commentOnly*/) + vsDocumentation = getDocumentationForSymbol(l.getMappedLocation, c, symbol, node, info.declaration, lsproto.MarkupKindPlainText, true /*commentOnly*/) } return quickInfo, documentation, vsDocumentation, quickInfoRuns @@ -143,21 +143,21 @@ func (l *LanguageService) getQuickInfoAndDocumentationForSymbol(c *checker.Check // getDocumentationForSymbol tries each documentation source in turn (call-signature documentation, // declaration JSDoc, alias target JSDoc) and returns the first non-empty result, formatted for // contentFormat. commentOnly restricts the result to the JSDoc summary, excluding the @tag section. -func (l *LanguageService) getDocumentationForSymbol(c *checker.Checker, symbol *ast.Symbol, node *ast.Node, declaration *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { - documentation := l.documentationFromSignature(c, symbol, getCallOrNewExpression(node), node, contentFormat, commentOnly) +func getDocumentationForSymbol(getMappedLocation func(string, core.TextRange) lsproto.Location, c *checker.Checker, symbol *ast.Symbol, node *ast.Node, declaration *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { + documentation := documentationFromSignature(getMappedLocation, c, symbol, getCallOrNewExpression(node), node, contentFormat, commentOnly) if documentation != "" { return documentation } - documentation = l.getDocumentationFromDeclaration(c, symbol, declaration, node, contentFormat, commentOnly) + documentation = getDocumentationFromDeclaration(getMappedLocation, c, symbol, declaration, node, contentFormat, commentOnly) if documentation != "" { return documentation } - return l.documentationFromAlias(c, symbol, node, contentFormat, commentOnly) + return documentationFromAlias(getMappedLocation, c, symbol, node, contentFormat, commentOnly) } -func (l *LanguageService) documentationFromSignature(c *checker.Checker, symbol *ast.Symbol, node *ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { +func documentationFromSignature(getMappedLocation func(string, core.TextRange) lsproto.Location, c *checker.Checker, symbol *ast.Symbol, node *ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { if node == nil { return "" } @@ -170,12 +170,12 @@ func (l *LanguageService) documentationFromSignature(c *checker.Checker, symbol return "" } if ast.IsCallSignatureDeclaration(declaration) || ast.IsConstructSignatureDeclaration(declaration) { - return l.getDocumentationFromDeclaration(c, symbol, declaration, location, contentFormat, commentOnly) + return getDocumentationFromDeclaration(getMappedLocation, c, symbol, declaration, location, contentFormat, commentOnly) } return "" } -func (l *LanguageService) documentationFromAlias(c *checker.Checker, symbol *ast.Symbol, node *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { +func documentationFromAlias(getMappedLocation func(string, core.TextRange) lsproto.Location, c *checker.Checker, symbol *ast.Symbol, node *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { if symbol == nil || symbol.Flags&ast.SymbolFlagsAlias == 0 { return "" } @@ -196,7 +196,7 @@ func (l *LanguageService) documentationFromAlias(c *checker.Checker, symbol *ast continue } - if documentation := l.getDocumentationFromDeclaration(c, candidate, aliasedDeclaration, node, contentFormat, commentOnly); documentation != "" { + if documentation := getDocumentationFromDeclaration(getMappedLocation, c, candidate, aliasedDeclaration, node, contentFormat, commentOnly); documentation != "" { return documentation } } @@ -204,14 +204,14 @@ func (l *LanguageService) documentationFromAlias(c *checker.Checker, symbol *ast return "" } -func (l *LanguageService) getDocumentationFromDeclaration(c *checker.Checker, symbol *ast.Symbol, declaration *ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { +func getDocumentationFromDeclaration(getMappedLocation func(string, core.TextRange) lsproto.Location, c *checker.Checker, symbol *ast.Symbol, declaration *ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { if declaration == nil { return "" } isMarkdown := contentFormat == lsproto.MarkupKindMarkdown var b strings.Builder if jsdoc := getJSDocOrTag(c, declaration, &collections.Set[*ast.Symbol]{}); jsdoc != nil && !(declaration.Flags&ast.NodeFlagsReparsed == 0 && containsTypedefTag(jsdoc)) { - l.writeComments(&b, c, jsdoc.Comments(), isMarkdown) + writeComments(getMappedLocation, &b, c, jsdoc.Comments(), isMarkdown) if jsdoc.Kind == ast.KindJSDoc && !commentOnly { if tags := jsdoc.AsJSDoc().Tags; tags != nil { for _, tag := range tags.Nodes { @@ -268,24 +268,24 @@ func (l *LanguageService) getDocumentationFromDeclaration(c *checker.Checker, sy } } else if tag.Kind == ast.KindJSDocSeeTag && tag.AsJSDocSeeTag().NameExpression != nil { b.WriteString(" — ") - l.writeNameLink(&b, c, tag.AsJSDocSeeTag().NameExpression.Name(), "", false /*quote*/, isMarkdown) + writeNameLink(getMappedLocation, &b, c, tag.AsJSDocSeeTag().NameExpression.Name(), "", false /*quote*/, isMarkdown) if len(comments) != 0 { b.WriteString(" ") - l.writeComments(&b, c, comments, isMarkdown) + writeComments(getMappedLocation, &b, c, comments, isMarkdown) } } else if tag.Kind == ast.KindJSDocThrowsTag && tag.AsJSDocThrowsTag().TypeExpression != nil { b.WriteString(" — ") b.WriteString(scanner.GetTextOfNode(tag.AsJSDocThrowsTag().TypeExpression)) if len(comments) != 0 { b.WriteString(" ") - l.writeComments(&b, c, comments, isMarkdown) + writeComments(getMappedLocation, &b, c, comments, isMarkdown) } } else if len(comments) != 0 { b.WriteString(" ") if comments[0].Kind != ast.KindJSDocText || !strings.HasPrefix(comments[0].Text(), "-") { b.WriteString("— ") } - l.writeComments(&b, c, comments, isMarkdown) + writeComments(getMappedLocation, &b, c, comments, isMarkdown) } } } @@ -898,152 +898,6 @@ func containsTypedefTag(jsdoc *ast.Node) bool { return false } -func getJSDoc(node *ast.Node) *ast.Node { - return core.LastOrNil(node.JSDoc(nil)) -} - -func getJSDocOrTag(c *checker.Checker, node *ast.Node, seenSymbols *collections.Set[*ast.Symbol]) *ast.Node { - if node == nil { - return nil - } - if jsdoc := getJSDoc(node); jsdoc != nil { - return jsdoc - } - switch { - case ast.IsParameterDeclaration(node): - name := node.Name() - if ast.IsBindingPattern(name) { - // For binding patterns, match JSDoc @param tags by position rather than by name - return getJSDocParameterTagByPosition(c, node) - } - return getMatchingJSDocTag(c, node.Parent, name.Text(), isMatchingParameterTag, seenSymbols) - case ast.IsTypeParameterDeclaration(node): - return getMatchingJSDocTag(c, node.Parent, node.Name().Text(), isMatchingTemplateTag, seenSymbols) - case ast.IsVariableDeclaration(node) && ast.IsVariableDeclarationList(node.Parent) && core.FirstOrNil(node.Parent.AsVariableDeclarationList().Declarations.Nodes) == node: - return getJSDocOrTag(c, node.Parent.Parent, seenSymbols) - case (ast.IsFunctionExpressionOrArrowFunction(node) || ast.IsClassExpression(node)) && - (ast.IsVariableDeclaration(node.Parent) || ast.IsPropertyDeclaration(node.Parent) || ast.IsPropertyAssignment(node.Parent)) && node.Parent.Initializer() == node: - return getJSDocOrTag(c, node.Parent, seenSymbols) - case ast.IsBindingElement(node) && ast.IsObjectBindingPattern(node.Parent): - if name := node.PropertyNameOrName(); ast.IsIdentifier(name) { - if objectType := c.GetTypeAtLocation(node.Parent); objectType != nil { - if prop := c.GetPropertyOfType(objectType, name.Text()); prop != nil { - for _, d := range prop.Declarations { - if jsdoc := getJSDoc(d); jsdoc != nil { - return jsdoc - } - } - } - } - } - } - if symbol := node.Symbol(); symbol != nil && node.Parent != nil { - if ast.IsFunctionDeclaration(node) || ast.IsMethodDeclaration(node) || ast.IsMethodSignatureDeclaration(node) || ast.IsConstructorDeclaration(node) || ast.IsConstructSignatureDeclaration(node) { - firstSignature := core.Find(symbol.Declarations, ast.IsFunctionLike) - if firstSignature != nil && node != firstSignature { - if jsDoc := getJSDocOrTag(c, firstSignature, seenSymbols); jsDoc != nil { - return jsDoc - } - } - } - if ast.IsClassOrInterfaceLike(node.Parent) { - isStatic := ast.HasStaticModifier(node) - classType := c.GetDeclaredTypeOfSymbol(node.Parent.Symbol()) - if isStatic { - // For static members, use the checker's base constructor type resolution. - // This correctly handles intersection constructor types from mixins - // (e.g., typeof MixinClass & T) by preserving the full intersection. - staticBaseType := c.GetApparentType(c.GetBaseConstructorTypeOfClass(classType)) - if prop := c.GetPropertyOfType(staticBaseType, symbol.Name); prop != nil && prop.ValueDeclaration != nil && seenSymbols.AddIfAbsent(prop) { - if jsDoc := getJSDocOrTag(c, prop.ValueDeclaration, seenSymbols); jsDoc != nil { - return jsDoc - } - } - } else { - for _, baseType := range c.GetBaseTypes(classType) { - if prop := c.GetPropertyOfType(baseType, symbol.Name); prop != nil && prop.ValueDeclaration != nil && seenSymbols.AddIfAbsent(prop) { - if jsDoc := getJSDocOrTag(c, prop.ValueDeclaration, seenSymbols); jsDoc != nil { - return jsDoc - } - } - } - } - } - } - return nil -} - -func getMatchingJSDocTag(c *checker.Checker, node *ast.Node, name string, match func(*ast.Node, string) bool, seenSymbols *collections.Set[*ast.Symbol]) *ast.Node { - if jsdoc := getJSDocOrTag(c, node, seenSymbols); jsdoc != nil && jsdoc.Kind == ast.KindJSDoc { - if tags := jsdoc.AsJSDoc().Tags; tags != nil { - for _, tag := range tags.Nodes { - if match(tag, name) { - return tag - } - } - } - } - return nil -} - -// getJSDocParameterTagByPosition finds a JSDoc @param tag for a binding pattern parameter by position. -// Since binding patterns don't have a simple name, we match the @param tag at the same index as the parameter. -func getJSDocParameterTagByPosition(c *checker.Checker, param *ast.Node) *ast.Node { - parent := param.Parent - if parent == nil { - return nil - } - - // Find the parameter's index in the parent's parameters list - params := parent.Parameters() - paramIndex := -1 - for i, p := range params { - if p.AsNode() == param { - paramIndex = i - break - } - } - if paramIndex < 0 { - return nil - } - - // Get the JSDoc for the parent function/method - jsdoc := getJSDocOrTag(c, parent, &collections.Set[*ast.Symbol]{}) - if jsdoc == nil || jsdoc.Kind != ast.KindJSDoc { - return nil - } - - // Collect all @param tags in order - tags := jsdoc.AsJSDoc().Tags - if tags == nil { - return nil - } - - paramTagIndex := 0 - for _, tag := range tags.Nodes { - if tag.Kind == ast.KindJSDocParameterTag { - if paramTagIndex == paramIndex { - return tag - } - paramTagIndex++ - } - } - return nil -} - -func isMatchingParameterTag(tag *ast.Node, name string) bool { - return tag.Kind == ast.KindJSDocParameterTag && isNodeWithName(tag, name) -} - -func isMatchingTemplateTag(tag *ast.Node, name string) bool { - return tag.Kind == ast.KindJSDocTemplateTag && core.Some(tag.TypeParameters(), func(tp *ast.Node) bool { return isNodeWithName(tp, name) }) -} - -func isNodeWithName(node *ast.Node, name string) bool { - nodeName := node.Name() - return ast.IsIdentifier(nodeName) && nodeName.Text() == name -} - func writeCode(b *strings.Builder, lang string, code string) { if code == "" { return @@ -1065,20 +919,20 @@ func writeCode(b *strings.Builder, lang string, code string) { b.WriteByte('\n') } -func (l *LanguageService) writeComments(b *strings.Builder, c *checker.Checker, comments []*ast.Node, isMarkdown bool) { +func writeComments(getMappedLocation func(string, core.TextRange) lsproto.Location, b *strings.Builder, c *checker.Checker, comments []*ast.Node, isMarkdown bool) { for _, comment := range comments { switch comment.Kind { case ast.KindJSDocText: b.WriteString(comment.Text()) case ast.KindJSDocLink, ast.KindJSDocLinkPlain: - l.writeJSDocLink(b, c, comment, false /*quote*/, isMarkdown) + writeJSDocLink(getMappedLocation, b, c, comment, false /*quote*/, isMarkdown) case ast.KindJSDocLinkCode: - l.writeJSDocLink(b, c, comment, true /*quote*/, isMarkdown) + writeJSDocLink(getMappedLocation, b, c, comment, true /*quote*/, isMarkdown) } } } -func (l *LanguageService) writeJSDocLink(b *strings.Builder, c *checker.Checker, link *ast.Node, quote bool, isMarkdown bool) { +func writeJSDocLink(getMappedLocation func(string, core.TextRange) lsproto.Location, b *strings.Builder, c *checker.Checker, link *ast.Node, quote bool, isMarkdown bool) { name := link.Name() text := strings.Trim(link.Text(), " ") if name == nil { @@ -1107,16 +961,16 @@ func (l *LanguageService) writeJSDocLink(b *strings.Builder, c *checker.Checker, } return } - l.writeNameLink(b, c, name, text, quote, isMarkdown) + writeNameLink(getMappedLocation, b, c, name, text, quote, isMarkdown) } -func (l *LanguageService) writeNameLink(b *strings.Builder, c *checker.Checker, name *ast.Node, text string, quote bool, isMarkdown bool) { +func writeNameLink(getMappedLocation func(string, core.TextRange) lsproto.Location, b *strings.Builder, c *checker.Checker, name *ast.Node, text string, quote bool, isMarkdown bool) { declarations := getDeclarationsFromLocation(c, name) if len(declarations) != 0 { declaration := declarations[0] file := ast.GetSourceFileOfNode(declaration) node := core.OrElse(ast.GetNameOfDeclaration(declaration), declaration) - loc := l.getMappedLocation(file.FileName(), createRangeFromNode(node, file)) + loc := getMappedLocation(file.FileName(), createRangeFromNode(node, file)) prefixLen := core.IfElse(strings.HasPrefix(text, "()"), 2, 0) linkText := trimCommentPrefix(text[prefixLen:]) if linkText == "" { diff --git a/internal/ls/jsdoc.go b/internal/ls/jsdoc.go new file mode 100644 index 00000000000..18d777594a1 --- /dev/null +++ b/internal/ls/jsdoc.go @@ -0,0 +1,311 @@ +package ls + +import ( + "slices" + "strings" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/checker" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/scanner" +) + +// JSDocTagInfo mirrors Strada's `JSDocTagInfo`, but renders the tag's text as a +// plain string instead of `SymbolDisplayPart[]`. +type JSDocTagInfo struct { + Name string + Text string +} + +// GetSymbolDocumentationComment renders a symbol's documentation comment as plain text. +// It backs the API's Symbol.getDocumentationComment and mirrors Strada's +// getJsDocCommentsFromDeclarations: comments are gathered from each unique declaration, +// deduplicated, and joined with line breaks. Like Strada, it does not resolve aliases — +// consumers resolve aliases themselves (via getAliasedSymbol) and re-query if desired. +func GetSymbolDocumentationComment(c *checker.Checker, symbol *ast.Symbol) string { + if symbol == nil { + return "" + } + var parts []string + var seen collections.Set[*ast.Node] + for _, decl := range symbol.Declarations { + if decl == nil { + continue + } + if !seen.AddIfAbsent(decl) { + continue + } + if doc := getDocumentationFromDeclaration(noMappedLocation, c, symbol, decl, decl, lsproto.MarkupKindPlainText, true /*commentOnly*/); doc != "" && !slices.Contains(parts, doc) { + parts = append(parts, doc) + } + } + return strings.Join(parts, "\n") +} + +// GetSymbolJSDocTags collects a symbol's JSDoc tags. It backs the API's Symbol.getJsDocTags +// and mirrors Strada's getJsDocTagsFromDeclarations, except each tag's text is rendered as a +// plain string rather than SymbolDisplayPart[]. Tags with no text have an empty Text field. +func GetSymbolJSDocTags(symbol *ast.Symbol) []JSDocTagInfo { + if symbol == nil { + return nil + } + var infos []JSDocTagInfo + var seen collections.Set[*ast.Node] + for _, decl := range symbol.Declarations { + if decl == nil { + continue + } + if !seen.AddIfAbsent(decl) { + continue + } + tags := declarationJSDocTags(decl) + // Skip comments containing @typedef/@callback since they're not associated with a + // particular declaration, unless they also carry @param/@return (treated as local docs). + hasTypedef := core.Some(tags, func(t *ast.Node) bool { + return t.Kind == ast.KindJSDocTypedefTag || t.Kind == ast.KindJSDocCallbackTag + }) + hasParamOrReturn := core.Some(tags, func(t *ast.Node) bool { + return t.Kind == ast.KindJSDocParameterTag || t.Kind == ast.KindJSDocReturnTag + }) + if hasTypedef && !hasParamOrReturn { + continue + } + for _, tag := range tags { + infos = append(infos, JSDocTagInfo{Name: tag.TagName().Text(), Text: getJSDocTagText(tag)}) + } + } + return infos +} + +// declarationJSDocTags returns the JSDoc tags associated with a declaration, walking the +// JSDoc comment location chain like the checker's getAllJSDocTags. +func declarationJSDocTags(node *ast.Node) []*ast.Node { + if node.Flags&ast.NodeFlagsJSDoc == 0 { + for current := node; current != nil; current = ast.GetNextJSDocCommentLocation(current) { + jsdocs := current.JSDoc(nil) + if len(jsdocs) == 0 { + continue + } + lastJSDoc := jsdocs[len(jsdocs)-1].AsJSDoc() + if lastJSDoc.Tags != nil { + return lastJSDoc.Tags.Nodes + } + } + } + return nil +} + +// getJSDocTagText renders the text of a single JSDoc tag as a plain string, mirroring +// Strada's getCommentDisplayParts collapsed from SymbolDisplayPart[] to a string. +func getJSDocTagText(tag *ast.Node) string { + comment := scanner.GetTextOfJSDocComment(tag.CommentList()) + addComment := func(s string) string { + if comment == "" { + return s + } + return s + " " + comment + } + switch tag.Kind { + case ast.KindJSDocThrowsTag: + if te := tag.AsJSDocThrowsTag().TypeExpression; te != nil { + return addComment(scanner.GetTextOfNode(te)) + } + return comment + case ast.KindJSDocImplementsTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocImplementsTag().ClassName)) + case ast.KindJSDocAugmentsTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocAugmentsTag().ClassName)) + case ast.KindJSDocTemplateTag: + templateTag := tag.AsJSDocTemplateTag() + var b strings.Builder + if templateTag.Constraint != nil { + b.WriteString(scanner.GetTextOfNode(templateTag.Constraint)) + } + if templateTag.TypeParameters != nil { + for i, tp := range templateTag.TypeParameters.Nodes { + if i == 0 && b.Len() != 0 { + b.WriteString(" ") + } + if i != 0 { + b.WriteString(", ") + } + b.WriteString(scanner.GetTextOfNode(tp)) + } + } + if comment != "" { + if b.Len() != 0 { + b.WriteString(" ") + } + b.WriteString(comment) + } + return b.String() + case ast.KindJSDocTypeTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocTypeTag().TypeExpression)) + case ast.KindJSDocSatisfiesTag: + return addComment(scanner.GetTextOfNode(tag.AsJSDocSatisfiesTag().TypeExpression)) + case ast.KindJSDocSeeTag: + if ne := tag.AsJSDocSeeTag().NameExpression; ne != nil { + return addComment(scanner.GetTextOfNode(ne)) + } + return comment + case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: + if name := tag.Name(); name != nil { + return addComment(scanner.GetTextOfNode(name)) + } + return comment + default: + return comment + } +} + +func getJSDoc(node *ast.Node) *ast.Node { + return core.LastOrNil(node.JSDoc(nil)) +} + +func getJSDocOrTag(c *checker.Checker, node *ast.Node, seenSymbols *collections.Set[*ast.Symbol]) *ast.Node { + if node == nil { + return nil + } + if jsdoc := getJSDoc(node); jsdoc != nil { + return jsdoc + } + switch { + case ast.IsParameterDeclaration(node): + name := node.Name() + if ast.IsBindingPattern(name) { + // For binding patterns, match JSDoc @param tags by position rather than by name + return getJSDocParameterTagByPosition(c, node) + } + return getMatchingJSDocTag(c, node.Parent, name.Text(), isMatchingParameterTag, seenSymbols) + case ast.IsTypeParameterDeclaration(node): + return getMatchingJSDocTag(c, node.Parent, node.Name().Text(), isMatchingTemplateTag, seenSymbols) + case ast.IsVariableDeclaration(node) && ast.IsVariableDeclarationList(node.Parent) && core.FirstOrNil(node.Parent.AsVariableDeclarationList().Declarations.Nodes) == node: + return getJSDocOrTag(c, node.Parent.Parent, seenSymbols) + case (ast.IsFunctionExpressionOrArrowFunction(node) || ast.IsClassExpression(node)) && + (ast.IsVariableDeclaration(node.Parent) || ast.IsPropertyDeclaration(node.Parent) || ast.IsPropertyAssignment(node.Parent)) && node.Parent.Initializer() == node: + return getJSDocOrTag(c, node.Parent, seenSymbols) + case ast.IsBindingElement(node) && ast.IsObjectBindingPattern(node.Parent): + if name := node.PropertyNameOrName(); ast.IsIdentifier(name) { + if objectType := c.GetTypeAtLocation(node.Parent); objectType != nil { + if prop := c.GetPropertyOfType(objectType, name.Text()); prop != nil { + for _, d := range prop.Declarations { + if jsdoc := getJSDoc(d); jsdoc != nil { + return jsdoc + } + } + } + } + } + } + if symbol := node.Symbol(); symbol != nil && node.Parent != nil { + if ast.IsFunctionDeclaration(node) || ast.IsMethodDeclaration(node) || ast.IsMethodSignatureDeclaration(node) || ast.IsConstructorDeclaration(node) || ast.IsConstructSignatureDeclaration(node) { + firstSignature := core.Find(symbol.Declarations, ast.IsFunctionLike) + if firstSignature != nil && node != firstSignature { + if jsDoc := getJSDocOrTag(c, firstSignature, seenSymbols); jsDoc != nil { + return jsDoc + } + } + } + if ast.IsClassOrInterfaceLike(node.Parent) { + isStatic := ast.HasStaticModifier(node) + classType := c.GetDeclaredTypeOfSymbol(node.Parent.Symbol()) + if isStatic { + // For static members, use the checker's base constructor type resolution. + // This correctly handles intersection constructor types from mixins + // (e.g., typeof MixinClass & T) by preserving the full intersection. + staticBaseType := c.GetApparentType(c.GetBaseConstructorTypeOfClass(classType)) + if prop := c.GetPropertyOfType(staticBaseType, symbol.Name); prop != nil && prop.ValueDeclaration != nil && seenSymbols.AddIfAbsent(prop) { + if jsDoc := getJSDocOrTag(c, prop.ValueDeclaration, seenSymbols); jsDoc != nil { + return jsDoc + } + } + } else { + for _, baseType := range c.GetBaseTypes(classType) { + if prop := c.GetPropertyOfType(baseType, symbol.Name); prop != nil && prop.ValueDeclaration != nil && seenSymbols.AddIfAbsent(prop) { + if jsDoc := getJSDocOrTag(c, prop.ValueDeclaration, seenSymbols); jsDoc != nil { + return jsDoc + } + } + } + } + } + } + return nil +} + +func getMatchingJSDocTag(c *checker.Checker, node *ast.Node, name string, match func(*ast.Node, string) bool, seenSymbols *collections.Set[*ast.Symbol]) *ast.Node { + if jsdoc := getJSDocOrTag(c, node, seenSymbols); jsdoc != nil && jsdoc.Kind == ast.KindJSDoc { + if tags := jsdoc.AsJSDoc().Tags; tags != nil { + for _, tag := range tags.Nodes { + if match(tag, name) { + return tag + } + } + } + } + return nil +} + +// getJSDocParameterTagByPosition finds a JSDoc @param tag for a binding pattern parameter by position. +// Since binding patterns don't have a simple name, we match the @param tag at the same index as the parameter. +func getJSDocParameterTagByPosition(c *checker.Checker, param *ast.Node) *ast.Node { + parent := param.Parent + if parent == nil { + return nil + } + + // Find the parameter's index in the parent's parameters list + params := parent.Parameters() + paramIndex := -1 + for i, p := range params { + if p.AsNode() == param { + paramIndex = i + break + } + } + if paramIndex < 0 { + return nil + } + + // Get the JSDoc for the parent function/method + jsdoc := getJSDocOrTag(c, parent, &collections.Set[*ast.Symbol]{}) + if jsdoc == nil || jsdoc.Kind != ast.KindJSDoc { + return nil + } + + // Collect all @param tags in order + tags := jsdoc.AsJSDoc().Tags + if tags == nil { + return nil + } + + paramTagIndex := 0 + for _, tag := range tags.Nodes { + if tag.Kind == ast.KindJSDocParameterTag { + if paramTagIndex == paramIndex { + return tag + } + paramTagIndex++ + } + } + return nil +} + +func isMatchingParameterTag(tag *ast.Node, name string) bool { + return tag.Kind == ast.KindJSDocParameterTag && isNodeWithName(tag, name) +} + +func isMatchingTemplateTag(tag *ast.Node, name string) bool { + return tag.Kind == ast.KindJSDocTemplateTag && core.Some(tag.TypeParameters(), func(tp *ast.Node) bool { return isNodeWithName(tp, name) }) +} + +func isNodeWithName(node *ast.Node, name string) bool { + nodeName := node.Name() + return ast.IsIdentifier(nodeName) && nodeName.Text() == name +} + +func noMappedLocation(string, core.TextRange) lsproto.Location { + return lsproto.Location{} +} diff --git a/internal/ls/signaturehelp.go b/internal/ls/signaturehelp.go index c837656666e..ff8070b651f 100644 --- a/internal/ls/signaturehelp.go +++ b/internal/ls/signaturehelp.go @@ -441,7 +441,7 @@ func (l *LanguageService) getSignatureHelpItem(candidate *checker.Signature, isT // Generate documentation from the signature's declaration var documentation *string if declaration := candidate.Declaration(); declaration != nil { - doc := l.getDocumentationFromDeclaration(c, nil, declaration, nil, docFormat, true /*commentOnly*/) + doc := getDocumentationFromDeclaration(l.getMappedLocation, c, nil, declaration, nil, docFormat, true /*commentOnly*/) if doc != "" { documentation = &doc } @@ -653,7 +653,7 @@ func (l *LanguageService) createSignatureHelpParameterFromLabel(parameter *ast.S isRest := parameter.CheckFlags&ast.CheckFlagsRestParameter != 0 var documentation *lsproto.StringOrMarkupContent if parameter.ValueDeclaration != nil { - doc := l.getDocumentationFromDeclaration(c, nil, parameter.ValueDeclaration, nil, docFormat, true /*commentOnly*/) + doc := getDocumentationFromDeclaration(l.getMappedLocation, c, nil, parameter.ValueDeclaration, nil, docFormat, true /*commentOnly*/) if doc != "" { documentation = &lsproto.StringOrMarkupContent{ MarkupContent: &lsproto.MarkupContent{