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..3e1d443f --- /dev/null +++ b/src/commands/tsserverRequests.ts @@ -0,0 +1,53 @@ +/* + * 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. + *--------------------------------------------------------------------------------------------*/ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +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 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) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + 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 fb726d98..ce7caecc 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(); @@ -1848,6 +1849,40 @@ 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); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 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(); + // 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, '/'), + }); + }); + 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 0fde3f9a..11047e56 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, { type WorkspaceConfiguration } 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, @@ -943,6 +946,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 bb494fe1..c2c04c58 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'; @@ -534,7 +534,7 @@ export class TsClient implements ITypeScriptServiceClient { command: K, args: AsyncTsServerRequests[K][0], token: CancellationToken, - ): Promise> { + ): Promise> { return this.executeImpl(command, args, { isAsync: true, token, @@ -542,6 +542,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); } @@ -562,7 +580,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 6d8405bc..45b0e66b 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; } @@ -236,7 +236,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, @@ -566,7 +566,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.