From 700d8df92d057f34b71e9d2057e31d4fd295080f Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 2 Aug 2025 16:33:47 +0200 Subject: [PATCH 1/4] feat: add support for typescript.tsserverRequest command Resolves #959 --- README.md | 30 +++++++++++++++++++ src/commands.ts | 2 ++ src/commands/tsserverRequests.ts | 51 ++++++++++++++++++++++++++++++++ src/lsp-server.test.ts | 34 ++++++++++++++++++++- src/lsp-server.ts | 9 ++++++ src/ts-client.ts | 24 +++++++++++++-- src/tsServer/server.ts | 6 ++-- src/typescriptService.ts | 8 +++++ 8 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/commands/tsserverRequests.ts diff --git a/README.md b/README.md index 55fcfdef..d126dcf2 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Maintained by a [community of contributors](https://github.com/typescript-langua - [Apply Refactoring](#apply-refactoring) - [Organize Imports](#organize-imports) - [Rename File](#rename-file) + - [Send Tsserver Command](#send-tsserver-command) - [Configure plugin](#configure-plugin) - [Code Lenses \(`textDocument/codeLens`\)](#code-lenses-textdocumentcodelens) - [Inlay hints \(`textDocument/inlayHint`\)](#inlay-hints-textdocumentinlayhint) @@ -200,6 +201,35 @@ Most of the time, you'll execute commands with arguments retrieved from another void ``` +#### Send Tsserver Command + +- Request: + ```ts + { + command: `typescript.tsserverRequest` + arguments: [ + string, // command + any, // command arguments in a format that the command expects + ExecuteInfo, // configuration object used for the tsserver request (see below) + ] + } + ``` +- Response: + ```ts + any + ``` + +The `ExecuteInfo` object is defined as follows: + +```ts +type ExecuteInfo = { + executionTarget?: number; // 0 - semantic server, 1 - syntax server; default: 0 + expectsResult?: boolean; // default: true + isAsync?: boolean; // default: false + lowPriority?: boolean; // default: true +}; +``` + #### Configure plugin - Request: diff --git a/src/commands.ts b/src/commands.ts index 6d31682f..c5605222 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,7 @@ import * as lsp from 'vscode-languageserver'; import { SourceDefinitionCommand } from './features/source-definition.js'; import { TypeScriptVersionSource } from './tsServer/versionProvider.js'; +import { TSServerRequestCommand } from './commands/tsserverRequests.js'; export const Commands = { APPLY_WORKSPACE_EDIT: '_typescript.applyWorkspaceEdit', @@ -20,6 +21,7 @@ export const Commands = { /** Commands below should be implemented by the client */ SELECT_REFACTORING: '_typescript.selectRefactoring', SOURCE_DEFINITION: SourceDefinitionCommand.id, + TS_SERVER_REQUEST: TSServerRequestCommand.id, }; type TypescriptVersionNotificationParams = { diff --git a/src/commands/tsserverRequests.ts b/src/commands/tsserverRequests.ts new file mode 100644 index 00000000..7eddb490 --- /dev/null +++ b/src/commands/tsserverRequests.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as lsp from 'vscode-languageserver'; +import type { TsClient } from '../ts-client.js'; +import { type ExecuteInfo, type TypeScriptRequestTypes } from '../typescriptService.js'; + +interface RequestArgs { + readonly file?: unknown; +} + +export class TSServerRequestCommand { + public static readonly id = 'typescript.tsserverRequest'; + + public static async execute( + client: TsClient, + command: keyof TypeScriptRequestTypes, + args?: any, + config?: ExecuteInfo, + token?: lsp.CancellationToken, + ): Promise { + if (args && typeof args === 'object' && !Array.isArray(args)) { + const requestArgs = args as RequestArgs; + const hasFile = typeof requestArgs.file === 'string'; + if (hasFile) { + const newArgs = { ...args }; + if (hasFile) { + const document = client.toOpenDocument(requestArgs.file); + if (document) { + newArgs.file = document.filepath; + } + } + args = newArgs; + } + } + + if (config && token && typeof config === 'object' && !Array.isArray(config)) { + config.token = token; + } + + return client.executeCustom(command, args, config); + } +} diff --git a/src/lsp-server.test.ts b/src/lsp-server.test.ts index 6d7fc4d5..cb3b9e24 100644 --- a/src/lsp-server.test.ts +++ b/src/lsp-server.test.ts @@ -11,8 +11,9 @@ import * as lsp from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { uri, createServer, position, lastPosition, filePath, positionAfter, readContents, TestLspServer, openDocumentAndWaitForDiagnostics, range, lastRange } from './test-utils.js'; import { Commands } from './commands.js'; -import { SemicolonPreference } from './ts-protocol.js'; +import { CommandTypes, SemicolonPreference } from './ts-protocol.js'; import { CodeActionKind } from './utils/types.js'; +import { ExecutionTarget } from './tsServer/server.js'; const diagnostics: Map = new Map(); @@ -1850,6 +1851,37 @@ describe('executeCommand', () => { ); }); + it('send custom tsserver command', async () => { + const fooUri = uri('foo.ts'); + const doc = { + uri: fooUri, + languageId: 'typescript', + version: 1, + text: 'export function fn(): void {}\nexport function newFn(): void {}', + }; + await openDocumentAndWaitForDiagnostics(server, doc); + const result = await server.executeCommand({ + command: Commands.TS_SERVER_REQUEST, + arguments: [ + CommandTypes.ProjectInfo, + { + file: filePath('foo.ts'), + needFileNameList: false, + }, + { + executionTarget: ExecutionTarget.Semantic, + expectsResult: true, + isAsync: false, + lowPriority: true, + }, + ], + }); + expect(result).toBeDefined(); + expect(result.body).toMatchObject({ + configFileName: filePath('tsconfig.json'), + }); + }); + it('go to source definition', async () => { // NOTE: This test needs to reference files that physically exist for the feature to work. const indexUri = uri('source-definition', 'index.ts'); diff --git a/src/lsp-server.ts b/src/lsp-server.ts index ef5907d2..84c23ff3 100644 --- a/src/lsp-server.ts +++ b/src/lsp-server.ts @@ -16,11 +16,13 @@ import { LspDocument } from './document.js'; import { asCompletionItems, asResolvedCompletionItem, CompletionContext, CompletionDataCache, getCompletionTriggerCharacter } from './completion.js'; import { asSignatureHelp, toTsTriggerReason } from './hover.js'; import { Commands, TypescriptVersionNotification } from './commands.js'; +import { TSServerRequestCommand } from './commands/tsserverRequests.js'; import { provideQuickFix } from './quickfix.js'; import { provideRefactors } from './refactor.js'; import { organizeImportsCommands, provideOrganizeImports } from './organize-imports.js'; import { CommandTypes, EventName, OrganizeImportsMode, TypeScriptInitializeParams, TypeScriptInitializationOptions, SupportedFeatures } from './ts-protocol.js'; import type { ts } from './ts-protocol.js'; +import { type TypeScriptRequestTypes, type ExecuteInfo } from './typescriptService.js'; import { collectDocumentSymbols, collectSymbolInformation } from './document-symbol.js'; import { fromProtocolCallHierarchyItem, fromProtocolCallHierarchyIncomingCall, fromProtocolCallHierarchyOutgoingCall } from './features/call-hierarchy.js'; import FileConfigurationManager from './features/fileConfigurationManager.js'; @@ -209,6 +211,7 @@ export class LspServer { Commands.ORGANIZE_IMPORTS, Commands.APPLY_RENAME_FILE, Commands.SOURCE_DEFINITION, + Commands.TS_SERVER_REQUEST, ], }, hoverProvider: true, @@ -934,6 +937,12 @@ export class LspServer { const [uri, position] = (params.arguments || []) as [lsp.DocumentUri?, lsp.Position?]; const reporter = await this.options.lspClient.createProgressReporter(token, workDoneProgress); return SourceDefinitionCommand.execute(uri, position, this.tsClient, this.options.lspClient, reporter, token); + } else if (params.command === Commands.TS_SERVER_REQUEST) { + const [command, args, config] = (params.arguments || []) as [keyof TypeScriptRequestTypes, unknown?, ExecuteInfo?]; + if (typeof command !== 'string') { + throw new Error(`"Command" argument must be a string, got: ${typeof command}`); + } + return TSServerRequestCommand.execute(this.tsClient, command, args, config, token); } else { this.logger.error(`Unknown command ${params.command}.`); } diff --git a/src/ts-client.ts b/src/ts-client.ts index 5b642d88..381e3657 100644 --- a/src/ts-client.ts +++ b/src/ts-client.ts @@ -21,7 +21,7 @@ import * as languageModeIds from './configuration/languageIds.js'; import { CommandTypes, EventName } from './ts-protocol.js'; import type { TypeScriptPlugin, ts } from './ts-protocol.js'; import type { ILogDirectoryProvider } from './tsServer/logDirectoryProvider.js'; -import { AsyncTsServerRequests, ClientCapabilities, ClientCapability, ExecConfig, NoResponseTsServerRequests, ITypeScriptServiceClient, ServerResponse, StandardTsServerRequests, TypeScriptRequestTypes } from './typescriptService.js'; +import { AsyncTsServerRequests, ClientCapabilities, ClientCapability, ExecConfig, NoResponseTsServerRequests, ITypeScriptServiceClient, ServerResponse, StandardTsServerRequests, TypeScriptRequestTypes, ExecuteInfo } from './typescriptService.js'; import { PluginManager } from './tsServer/plugins.js'; import type { ITypeScriptServer, TypeScriptServerExitEvent } from './tsServer/server.js'; import { TypeScriptServerError } from './tsServer/serverError.js'; @@ -530,7 +530,7 @@ export class TsClient implements ITypeScriptServiceClient { command: K, args: AsyncTsServerRequests[K][0], token: CancellationToken, - ): Promise> { + ): Promise> { return this.executeImpl(command, args, { isAsync: true, token, @@ -538,6 +538,24 @@ export class TsClient implements ITypeScriptServiceClient { })[0]!; } + // For use by TSServerRequestCommand. + public executeCustom( + command: K, + args: any, + executeInfo?: ExecuteInfo, + ): Promise> { + const updatedExecuteInfo: ExecuteInfo = { + expectsResult: true, + isAsync: false, + ...executeInfo, + }; + const executions = this.executeImpl(command, args, updatedExecuteInfo); + + return executions[0]!.catch(error => { + throw new ResponseError(1, (error as Error).message); + }); + } + public interruptGetErr(f: () => R): R { return this.documents.interruptGetErr(f); } @@ -558,7 +576,7 @@ export class TsClient implements ITypeScriptServiceClient { // return this._configuration; // } - private executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: { isAsync: boolean; token?: CancellationToken; expectsResult: boolean; lowPriority?: boolean; requireSemantic?: boolean; }): Array> | undefined> { + private executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: ExecuteInfo): Array> | undefined> { const serverState = this.serverState; if (serverState.type === ServerState.Type.Running) { return serverState.server.executeImpl(command, args, executeInfo); diff --git a/src/tsServer/server.ts b/src/tsServer/server.ts index 39fdf201..d48c6157 100644 --- a/src/tsServer/server.ts +++ b/src/tsServer/server.ts @@ -50,7 +50,7 @@ export interface ITypeScriptServer { * @return A list of all execute requests. If there are multiple entries, the first item is the primary * request while the rest are secondary ones. */ - executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: { isAsync: boolean; token?: CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget; }): Array> | undefined>; + executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: ExecuteInfo): Array> | undefined>; dispose(): void; } @@ -232,7 +232,7 @@ export class SingleTsServer implements ITypeScriptServer { } } - public executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: { isAsync: boolean; token?: CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget; }): Array> | undefined> { + public executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: ExecuteInfo): Array> | undefined> { const request = this._requestQueue.createRequest(command, args); const requestInfo: RequestItem = { request, @@ -556,7 +556,7 @@ export class SyntaxRoutingTsServer implements ITypeScriptServer { this.semanticServer.kill(); } - public executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: { isAsync: boolean; token?: CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget; }): Array> | undefined> { + public executeImpl(command: keyof TypeScriptRequestTypes, args: any, executeInfo: ExecuteInfo): Array> | undefined> { return this.router.execute(command, args, executeInfo); } } diff --git a/src/typescriptService.ts b/src/typescriptService.ts index 465409da..a37c2b0a 100644 --- a/src/typescriptService.ts +++ b/src/typescriptService.ts @@ -41,6 +41,14 @@ export type ExecConfig = { readonly executionTarget?: ExecutionTarget; }; +export type ExecuteInfo = { + readonly executionTarget?: ExecutionTarget; + readonly expectsResult: boolean; + readonly isAsync: boolean; + readonly lowPriority?: boolean; + token?: lsp.CancellationToken; +}; + export enum ClientCapability { /** * Basic syntax server. All clients should support this. From 916e6a4e84f99f7f6eccdeef6559b351afd4e865 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 2 Aug 2025 16:45:52 +0200 Subject: [PATCH 2/4] chore: fix test on windows --- src/lsp-server.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lsp-server.test.ts b/src/lsp-server.test.ts index cb3b9e24..b5548b6e 100644 --- a/src/lsp-server.test.ts +++ b/src/lsp-server.test.ts @@ -1878,7 +1878,8 @@ describe('executeCommand', () => { }); expect(result).toBeDefined(); expect(result.body).toMatchObject({ - configFileName: filePath('tsconfig.json'), + // tsserver returns non-native path separators on Windows. + configFileName: filePath('tsconfig.json').replace('\\', '/'), }); }); From 74bc78108eeccca97fc7b254e5557c65f76fd49b Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 2 Aug 2025 16:50:31 +0200 Subject: [PATCH 3/4] chore: global replace --- src/lsp-server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lsp-server.test.ts b/src/lsp-server.test.ts index b5548b6e..f87cd457 100644 --- a/src/lsp-server.test.ts +++ b/src/lsp-server.test.ts @@ -1879,7 +1879,7 @@ describe('executeCommand', () => { expect(result).toBeDefined(); expect(result.body).toMatchObject({ // tsserver returns non-native path separators on Windows. - configFileName: filePath('tsconfig.json').replace('\\', '/'), + configFileName: filePath('tsconfig.json').replace(/\\/g, '/'), }); }); From ffdc0c7ce297009150454d62fc31e9d90c7cab46 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Tue, 5 Aug 2025 13:05:43 +0200 Subject: [PATCH 4/4] type lint --- src/commands/tsserverRequests.ts | 4 +++- src/lsp-server.test.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/tsserverRequests.ts b/src/commands/tsserverRequests.ts index 7eddb490..3e1d443f 100644 --- a/src/commands/tsserverRequests.ts +++ b/src/commands/tsserverRequests.ts @@ -8,6 +8,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import * as lsp from 'vscode-languageserver'; import type { TsClient } from '../ts-client.js'; @@ -20,7 +21,7 @@ interface RequestArgs { export class TSServerRequestCommand { public static readonly id = 'typescript.tsserverRequest'; - public static async execute( + public static execute( client: TsClient, command: keyof TypeScriptRequestTypes, args?: any, @@ -35,6 +36,7 @@ export class TSServerRequestCommand { if (hasFile) { const document = client.toOpenDocument(requestArgs.file); if (document) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access newArgs.file = document.filepath; } } diff --git a/src/lsp-server.test.ts b/src/lsp-server.test.ts index 5c77b121..ce7caecc 100644 --- a/src/lsp-server.test.ts +++ b/src/lsp-server.test.ts @@ -1858,6 +1858,7 @@ describe('executeCommand', () => { text: 'export function fn(): void {}\nexport function newFn(): void {}', }; await openDocumentAndWaitForDiagnostics(server, doc); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const result = await server.executeCommand({ command: Commands.TS_SERVER_REQUEST, arguments: [ @@ -1875,6 +1876,7 @@ describe('executeCommand', () => { ], }); expect(result).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(result.body).toMatchObject({ // tsserver returns non-native path separators on Windows. configFileName: filePath('tsconfig.json').replace(/\\/g, '/'),