diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md index 7833e8b87..75c018ecf 100644 --- a/packages/cli/LSP_DEBUGGING_GUIDE.md +++ b/packages/cli/LSP_DEBUGGING_GUIDE.md @@ -24,6 +24,7 @@ LSP 功能通过设置系统配置,包含以下选项: - `lsp.excluded`: 排除的 LSP 服务器名称黑名单 在 settings.json 中的示例配置: + ```json { "lsp": { @@ -34,20 +35,26 @@ LSP 功能通过设置系统配置,包含以下选项: } ``` +也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。 + ## 3. NativeLspService 调试功能 `NativeLspService` 类包含几个调试功能: ### 3.1 控制台日志 + 服务向控制台输出状态消息: + - `LSP 服务器 ${name} 启动成功` - 服务器成功启动 - `LSP 服务器 ${name} 启动失败` - 服务器启动失败 - `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现 ### 3.2 错误处理 + 服务具有全面的错误处理和详细的错误消息 ### 3.3 状态跟踪 + 您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态 ## 4. 调试命令 @@ -62,7 +69,30 @@ qwen --debug --prompt "调试 LSP 功能" ## 5. 手动 LSP 服务器配置 -您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器: +您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。 +推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移: + +```json +{ + "languageServers": { + "pylsp": { + "command": "pylsp", + "args": [], + "languages": ["python"], + "transport": "stdio", + "settings": {}, + "workspaceFolder": null, + "startupTimeout": 10000, + "shutdownTimeout": 3000, + "restartOnCrash": true, + "maxRestarts": 3, + "trustRequired": true + } + } +} +``` + +旧格式示例: ```json { @@ -78,15 +108,18 @@ qwen --debug --prompt "调试 LSP 功能" ## 6. LSP 问题排查 ### 6.1 检查 LSP 服务器是否已安装 + - 对于 TypeScript/JavaScript: `typescript-language-server` -- 对于 Python: `pylsp` +- 对于 Python: `pylsp` - 对于 Go: `gopls` ### 6.2 验证工作区信任 + - LSP 服务器可能需要受信任的工作区才能启动 - 检查 `security.folderTrust.enabled` 设置 ### 6.3 查看日志 + - 查找以 `LSP 服务器` 开头的控制台消息 - 检查命令存在性和路径安全性问题 @@ -104,4 +137,4 @@ LSP 服务的启动遵循以下流程: - 使用 `--debug` 标志查看详细的启动过程 - 检查工作区是否受信任(影响 LSP 服务器启动) - 确认 LSP 服务器命令在系统 PATH 中可用 -- 使用 `getStatus()` 方法监控服务器运行状态 \ No newline at end of file +- 使用 `getStatus()` 方法监控服务器运行状态 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index cce32b209..1aaa521b8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -20,22 +20,24 @@ import { ExtensionStorage, type Extension } from './extension.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { NativeLspService } from '../services/lsp/NativeLspService.js'; -const mockDiscoverAndPrepare = vi.fn(); -const mockStartLsp = vi.fn(); -const mockDefinitions = vi.fn().mockResolvedValue([]); -const mockReferences = vi.fn().mockResolvedValue([]); -const mockWorkspaceSymbols = vi.fn().mockResolvedValue([]); -const nativeLspServiceMock = vi.fn().mockImplementation(() => ({ - discoverAndPrepare: mockDiscoverAndPrepare, - start: mockStartLsp, - definitions: mockDefinitions, - references: mockReferences, - workspaceSymbols: mockWorkspaceSymbols, -})); +const createNativeLspServiceInstance = () => ({ + discoverAndPrepare: vi.fn(), + start: vi.fn(), + definitions: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + workspaceSymbols: vi.fn().mockResolvedValue([]), +}); vi.mock('../services/lsp/NativeLspService.js', () => ({ - NativeLspService: nativeLspServiceMock, + NativeLspService: vi.fn().mockImplementation(() => ({ + discoverAndPrepare: vi.fn(), + start: vi.fn(), + definitions: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + workspaceSymbols: vi.fn().mockResolvedValue([]), + })), })); vi.mock('./trustedFolders.js', () => ({ @@ -44,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({ .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted })); +const nativeLspServiceMock = vi.mocked(NativeLspService); +const getLastLspInstance = () => { + const results = nativeLspServiceMock.mock.results; + if (results.length === 0) { + return undefined; + } + return results[results.length - 1]?.value as ReturnType< + typeof createNativeLspServiceInstance + >; +}; + vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); const pathMod = await import('node:path'); @@ -533,16 +546,10 @@ describe('loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); - mockDiscoverAndPrepare.mockReset(); - mockStartLsp.mockReset(); - mockWorkspaceSymbols.mockReset(); - mockWorkspaceSymbols.mockResolvedValue([]); nativeLspServiceMock.mockReset(); - nativeLspServiceMock.mockImplementation(() => ({ - discoverAndPrepare: mockDiscoverAndPrepare, - start: mockStartLsp, - workspaceSymbols: mockWorkspaceSymbols, - })); + nativeLspServiceMock.mockImplementation(() => + createNativeLspServiceInstance(), + ); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); }); @@ -637,8 +644,10 @@ describe('loadCliConfig', () => { expect(config.getLspAllowed()).toEqual(['typescript-language-server']); expect(config.getLspExcluded()).toEqual(['pylsp']); expect(nativeLspServiceMock).toHaveBeenCalledTimes(1); - expect(mockDiscoverAndPrepare).toHaveBeenCalledTimes(1); - expect(mockStartLsp).toHaveBeenCalledTimes(1); + const lspInstance = getLastLspInstance(); + expect(lspInstance).toBeDefined(); + expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1); + expect(lspInstance?.start).toHaveBeenCalledTimes(1); const options = nativeLspServiceMock.mock.calls[0][5]; expect(options?.allowedServers).toEqual(['typescript-language-server']); @@ -664,7 +673,7 @@ describe('loadCliConfig', () => { expect(config.isLspEnabled()).toBe(true); expect(nativeLspServiceMock).not.toHaveBeenCalled(); - expect(mockDiscoverAndPrepare).not.toHaveBeenCalled(); + expect(getLastLspInstance()).toBeUndefined(); }); it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4fdf08079..214e923c9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -193,6 +193,67 @@ class NativeLspClient implements LspClient { limit, ); } + + /** + * Get hover information (documentation, type info) for a symbol. + */ + hover( + location: Parameters[0], + serverName?: string, + ) { + return this.service.hover(location, serverName); + } + + /** + * Get all symbols in a document. + */ + documentSymbols(uri: string, serverName?: string, limit?: number) { + return this.service.documentSymbols(uri, serverName, limit); + } + + /** + * Find implementations of an interface or abstract method. + */ + implementations( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.implementations(location, serverName, limit); + } + + /** + * Prepare call hierarchy item at a position (functions/methods). + */ + prepareCallHierarchy( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.prepareCallHierarchy(location, serverName, limit); + } + + /** + * Find all functions/methods that call the given function. + */ + incomingCalls( + item: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.incomingCalls(item, serverName, limit); + } + + /** + * Find all functions/methods called by the given function. + */ + outgoingCalls( + item: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.outgoingCalls(item, serverName, limit); + } } function normalizeOutputFormat( @@ -812,6 +873,7 @@ export async function loadCliConfig( const lspEnabled = settings.lsp?.enabled ?? false; const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed; const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded; + const lspLanguageServers = settings.lsp?.languageServers; let lspClient: LspClient | undefined; const question = argv.promptInteractive || argv.prompt || ''; const inputFormat: InputFormat = @@ -1149,6 +1211,7 @@ export async function loadCliConfig( allowedServers: lspAllowed, excludedServers: lspExcluded, requireTrustedWorkspace: folderTrust, + inlineServerConfigs: lspLanguageServers, }, ); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a70590fe8..585e574f2 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1083,6 +1083,17 @@ const SETTINGS_SCHEMA = { 'Optional blocklist of LSP server names that should not start.', showInDialog: false, }, + languageServers: { + type: 'object', + label: 'LSP Language Servers', + category: 'LSP', + requiresRestart: true, + default: {} as Record, + description: + 'Inline LSP server configuration (same format as .lsp.json).', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, }, }, useSmartEdit: { diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index 9f2e4c9b8..056afc91f 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -228,6 +228,12 @@ interface LspConnection { socket?: net.Socket; } +interface SocketConnectionOptions { + host?: string; + port?: number; + path?: string; +} + interface JsonRpcMessage { jsonrpc: string; id?: number | string; @@ -249,6 +255,7 @@ export class LspConnectionFactory { command: string, args: string[], options?: cp.SpawnOptions, + timeoutMs = 10000, ): Promise { return new Promise((resolve, reject) => { const spawnOptions: cp.SpawnOptions = { @@ -262,7 +269,7 @@ export class LspConnectionFactory { if (!processInstance.killed) { processInstance.kill(); } - }, 10000); + }, timeoutMs); processInstance.once('error', (error) => { clearTimeout(timeoutId); @@ -300,14 +307,37 @@ export class LspConnectionFactory { static async createTcpConnection( host: string, port: number, + timeoutMs = 10000, + ): Promise { + return LspConnectionFactory.createSocketConnection( + { host, port }, + timeoutMs, + ); + } + + /** + * 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket) + */ + static async createSocketConnection( + options: SocketConnectionOptions, + timeoutMs = 10000, ): Promise { return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }); + const socketOptions = options.path + ? { path: options.path } + : { host: options.host ?? '127.0.0.1', port: options.port }; + + if (!('path' in socketOptions) && !socketOptions.port) { + reject(new Error('Socket transport requires port or path')); + return; + } + + const socket = net.createConnection(socketOptions); const timeoutId = setTimeout(() => { reject(new Error('LSP server connection timeout')); socket.destroy(); - }, 10000); + }, timeoutMs); const onError = (error: Error) => { clearTimeout(timeoutId); diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index acac65b98..5ee4eff29 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,5 +1,11 @@ import { NativeLspService } from './NativeLspService.js'; import { EventEmitter } from 'events'; +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, +} from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index fe2da4498..77445a2f8 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -3,8 +3,13 @@ import type { WorkspaceContext, FileDiscoveryService, IdeContextStore, - LspLocation, + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, LspDefinition, + LspHoverResult, + LspLocation, + LspRange, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; @@ -21,16 +26,31 @@ interface LspInitializationOptions { [key: string]: unknown; } +interface LspSocketOptions { + host?: string; + port?: number; + path?: string; +} + // 定义 LSP 服务器配置类型 interface LspServerConfig { name: string; languages: string[]; - command: string; - args: string[]; - transport: 'stdio' | 'tcp'; + command?: string; + args?: string[]; + transport: 'stdio' | 'tcp' | 'socket'; + env?: Record; initializationOptions?: LspInitializationOptions; + settings?: Record; + extensionToLanguage?: Record; rootUri: string; + workspaceFolder?: string; + startupTimeout?: number; + shutdownTimeout?: number; + restartOnCrash?: boolean; + maxRestarts?: number; trustRequired?: boolean; + socket?: LspSocketOptions; } // 定义 LSP 连接接口 @@ -56,13 +76,52 @@ interface LspServerHandle { process?: ChildProcess; error?: Error; warmedUp?: boolean; + stopRequested?: boolean; + restartAttempts?: number; } +/** + * Symbol kind labels for converting numeric LSP SymbolKind to readable strings. + * Based on the LSP specification: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind + */ +const SYMBOL_KIND_LABELS: Record = { + 1: 'File', + 2: 'Module', + 3: 'Namespace', + 4: 'Package', + 5: 'Class', + 6: 'Method', + 7: 'Property', + 8: 'Field', + 9: 'Constructor', + 10: 'Enum', + 11: 'Interface', + 12: 'Function', + 13: 'Variable', + 14: 'Constant', + 15: 'String', + 16: 'Number', + 17: 'Boolean', + 18: 'Array', + 19: 'Object', + 20: 'Key', + 21: 'Null', + 22: 'EnumMember', + 23: 'Struct', + 24: 'Event', + 25: 'Operator', + 26: 'TypeParameter', +}; + +const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000; +const DEFAULT_LSP_MAX_RESTARTS = 3; + interface NativeLspServiceOptions { allowedServers?: string[]; excludedServers?: string[]; requireTrustedWorkspace?: boolean; workspaceRoot?: string; + inlineServerConfigs?: Record; } export class NativeLspService { @@ -74,6 +133,8 @@ export class NativeLspService { private excludedServers?: string[]; private requireTrustedWorkspace: boolean; private workspaceRoot: string; + private inlineServerConfigs?: Record; + private warnedLegacyConfig = false; constructor( config: CoreConfig, @@ -92,6 +153,7 @@ export class NativeLspService { this.workspaceRoot = options.workspaceRoot ?? (config as { getProjectRoot: () => string }).getProjectRoot(); + this.inlineServerConfigs = options.inlineServerConfigs; } /** @@ -108,10 +170,13 @@ export class NativeLspService { } // 检测工作区中的语言 - const detectedLanguages = await this.detectLanguages(); + const userConfigs = await this.loadUserConfigs(); + const extensionOverrides = + this.collectExtensionToLanguageOverrides(userConfigs); + const detectedLanguages = await this.detectLanguages(extensionOverrides); // 合并配置:内置预设 + 用户 .lsp.json + 可选 cclsp 兼容转换 - const serverConfigs = await this.mergeConfigs(detectedLanguages); + const serverConfigs = this.mergeConfigs(detectedLanguages, userConfigs); // 创建服务器句柄 for (const config of serverConfigs) { @@ -309,11 +374,338 @@ export class NativeLspService { return []; } + /** + * 获取悬停信息 + */ + async hover( + location: LspLocation, + serverName?: string, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request('textDocument/hover', { + textDocument: { uri: location.uri }, + position: location.range.start, + }); + const normalized = this.normalizeHoverResult(response, name); + if (normalized) { + return normalized; + } + } catch (error) { + console.warn(`LSP textDocument/hover failed for ${name}:`, error); + } + } + + return null; + } + + /** + * 获取文档符号 + */ + async documentSymbols( + uri: string, + serverName?: string, + limit = 200, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/documentSymbol', + { + textDocument: { uri }, + }, + ); + if (!Array.isArray(response)) { + continue; + } + const symbols: LspSymbolInformation[] = []; + for (const item of response) { + if (!item || typeof item !== 'object') { + continue; + } + const itemObj = item as Record; + if (this.isDocumentSymbol(itemObj)) { + this.collectDocumentSymbol(itemObj, uri, name, symbols, limit); + } else { + const normalized = this.normalizeSymbolResult(itemObj, name); + if (normalized) { + symbols.push(normalized); + } + } + if (symbols.length >= limit) { + return symbols.slice(0, limit); + } + } + if (symbols.length > 0) { + return symbols.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/documentSymbol failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找实现 + */ + async implementations( + location: LspLocation, + serverName?: string, + limit = 50, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/implementation', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const implementations: LspDefinition[] = []; + for (const item of candidates) { + const normalized = this.normalizeLocationResult(item, name); + if (normalized) { + implementations.push(normalized); + if (implementations.length >= limit) { + return implementations.slice(0, limit); + } + } + } + if (implementations.length > 0) { + return implementations.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/implementation failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 准备调用层级 + */ + async prepareCallHierarchy( + location: LspLocation, + serverName?: string, + limit = 50, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/prepareCallHierarchy', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const items: LspCallHierarchyItem[] = []; + for (const item of candidates) { + const normalized = this.normalizeCallHierarchyItem(item, name); + if (normalized) { + items.push(normalized); + if (items.length >= limit) { + return items.slice(0, limit); + } + } + } + if (items.length > 0) { + return items.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/prepareCallHierarchy failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找调用当前函数的调用者 + */ + async incomingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit = 50, + ): Promise { + const targetServer = serverName ?? item.serverName; + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!targetServer || name === targetServer), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'callHierarchy/incomingCalls', + { + item: this.toCallHierarchyItemParams(item), + }, + ); + if (!Array.isArray(response)) { + continue; + } + const calls: LspCallHierarchyIncomingCall[] = []; + for (const call of response) { + const normalized = this.normalizeIncomingCall(call, name); + if (normalized) { + calls.push(normalized); + if (calls.length >= limit) { + return calls.slice(0, limit); + } + } + } + if (calls.length > 0) { + return calls.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP callHierarchy/incomingCalls failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找当前函数调用的目标 + */ + async outgoingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit = 50, + ): Promise { + const targetServer = serverName ?? item.serverName; + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!targetServer || name === targetServer), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'callHierarchy/outgoingCalls', + { + item: this.toCallHierarchyItemParams(item), + }, + ); + if (!Array.isArray(response)) { + continue; + } + const calls: LspCallHierarchyOutgoingCall[] = []; + for (const call of response) { + const normalized = this.normalizeOutgoingCall(call, name); + if (normalized) { + calls.push(normalized); + if (calls.length >= limit) { + return calls.slice(0, limit); + } + } + } + if (calls.length > 0) { + return calls.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP callHierarchy/outgoingCalls failed for ${name}:`, + error, + ); + } + } + + return []; + } + /** * 检测工作区中的编程语言 */ - private async detectLanguages(): Promise { - const patterns = ['**/*.{js,ts,jsx,tsx,py,go,rs,java,cpp,php,rb,cs}']; + private async detectLanguages( + extensionOverrides: Record = {}, + ): Promise { + const extensionMap = this.getExtensionToLanguageMap(extensionOverrides); + const extensions = Object.keys(extensionMap); + const patterns = + extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*']; const excludePatterns = [ '**/node_modules/**', '**/.git/**', @@ -351,7 +743,7 @@ export class NativeLspService { for (const file of Array.from(files)) { const ext = path.extname(file).slice(1).toLowerCase(); if (ext) { - const lang = this.mapExtensionToLanguage(ext); + const lang = this.mapExtensionToLanguage(ext, extensionMap); if (lang) { languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); } @@ -413,8 +805,17 @@ export class NativeLspService { /** * 将文件扩展名映射到编程语言 */ - private mapExtensionToLanguage(ext: string): string | null { - const extToLang: { [key: string]: string } = { + private mapExtensionToLanguage( + ext: string, + extensionMap: Record, + ): string | null { + return extensionMap[ext] || null; + } + + private getExtensionToLanguageMap( + extensionOverrides: Record = {}, + ): Record { + const extToLang: Record = { js: 'javascript', ts: 'typescript', jsx: 'javascriptreact', @@ -437,7 +838,37 @@ export class NativeLspService { yml: 'yaml', }; - return extToLang[ext] || null; + for (const [key, value] of Object.entries(extensionOverrides)) { + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + extToLang[normalized.toLowerCase()] = value; + } + + return extToLang; + } + + private collectExtensionToLanguageOverrides( + configs: LspServerConfig[], + ): Record { + const overrides: Record = {}; + for (const config of configs) { + if (!config.extensionToLanguage) { + continue; + } + for (const [key, value] of Object.entries(config.extensionToLanguage)) { + if (typeof value !== 'string') { + continue; + } + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + overrides[normalized.toLowerCase()] = value; + } + } + return overrides; } /** @@ -536,7 +967,7 @@ export class NativeLspService { return { name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, - kind: itemObj['kind'] ? String(itemObj['kind']) : undefined, + kind: this.normalizeSymbolKind(itemObj['kind']), containerName: (itemObj['containerName'] ?? itemObj['container']) as | string | undefined, @@ -557,18 +988,331 @@ export class NativeLspService { }; } + private normalizeRange(range: unknown): LspRange | null { + if (!range || typeof range !== 'object') { + return null; + } + + const rangeObj = range as Record; + const start = rangeObj['start']; + const end = rangeObj['end']; + + if ( + !start || + typeof start !== 'object' || + !end || + typeof end !== 'object' + ) { + return null; + } + + const startObj = start as Record; + const endObj = end as Record; + + return { + start: { + line: Number(startObj['line'] ?? 0), + character: Number(startObj['character'] ?? 0), + }, + end: { + line: Number(endObj['line'] ?? 0), + character: Number(endObj['character'] ?? 0), + }, + }; + } + + private normalizeRanges(ranges: unknown): LspRange[] { + if (!Array.isArray(ranges)) { + return []; + } + + const results: LspRange[] = []; + for (const range of ranges) { + const normalized = this.normalizeRange(range); + if (normalized) { + results.push(normalized); + } + } + + return results; + } + + private normalizeSymbolKind(kind: unknown): string | undefined { + if (typeof kind === 'number') { + return SYMBOL_KIND_LABELS[kind] ?? String(kind); + } + if (typeof kind === 'string') { + const trimmed = kind.trim(); + if (trimmed === '') { + return undefined; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && SYMBOL_KIND_LABELS[numeric]) { + return SYMBOL_KIND_LABELS[numeric]; + } + return trimmed; + } + return undefined; + } + + private normalizeHoverContents(contents: unknown): string { + if (!contents) { + return ''; + } + if (typeof contents === 'string') { + return contents; + } + if (Array.isArray(contents)) { + const parts = contents + .map((item) => this.normalizeHoverContents(item)) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return parts.join('\n'); + } + if (typeof contents === 'object') { + const contentsObj = contents as Record; + const value = contentsObj['value']; + if (typeof value === 'string') { + const language = contentsObj['language']; + if (typeof language === 'string' && language.trim() !== '') { + return `\`\`\`${language}\n${value}\n\`\`\``; + } + return value; + } + } + return ''; + } + + private normalizeHoverResult( + response: unknown, + serverName: string, + ): LspHoverResult | null { + if (!response) { + return null; + } + if (typeof response !== 'object') { + const contents = this.normalizeHoverContents(response); + if (!contents.trim()) { + return null; + } + return { + contents, + serverName, + }; + } + + const responseObj = response as Record; + const contents = this.normalizeHoverContents(responseObj['contents']); + if (!contents.trim()) { + return null; + } + + const range = this.normalizeRange(responseObj['range']); + return { + contents, + range: range ?? undefined, + serverName, + }; + } + + private normalizeCallHierarchyItem( + item: unknown, + serverName: string, + ): LspCallHierarchyItem | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const nameValue = itemObj['name'] ?? itemObj['label'] ?? 'symbol'; + const name = + typeof nameValue === 'string' ? nameValue : String(nameValue ?? ''); + const uri = itemObj['uri']; + + if (!name || typeof uri !== 'string') { + return null; + } + + const range = this.normalizeRange(itemObj['range']); + const selectionRange = + this.normalizeRange(itemObj['selectionRange']) ?? range; + + if (!range || !selectionRange) { + return null; + } + + const serverOverride = + typeof itemObj['serverName'] === 'string' + ? (itemObj['serverName'] as string) + : undefined; + + // Preserve raw numeric kind for server communication + // Priority: rawKind field > numeric kind > parsed numeric string + let rawKind: number | undefined; + if (typeof itemObj['rawKind'] === 'number') { + rawKind = itemObj['rawKind']; + } else if (typeof itemObj['kind'] === 'number') { + rawKind = itemObj['kind']; + } else if (typeof itemObj['kind'] === 'string') { + const parsed = Number(itemObj['kind']); + if (Number.isFinite(parsed)) { + rawKind = parsed; + } + } + + return { + name, + kind: this.normalizeSymbolKind(itemObj['kind']), + rawKind, + detail: + typeof itemObj['detail'] === 'string' + ? (itemObj['detail'] as string) + : undefined, + uri, + range, + selectionRange, + data: itemObj['data'], + serverName: serverOverride ?? serverName, + }; + } + + private normalizeIncomingCall( + item: unknown, + serverName: string, + ): LspCallHierarchyIncomingCall | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; + const from = this.normalizeCallHierarchyItem(itemObj['from'], serverName); + if (!from) { + return null; + } + return { + from, + fromRanges: this.normalizeRanges(itemObj['fromRanges']), + }; + } + + private normalizeOutgoingCall( + item: unknown, + serverName: string, + ): LspCallHierarchyOutgoingCall | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; + const to = this.normalizeCallHierarchyItem(itemObj['to'], serverName); + if (!to) { + return null; + } + return { + to, + fromRanges: this.normalizeRanges(itemObj['fromRanges']), + }; + } + + private toCallHierarchyItemParams( + item: LspCallHierarchyItem, + ): Record { + // Use rawKind (numeric) for server communication, fallback to parsing kind string + let numericKind: number | undefined = item.rawKind; + if (numericKind === undefined && item.kind !== undefined) { + const parsed = Number(item.kind); + if (Number.isFinite(parsed)) { + numericKind = parsed; + } + } + + return { + name: item.name, + kind: numericKind, + detail: item.detail, + uri: item.uri, + range: item.range, + selectionRange: item.selectionRange, + data: item.data, + }; + } + + private isDocumentSymbol(item: Record): boolean { + const range = item['range']; + const selectionRange = item['selectionRange']; + return ( + typeof range === 'object' && + range !== null && + typeof selectionRange === 'object' && + selectionRange !== null + ); + } + + private collectDocumentSymbol( + item: Record, + uri: string, + serverName: string, + results: LspSymbolInformation[], + limit: number, + containerName?: string, + ): void { + if (results.length >= limit) { + return; + } + + const nameValue = item['name'] ?? item['label'] ?? 'symbol'; + const name = typeof nameValue === 'string' ? nameValue : String(nameValue); + const selectionRange = + this.normalizeRange(item['selectionRange']) ?? + this.normalizeRange(item['range']); + + if (!selectionRange) { + return; + } + + results.push({ + name, + kind: this.normalizeSymbolKind(item['kind']), + containerName, + location: { + uri, + range: selectionRange, + }, + serverName, + }); + + if (results.length >= limit) { + return; + } + + const children = item['children']; + if (Array.isArray(children)) { + for (const child of children) { + if (results.length >= limit) { + break; + } + if (child && typeof child === 'object') { + this.collectDocumentSymbol( + child as Record, + uri, + serverName, + results, + limit, + name, + ); + } + } + } + } + /** * 合并配置:内置预设 + 用户配置 + 兼容层 */ - private async mergeConfigs( + private mergeConfigs( detectedLanguages: string[], - ): Promise { + userConfigs: LspServerConfig[], + ): LspServerConfig[] { // 内置预设配置 const presets = this.getBuiltInPresets(detectedLanguages); - // 用户 .lsp.json 配置(如果存在) - const userConfigs = await this.loadUserConfigs(); - // 合并配置,用户配置优先级更高 const mergedConfigs = [...presets]; @@ -614,6 +1358,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -627,6 +1372,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -640,6 +1386,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -654,63 +1401,331 @@ export class NativeLspService { */ private async loadUserConfigs(): Promise { const configs: LspServerConfig[] = []; + const sources: Array<{ origin: string; data: unknown }> = []; - try { - const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); - if (fs.existsSync(lspConfigPath)) { + if (this.inlineServerConfigs) { + sources.push({ + origin: 'settings.lsp.languageServers', + data: this.inlineServerConfigs, + }); + } + + const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); + if (fs.existsSync(lspConfigPath)) { + try { const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); - const userConfig = JSON.parse(configContent); - - // 验证并转换用户配置为内部格式 - if (userConfig && typeof userConfig === 'object') { - for (const [langId, serverSpec] of Object.entries( - userConfig, - ) as Array<[string, Record]>) { - // 转换为文件 URI 格式 - const rootUri = pathToFileURL(this.workspaceRoot).toString(); - - // 驗證 command 不為 undefined - if (!(serverSpec as Record)['command']) { - console.warn(`LSP 配置錯誤: ${langId} 缺少 command 屬性`); - continue; - } - - const serverConfig: LspServerConfig = { - name: (serverSpec as Record)[ - 'command' - ] as string, - languages: [langId], - command: (serverSpec as Record)[ - 'command' - ] as string, - args: - ((serverSpec as Record)['args'] as string[]) || - [], - transport: - ((serverSpec as Record)['transport'] as - | 'stdio' - | 'tcp') || 'stdio', - initializationOptions: (serverSpec as Record)[ - 'initializationOptions' - ] as LspInitializationOptions, - rootUri, - trustRequired: - ((serverSpec as Record)[ - 'trustRequired' - ] as boolean) ?? true, - }; - - configs.push(serverConfig); - } - } + sources.push({ + origin: lspConfigPath, + data: JSON.parse(configContent), + }); + } catch (e) { + console.warn('加载用户 .lsp.json 配置失败:', e); } - } catch (e) { - console.warn('加载用户 .lsp.json 配置失败:', e); + } + + for (const source of sources) { + const parsed = this.parseConfigSource(source.data, source.origin); + if (parsed.usedLegacyFormat && parsed.configs.length > 0) { + this.warnLegacyConfig(source.origin); + } + configs.push(...parsed.configs); } return configs; } + private parseConfigSource( + source: unknown, + origin: string, + ): { configs: LspServerConfig[]; usedLegacyFormat: boolean } { + if (!this.isRecord(source)) { + return { configs: [], usedLegacyFormat: false }; + } + + const configs: LspServerConfig[] = []; + let serverMap: Record = source; + let usedLegacyFormat = false; + + if (this.isRecord(source['languageServers'])) { + serverMap = source['languageServers'] as Record; + } else if (this.isNewFormatServerMap(source)) { + serverMap = source; + } else { + usedLegacyFormat = true; + } + + for (const [key, spec] of Object.entries(serverMap)) { + if (!this.isRecord(spec)) { + continue; + } + + const languagesValue = spec['languages']; + const languages = usedLegacyFormat + ? [key] + : (this.normalizeStringArray(languagesValue) ?? + (typeof languagesValue === 'string' ? [languagesValue] : [])); + + const name = usedLegacyFormat + ? typeof spec['command'] === 'string' + ? (spec['command'] as string) + : key + : key; + + const config = this.buildServerConfig(name, languages, spec, origin); + if (config) { + configs.push(config); + } + } + + return { configs, usedLegacyFormat }; + } + + private buildServerConfig( + name: string, + languages: string[], + spec: Record, + origin: string, + ): LspServerConfig | null { + const transport = this.normalizeTransport(spec['transport']); + const command = + typeof spec['command'] === 'string' + ? (spec['command'] as string) + : undefined; + const args = this.normalizeStringArray(spec['args']) ?? []; + const env = this.normalizeEnv(spec['env']); + const initializationOptions = this.isRecord(spec['initializationOptions']) + ? (spec['initializationOptions'] as LspInitializationOptions) + : undefined; + const settings = this.isRecord(spec['settings']) + ? (spec['settings'] as Record) + : undefined; + const extensionToLanguage = this.normalizeExtensionToLanguage( + spec['extensionToLanguage'], + ); + const workspaceFolder = this.resolveWorkspaceFolder( + spec['workspaceFolder'], + ); + const rootUri = pathToFileURL(workspaceFolder).toString(); + const startupTimeout = this.normalizeTimeout(spec['startupTimeout']); + const shutdownTimeout = this.normalizeTimeout(spec['shutdownTimeout']); + const restartOnCrash = + typeof spec['restartOnCrash'] === 'boolean' + ? (spec['restartOnCrash'] as boolean) + : undefined; + const maxRestarts = this.normalizeMaxRestarts(spec['maxRestarts']); + const trustRequired = + typeof spec['trustRequired'] === 'boolean' + ? (spec['trustRequired'] as boolean) + : true; + const socket = this.normalizeSocketOptions(spec); + + if (transport === 'stdio' && !command) { + console.warn(`LSP config error in ${origin}: ${name} missing command`); + return null; + } + + if (transport !== 'stdio' && !socket) { + console.warn( + `LSP config error in ${origin}: ${name} missing socket info`, + ); + return null; + } + + return { + name, + languages, + command, + args, + transport, + env, + initializationOptions, + settings, + extensionToLanguage, + rootUri, + workspaceFolder, + startupTimeout, + shutdownTimeout, + restartOnCrash, + maxRestarts, + trustRequired, + socket, + }; + } + + private isNewFormatServerMap(value: Record): boolean { + return Object.values(value).some( + (entry) => this.isRecord(entry) && this.isNewFormatServerSpec(entry), + ); + } + + private isNewFormatServerSpec(value: Record): boolean { + return ( + Array.isArray(value['languages']) || + this.isRecord(value['extensionToLanguage']) || + this.isRecord(value['settings']) || + value['workspaceFolder'] !== undefined || + value['startupTimeout'] !== undefined || + value['shutdownTimeout'] !== undefined || + value['restartOnCrash'] !== undefined || + value['maxRestarts'] !== undefined || + this.isRecord(value['env']) || + value['socket'] !== undefined + ); + } + + private warnLegacyConfig(origin: string): void { + if (this.warnedLegacyConfig) { + return; + } + console.warn( + `Legacy LSP config detected in ${origin}. Please migrate to the languageServers format.`, + ); + this.warnedLegacyConfig = true; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((item): item is string => typeof item === 'string'); + } + + private normalizeEnv(value: unknown): Record | undefined { + if (!this.isRecord(value)) { + return undefined; + } + const env: Record = {}; + for (const [key, val] of Object.entries(value)) { + if ( + typeof val === 'string' || + typeof val === 'number' || + typeof val === 'boolean' + ) { + env[key] = String(val); + } + } + return Object.keys(env).length > 0 ? env : undefined; + } + + private normalizeExtensionToLanguage( + value: unknown, + ): Record | undefined { + if (!this.isRecord(value)) { + return undefined; + } + const mapping: Record = {}; + for (const [key, lang] of Object.entries(value)) { + if (typeof lang !== 'string') { + continue; + } + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + mapping[normalized.toLowerCase()] = lang; + } + return Object.keys(mapping).length > 0 ? mapping : undefined; + } + + private normalizeTransport(value: unknown): 'stdio' | 'tcp' | 'socket' { + if (typeof value !== 'string') { + return 'stdio'; + } + const normalized = value.toLowerCase(); + if (normalized === 'tcp' || normalized === 'socket') { + return normalized; + } + return 'stdio'; + } + + private normalizeTimeout(value: unknown): number | undefined { + if (typeof value !== 'number') { + return undefined; + } + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + return value; + } + + private normalizeMaxRestarts(value: unknown): number | undefined { + if (typeof value !== 'number') { + return undefined; + } + if (!Number.isFinite(value) || value < 0) { + return undefined; + } + return value; + } + + private normalizeSocketOptions( + value: Record, + ): LspSocketOptions | undefined { + const socketValue = value['socket']; + if (typeof socketValue === 'string') { + return { path: socketValue }; + } + + const source = this.isRecord(socketValue) ? socketValue : value; + const host = + typeof source['host'] === 'string' + ? (source['host'] as string) + : undefined; + const pathValue = + typeof source['path'] === 'string' + ? (source['path'] as string) + : typeof source['socketPath'] === 'string' + ? (source['socketPath'] as string) + : undefined; + const portValue = source['port']; + const port = + typeof portValue === 'number' + ? portValue + : typeof portValue === 'string' + ? Number(portValue) + : undefined; + + const socket: LspSocketOptions = {}; + if (host) { + socket.host = host; + } + if (Number.isFinite(port) && (port as number) > 0) { + socket.port = port as number; + } + if (pathValue) { + socket.path = pathValue; + } + + if (!socket.path && !socket.port) { + return undefined; + } + return socket; + } + + private resolveWorkspaceFolder(value: unknown): string { + if (typeof value !== 'string' || value.trim() === '') { + return this.workspaceRoot; + } + + const resolved = path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(this.workspaceRoot, value); + const root = path.resolve(this.workspaceRoot); + + if (resolved === root || resolved.startsWith(root + path.sep)) { + return resolved; + } + + console.warn( + `LSP workspaceFolder must be within ${this.workspaceRoot}; using workspace root instead.`, + ); + return this.workspaceRoot; + } + /** * 启动单个 LSP 服务器 */ @@ -718,13 +1733,22 @@ export class NativeLspService { name: string, handle: LspServerHandle, ): Promise { - if (this.excludedServers?.includes(name)) { + if (handle.status === 'IN_PROGRESS') { + return; + } + handle.stopRequested = false; + + if (this.isServerInList(this.excludedServers, handle.config)) { console.log(`LSP 服务器 ${name} 在排除列表中,跳过启动`); handle.status = 'FAILED'; return; } - if (this.allowedServers && !this.allowedServers.includes(name)) { + if ( + this.allowedServers && + this.allowedServers.length > 0 && + !this.isServerInList(this.allowedServers, handle.config) + ) { console.log(`LSP 服务器 ${name} 不在允许列表中,跳过启动`); handle.status = 'FAILED'; return; @@ -753,22 +1777,37 @@ export class NativeLspService { } // 检查命令是否存在 - if (!(await this.commandExists(handle.config.command))) { - console.warn(`LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`); - handle.status = 'FAILED'; - return; - } + if (handle.config.command) { + const commandCwd = handle.config.workspaceFolder ?? this.workspaceRoot; + if ( + !(await this.commandExists( + handle.config.command, + handle.config.env, + commandCwd, + )) + ) { + console.warn( + `LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } - // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, this.workspaceRoot)) { - console.warn( - `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, - ); - handle.status = 'FAILED'; - return; + // 检查路径安全性 + if ( + !this.isPathSafe(handle.config.command, this.workspaceRoot, commandCwd) + ) { + console.warn( + `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } } try { + handle.error = undefined; + handle.warmedUp = false; handle.status = 'IN_PROGRESS'; // 创建 LSP 连接 @@ -780,6 +1819,7 @@ export class NativeLspService { await this.initializeLspServer(connection, handle.config); handle.status = 'READY'; + this.attachRestartHandler(name, handle); console.log(`LSP 服务器 ${name} 启动成功`); } catch (error) { handle.status = 'FAILED'; @@ -795,19 +1835,146 @@ export class NativeLspService { name: string, handle: LspServerHandle, ): Promise { + handle.stopRequested = true; + if (handle.connection) { try { - await handle.connection.shutdown(); - handle.connection.end(); + await this.shutdownConnection(handle); } catch (error) { console.error(`关闭 LSP 服务器 ${name} 时出错:`, error); } - } else if (handle.process && !handle.process.killed) { + } else if (handle.process && handle.process.exitCode === null) { handle.process.kill(); } handle.connection = undefined; handle.process = undefined; handle.status = 'NOT_STARTED'; + handle.warmedUp = false; + handle.restartAttempts = 0; + } + + private isServerInList( + list: string[] | undefined, + config: LspServerConfig, + ): boolean { + if (!list || list.length === 0) { + return false; + } + if (list.includes(config.name)) { + return true; + } + if (config.command && list.includes(config.command)) { + return true; + } + return false; + } + + private async shutdownConnection(handle: LspServerHandle): Promise { + if (!handle.connection) { + return; + } + try { + const shutdownPromise = handle.connection.shutdown(); + if (typeof handle.config.shutdownTimeout === 'number') { + await Promise.race([ + shutdownPromise, + new Promise((resolve) => + setTimeout(resolve, handle.config.shutdownTimeout), + ), + ]); + } else { + await shutdownPromise; + } + } finally { + handle.connection.end(); + } + } + + private attachRestartHandler(name: string, handle: LspServerHandle): void { + if (!handle.process) { + return; + } + handle.process.once('exit', (code) => { + if (handle.stopRequested) { + return; + } + if (!handle.config.restartOnCrash) { + handle.status = 'FAILED'; + return; + } + const maxRestarts = handle.config.maxRestarts ?? DEFAULT_LSP_MAX_RESTARTS; + if (maxRestarts <= 0) { + handle.status = 'FAILED'; + return; + } + const attempts = handle.restartAttempts ?? 0; + if (attempts >= maxRestarts) { + console.warn( + `LSP 服务器 ${name} 达到最大重启次数 (${maxRestarts}),停止重启`, + ); + handle.status = 'FAILED'; + return; + } + handle.restartAttempts = attempts + 1; + console.warn( + `LSP 服务器 ${name} 退出 (code ${code ?? 'unknown'}),正在重启 (${handle.restartAttempts}/${maxRestarts})`, + ); + this.resetHandle(handle); + void this.startServer(name, handle); + }); + } + + private resetHandle(handle: LspServerHandle): void { + if (handle.connection) { + handle.connection.end(); + } + if (handle.process && handle.process.exitCode === null) { + handle.process.kill(); + } + handle.connection = undefined; + handle.process = undefined; + handle.status = 'NOT_STARTED'; + handle.error = undefined; + handle.warmedUp = false; + handle.stopRequested = false; + } + + private buildProcessEnv( + env: Record | undefined, + ): NodeJS.ProcessEnv | undefined { + if (!env || Object.keys(env).length === 0) { + return undefined; + } + return { ...process.env, ...env }; + } + + private async connectSocketWithRetry( + socket: LspSocketOptions, + timeoutMs: number, + ): Promise< + Awaited> + > { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error('LSP server connection timeout'); + } + try { + return await LspConnectionFactory.createSocketConnection( + socket, + remaining, + ); + } catch (error) { + attempt += 1; + if (Date.now() >= deadline) { + throw error; + } + const delay = Math.min(250 * attempt, 1000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } /** @@ -815,17 +1982,27 @@ export class NativeLspService { */ private async createLspConnection(config: LspServerConfig): Promise<{ connection: LspConnectionInterface; - process: ChildProcess; + process?: ChildProcess; shutdown: () => Promise; exit: () => void; initialize: (params: unknown) => Promise; }> { + const workspaceFolder = config.workspaceFolder ?? this.workspaceRoot; + const startupTimeout = + config.startupTimeout ?? DEFAULT_LSP_STARTUP_TIMEOUT_MS; + const env = this.buildProcessEnv(config.env); + if (config.transport === 'stdio') { + if (!config.command) { + throw new Error('LSP stdio transport requires a command'); + } + // 修复:使用 cwd 作为 cwd 而不是 rootUri const lspConnection = await LspConnectionFactory.createStdioConnection( config.command, - config.args, - { cwd: this.workspaceRoot }, + config.args ?? [], + { cwd: workspaceFolder, env }, + startupTimeout, ); return { @@ -843,9 +2020,50 @@ export class NativeLspService { initialize: async (params: unknown) => lspConnection.connection.initialize(params), }; - } else if (config.transport === 'tcp') { - // 如果需要 TCP 支持,可以扩展此部分 - throw new Error('TCP transport not yet implemented'); + } else if (config.transport === 'tcp' || config.transport === 'socket') { + if (!config.socket) { + throw new Error('LSP socket transport requires host/port or path'); + } + + let process: ChildProcess | undefined; + if (config.command) { + process = spawn(config.command, config.args ?? [], { + cwd: workspaceFolder, + env, + stdio: 'ignore', + }); + await new Promise((resolve, reject) => { + process?.once('spawn', () => resolve()); + process?.once('error', (error) => { + reject(new Error(`Failed to spawn LSP server: ${error.message}`)); + }); + }); + } + + try { + const lspConnection = await this.connectSocketWithRetry( + config.socket, + startupTimeout, + ); + + return { + connection: lspConnection.connection as LspConnectionInterface, + process, + shutdown: async () => { + await lspConnection.connection.shutdown(); + }, + exit: () => { + lspConnection.connection.end(); + }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), + }; + } catch (error) { + if (process && process.exitCode === null) { + process.kill(); + } + throw error; + } } else { throw new Error(`Unsupported transport: ${config.transport}`); } @@ -858,15 +2076,16 @@ export class NativeLspService { connection: Awaited>, config: LspServerConfig, ): Promise { + const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot; const workspaceFolder = { - name: path.basename(this.workspaceRoot) || this.workspaceRoot, + name: path.basename(workspaceFolderPath) || workspaceFolderPath, uri: config.rootUri, }; const initializeParams = { processId: process.pid, rootUri: config.rootUri, - rootPath: this.workspaceRoot, + rootPath: workspaceFolderPath, workspaceFolders: [workspaceFolder], capabilities: { textDocument: { @@ -904,8 +2123,21 @@ export class NativeLspService { }, }); + if (config.settings && Object.keys(config.settings).length > 0) { + connection.connection.send({ + jsonrpc: '2.0', + method: 'workspace/didChangeConfiguration', + params: { + settings: config.settings, + }, + }); + } + // Warm up TypeScript server by opening a workspace file so it can create a project. - if (config.name.includes('typescript')) { + if ( + config.name.includes('typescript') || + (config.command?.includes('typescript') ?? false) + ) { try { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { @@ -936,13 +2168,18 @@ export class NativeLspService { /** * 检查命令是否存在 */ - private async commandExists(command: string): Promise { + private async commandExists( + command: string, + env?: Record, + cwd?: string, + ): Promise { // 实现命令存在性检查 return new Promise((resolve) => { let settled = false; const child = spawn(command, ['--version'], { stdio: ['ignore', 'ignore', 'ignore'], - cwd: this.workspaceRoot, + cwd: cwd ?? this.workspaceRoot, + env: this.buildProcessEnv(env), }); child.on('error', () => { @@ -971,23 +2208,19 @@ export class NativeLspService { /** * 检查路径安全性 */ - private isPathSafe(command: string, workspacePath: string): boolean { + private isPathSafe( + command: string, + workspacePath: string, + cwd?: string, + ): boolean { // 检查命令是否在工作区路径内,或者是否在系统 PATH 中 // 允许全局安装的命令(如在 PATH 中的命令) // 只阻止显式指定工作区外绝对路径的情况 - if (path.isAbsolute(command)) { - // 如果是绝对路径,检查是否在工作区路径内 - const resolvedPath = path.resolve(command); - const resolvedWorkspacePath = path.resolve(workspacePath); - return ( - resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || - resolvedPath === resolvedWorkspacePath - ); - } - // 相对路径和命令名(在 PATH 中查找)认为是安全的 - // 但需要确保相对路径不指向工作区外 - const resolvedPath = path.resolve(workspacePath, command); const resolvedWorkspacePath = path.resolve(workspacePath); + const basePath = cwd ? path.resolve(cwd) : resolvedWorkspacePath; + const resolvedPath = path.isAbsolute(command) + ? path.resolve(command) + : path.resolve(basePath, command); return ( resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || resolvedPath === resolvedWorkspacePath @@ -1008,7 +2241,7 @@ export class NativeLspService { if (this.requireTrustedWorkspace || serverConfig.trustRequired) { console.log( - `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command})`, + `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command ?? serverConfig.transport})`, ); return false; } @@ -1056,7 +2289,10 @@ export class NativeLspService { } private isTypescriptServer(handle: LspServerHandle): boolean { - return handle.config.name.includes('typescript'); + return ( + handle.config.name.includes('typescript') || + (handle.config.command?.includes('typescript') ?? false) + ); } private isNoProjectErrorResponse(response: unknown): boolean { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4625896e9..7d504c212 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -64,6 +64,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js'; import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js'; import { LspFindReferencesTool } from '../tools/lsp-find-references.js'; +import { LspTool } from '../tools/lsp.js'; import type { LspClient } from '../lsp/types.js'; // Other modules @@ -1583,6 +1584,9 @@ export class Config { registerCoreTool(WebSearchTool, this); } if (this.isLspEnabled() && this.getLspClient()) { + // Register the unified LSP tool (recommended) + registerCoreTool(LspTool, this); + // Keep legacy tools for backward compatibility registerCoreTool(LspGoToDefinitionTool, this); registerCoreTool(LspFindReferencesTool, this); registerCoreTool(LspWorkspaceSymbolTool, this); diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 309ad43b9..936a784ac 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -39,20 +39,140 @@ export interface LspDefinition extends LspLocationWithServer { readonly serverName?: string; } +/** + * Hover result containing documentation or type information. + */ +export interface LspHoverResult { + /** The hover content as a string (normalized from MarkupContent/MarkedString). */ + contents: string; + /** Optional range that the hover applies to. */ + range?: LspRange; + /** The LSP server that provided this result. */ + serverName?: string; +} + +/** + * Call hierarchy item representing a function, method, or callable. + */ +export interface LspCallHierarchyItem { + /** The name of this item. */ + name: string; + /** The kind of this item (function, method, constructor, etc.) as readable string. */ + kind?: string; + /** The raw numeric SymbolKind from LSP, preserved for server communication. */ + rawKind?: number; + /** Additional details like signature or file path. */ + detail?: string; + /** The URI of the document containing this item. */ + uri: string; + /** The full range of this item. */ + range: LspRange; + /** The range that should be selected when navigating to this item. */ + selectionRange: LspRange; + /** Opaque data used by the server for subsequent calls. */ + data?: unknown; + /** The LSP server that provided this item. */ + serverName?: string; +} + +/** + * Incoming call representing a function that calls the target. + */ +export interface LspCallHierarchyIncomingCall { + /** The caller item. */ + from: LspCallHierarchyItem; + /** The ranges where the call occurs within the caller. */ + fromRanges: LspRange[]; +} + +/** + * Outgoing call representing a function called by the target. + */ +export interface LspCallHierarchyOutgoingCall { + /** The callee item. */ + to: LspCallHierarchyItem; + /** The ranges where the call occurs within the caller. */ + fromRanges: LspRange[]; +} + export interface LspClient { + /** + * Search for symbols across the workspace. + */ workspaceSymbols( query: string, limit?: number, ): Promise; + + /** + * Get hover information (documentation, type info) for a symbol. + */ + hover( + location: LspLocation, + serverName?: string, + ): Promise; + + /** + * Get all symbols in a document. + */ + documentSymbols( + uri: string, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find where a symbol is defined. + */ definitions( location: LspLocation, serverName?: string, limit?: number, ): Promise; + + /** + * Find implementations of an interface or abstract method. + */ + implementations( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all references to a symbol. + */ references( location: LspLocation, serverName?: string, includeDeclaration?: boolean, limit?: number, ): Promise; + + /** + * Prepare call hierarchy item at a position (functions/methods). + */ + prepareCallHierarchy( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all functions/methods that call the given function. + */ + incomingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all functions/methods called by the given function. + */ + outgoingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise; } diff --git a/packages/core/src/tools/lsp.test.ts b/packages/core/src/tools/lsp.test.ts new file mode 100644 index 000000000..ca2a2fc0c --- /dev/null +++ b/packages/core/src/tools/lsp.test.ts @@ -0,0 +1,1220 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Config } from '../config/config.js'; +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspClient, + LspDefinition, + LspHoverResult, + LspLocation, + LspReference, + LspSymbolInformation, +} from '../lsp/types.js'; +import { LspTool, type LspToolParams, type LspOperation } from './lsp.js'; + +const abortSignal = new AbortController().signal; +const workspaceRoot = '/test/workspace'; + +/** + * Helper to resolve a path relative to workspace root. + */ +const resolvePath = (...segments: string[]) => + path.join(workspaceRoot, ...segments); + +/** + * Helper to convert file path to URI. + */ +const toUri = (filePath: string) => pathToFileURL(filePath).toString(); + +/** + * Helper to create a mock LspLocation. + */ +const createLocation = ( + filePath: string, + line: number, + character: number, +): LspLocation => ({ + uri: toUri(filePath), + range: { + start: { line, character }, + end: { line, character }, + }, +}); + +/** + * Create a mock LspClient with all methods mocked. + */ +const createMockClient = (): LspClient => + ({ + workspaceSymbols: vi.fn().mockResolvedValue([]), + hover: vi.fn().mockResolvedValue(null), + documentSymbols: vi.fn().mockResolvedValue([]), + definitions: vi.fn().mockResolvedValue([]), + implementations: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + prepareCallHierarchy: vi.fn().mockResolvedValue([]), + incomingCalls: vi.fn().mockResolvedValue([]), + outgoingCalls: vi.fn().mockResolvedValue([]), + }) as unknown as LspClient; + +/** + * Create a mock Config for testing. + */ +const createMockConfig = (client?: LspClient, enabled = true): Config => + ({ + getLspClient: () => client, + isLspEnabled: () => enabled, + getProjectRoot: () => workspaceRoot, + }) as unknown as Config; + +/** + * Create a LspTool with mock config. + */ +const createTool = (client?: LspClient, enabled = true) => + new LspTool(createMockConfig(client, enabled)); + +describe('LspTool', () => { + describe('validateToolParams', () => { + let tool: LspTool; + + beforeEach(() => { + tool = createTool(); + }); + + describe('location-based operations', () => { + const locationOperations: LspOperation[] = [ + 'goToDefinition', + 'findReferences', + 'hover', + 'goToImplementation', + 'prepareCallHierarchy', + ]; + + it.each(locationOperations)( + 'requires filePath for %s operation', + (operation) => { + const result = tool.validateToolParams({ + operation, + } as LspToolParams); + expect(result).toBe(`filePath is required for ${operation}.`); + }, + ); + + it.each(locationOperations)( + 'requires line for %s operation', + (operation) => { + const result = tool.validateToolParams({ + operation, + filePath: 'src/app.ts', + } as LspToolParams); + expect(result).toBe(`line is required for ${operation}.`); + }, + ); + + it.each(locationOperations)( + 'passes validation with valid params for %s', + (operation) => { + const result = tool.validateToolParams({ + operation, + filePath: 'src/app.ts', + line: 10, + character: 5, + } as LspToolParams); + expect(result).toBeNull(); + }, + ); + }); + + describe('documentSymbol operation', () => { + it('requires filePath for documentSymbol', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + } as LspToolParams); + expect(result).toBe('filePath is required for documentSymbol.'); + }); + + it('passes validation with filePath', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('workspaceSymbol operation', () => { + it('requires query for workspaceSymbol', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + + it('rejects empty query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: ' ', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + + it('passes validation with query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: 'Widget', + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('call hierarchy operations', () => { + it('requires callHierarchyItem for incomingCalls', () => { + const result = tool.validateToolParams({ + operation: 'incomingCalls', + } as LspToolParams); + expect(result).toBe('callHierarchyItem is required for incomingCalls.'); + }); + + it('requires callHierarchyItem for outgoingCalls', () => { + const result = tool.validateToolParams({ + operation: 'outgoingCalls', + } as LspToolParams); + expect(result).toBe('callHierarchyItem is required for outgoingCalls.'); + }); + + it('passes validation with callHierarchyItem', () => { + const item: LspCallHierarchyItem = { + name: 'testFunc', + uri: 'file:///test.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + selectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }; + const result = tool.validateToolParams({ + operation: 'incomingCalls', + callHierarchyItem: item, + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('numeric parameter validation', () => { + it('rejects non-positive line', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 0, + } as LspToolParams); + expect(result).toBe('line must be a positive number.'); + }); + + it('rejects negative line', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: -1, + } as LspToolParams); + expect(result).toBe('line must be a positive number.'); + }); + + it('rejects non-positive character', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 1, + character: 0, + } as LspToolParams); + expect(result).toBe('character must be a positive number.'); + }); + + it('rejects non-positive limit', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + limit: 0, + } as LspToolParams); + expect(result).toBe('limit must be a positive number.'); + }); + }); + + describe('edge case validation', () => { + it('rejects empty filePath', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: '', + line: 1, + } as LspToolParams); + expect(result).toBe('filePath is required for goToDefinition.'); + }); + + it('rejects whitespace-only filePath', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: ' ', + line: 1, + } as LspToolParams); + expect(result).toBe('filePath is required for goToDefinition.'); + }); + + it('rejects whitespace-only query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: ' \t\n ', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + }); + }); + + describe('execute', () => { + describe('LSP disabled or unavailable', () => { + it('returns unavailable message when LSP is disabled', async () => { + const tool = createTool(undefined, false); + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 1, + character: 1, + }); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain('LSP hover is unavailable'); + expect(result.llmContent).toContain('LSP disabled or not initialized'); + }); + + it('returns unavailable message when no LSP client', async () => { + const tool = createTool(undefined, true); + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 1, + character: 1, + }); + const result = await invocation.execute(abortSignal); + // Note: operation labels are formatted (e.g., "go-to-definition") + expect(result.llmContent).toContain( + 'LSP go-to-definition is unavailable', + ); + }); + }); + + describe('goToDefinition operation', () => { + it('dispatches to definitions and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const definition: LspDefinition = { + ...createLocation(filePath, 10, 5), + serverName: 'tsserver', + }; + (client.definitions as Mock).mockResolvedValue([definition]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.objectContaining({ + uri: toUri(filePath), + range: expect.objectContaining({ + start: { line: 4, character: 9 }, // 1-based to 0-based conversion + }), + }), + undefined, + 20, + ); + expect(result.llmContent).toContain('Definitions for'); + expect(result.llmContent).toContain('1.'); + }); + + it('handles empty results', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No definitions found'); + }); + }); + + describe('findReferences operation', () => { + it('dispatches to references and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const refs: LspReference[] = [ + { ...createLocation(filePath, 10, 5), serverName: 'tsserver' }, + { ...createLocation(filePath, 20, 8) }, + ]; + (client.references as Mock).mockResolvedValue(refs); + + const invocation = tool.build({ + operation: 'findReferences', + filePath: 'src/app.ts', + line: 5, + character: 10, + includeDeclaration: true, + }); + const result = await invocation.execute(abortSignal); + + // Default limit for references is 50 + expect(client.references).toHaveBeenCalledWith( + expect.objectContaining({ uri: toUri(filePath) }), + undefined, + true, + 50, + ); + expect(result.llmContent).toContain('References for'); + expect(result.llmContent).toContain('1.'); + expect(result.llmContent).toContain('2.'); + }); + }); + + describe('hover operation', () => { + it('dispatches to hover and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const hoverResult: LspHoverResult = { + contents: '**Type**: string\n\nA sample variable.', + }; + (client.hover as Mock).mockResolvedValue(hoverResult); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + expect(client.hover).toHaveBeenCalled(); + expect(result.llmContent).toContain('Hover for'); + expect(result.llmContent).toContain('Type'); + }); + + it('handles null hover result', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.hover as Mock).mockResolvedValue(null); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No hover information found'); + }); + }); + + describe('documentSymbol operation', () => { + it('dispatches to documentSymbols and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'MyClass', + kind: 'Class', + containerName: 'app', + location: createLocation(filePath, 5, 0), + serverName: 'tsserver', + }, + { + name: 'myFunction', + kind: 'Function', + location: createLocation(filePath, 20, 0), + }, + ]; + (client.documentSymbols as Mock).mockResolvedValue(symbols); + + const invocation = tool.build({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + }); + const result = await invocation.execute(abortSignal); + + // Default limit for documentSymbols is 50 + expect(client.documentSymbols).toHaveBeenCalledWith( + toUri(filePath), + undefined, + 50, + ); + expect(result.llmContent).toContain('Document symbols for'); + expect(result.llmContent).toContain('MyClass'); + expect(result.llmContent).toContain('myFunction'); + }); + }); + + describe('workspaceSymbol operation', () => { + it('dispatches to workspaceSymbols and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'Widget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + limit: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.workspaceSymbols).toHaveBeenCalledWith('Widget', 10); + expect(result.llmContent).toContain('symbols for query "Widget"'); + expect(result.llmContent).toContain('Widget'); + }); + }); + + describe('goToImplementation operation', () => { + it('dispatches to implementations and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'impl.ts'); + const impl: LspDefinition = { + ...createLocation(filePath, 15, 2), + serverName: 'tsserver', + }; + (client.implementations as Mock).mockResolvedValue([impl]); + + const invocation = tool.build({ + operation: 'goToImplementation', + filePath: 'src/interface.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.implementations).toHaveBeenCalled(); + expect(result.llmContent).toContain('Implementations for'); + }); + }); + + describe('prepareCallHierarchy operation', () => { + it('dispatches to prepareCallHierarchy and formats results with JSON', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const item: LspCallHierarchyItem = { + name: 'myFunction', + kind: 'Function', + detail: '(param: string)', + uri: toUri(filePath), + range: { + start: { line: 10, character: 0 }, + end: { line: 20, character: 1 }, + }, + selectionRange: { + start: { line: 10, character: 9 }, + end: { line: 10, character: 19 }, + }, + serverName: 'tsserver', + }; + (client.prepareCallHierarchy as Mock).mockResolvedValue([item]); + + const invocation = tool.build({ + operation: 'prepareCallHierarchy', + filePath: 'src/app.ts', + line: 11, + character: 15, + }); + const result = await invocation.execute(abortSignal); + + expect(client.prepareCallHierarchy).toHaveBeenCalled(); + expect(result.llmContent).toContain('Call hierarchy items for'); + expect(result.llmContent).toContain('myFunction'); + expect(result.llmContent).toContain('Call hierarchy items (JSON):'); + expect(result.llmContent).toContain('"name": "myFunction"'); + }); + }); + + describe('incomingCalls operation', () => { + it('dispatches to incomingCalls and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const targetPath = resolvePath('src', 'target.ts'); + const callerPath = resolvePath('src', 'caller.ts'); + + const targetItem: LspCallHierarchyItem = { + name: 'targetFunc', + uri: toUri(targetPath), + range: { + start: { line: 5, character: 0 }, + end: { line: 10, character: 1 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + serverName: 'tsserver', + }; + + const callerItem: LspCallHierarchyItem = { + name: 'callerFunc', + kind: 'Function', + uri: toUri(callerPath), + range: { + start: { line: 20, character: 0 }, + end: { line: 30, character: 1 }, + }, + selectionRange: { + start: { line: 20, character: 9 }, + end: { line: 20, character: 19 }, + }, + }; + + const incomingCall: LspCallHierarchyIncomingCall = { + from: callerItem, + fromRanges: [ + { + start: { line: 25, character: 4 }, + end: { line: 25, character: 14 }, + }, + ], + }; + (client.incomingCalls as Mock).mockResolvedValue([incomingCall]); + + const invocation = tool.build({ + operation: 'incomingCalls', + callHierarchyItem: targetItem, + }); + const result = await invocation.execute(abortSignal); + + expect(client.incomingCalls).toHaveBeenCalledWith( + targetItem, + 'tsserver', + 20, + ); + expect(result.llmContent).toContain('Incoming calls for targetFunc'); + expect(result.llmContent).toContain('callerFunc'); + expect(result.llmContent).toContain('Incoming calls (JSON):'); + }); + }); + + describe('outgoingCalls operation', () => { + it('dispatches to outgoingCalls and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const sourcePath = resolvePath('src', 'source.ts'); + const targetPath = resolvePath('src', 'target.ts'); + + const sourceItem: LspCallHierarchyItem = { + name: 'sourceFunc', + uri: toUri(sourcePath), + range: { + start: { line: 5, character: 0 }, + end: { line: 15, character: 1 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + }; + + const targetItem: LspCallHierarchyItem = { + name: 'targetFunc', + kind: 'Function', + uri: toUri(targetPath), + range: { + start: { line: 20, character: 0 }, + end: { line: 30, character: 1 }, + }, + selectionRange: { + start: { line: 20, character: 9 }, + end: { line: 20, character: 19 }, + }, + serverName: 'tsserver', + }; + + const outgoingCall: LspCallHierarchyOutgoingCall = { + to: targetItem, + fromRanges: [ + { + start: { line: 10, character: 4 }, + end: { line: 10, character: 14 }, + }, + ], + }; + (client.outgoingCalls as Mock).mockResolvedValue([outgoingCall]); + + const invocation = tool.build({ + operation: 'outgoingCalls', + callHierarchyItem: sourceItem, + }); + const result = await invocation.execute(abortSignal); + + expect(client.outgoingCalls).toHaveBeenCalled(); + expect(result.llmContent).toContain('Outgoing calls for sourceFunc'); + expect(result.llmContent).toContain('targetFunc'); + expect(result.llmContent).toContain('Outgoing calls (JSON):'); + }); + }); + + describe('error handling', () => { + it('handles LSP client errors gracefully', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockRejectedValue( + new Error('Connection refused'), + ); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Connection refused'); + }); + + it('handles hover operation errors', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.hover as Mock).mockRejectedValue(new Error('Server timeout')); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Server timeout'); + }); + + it('handles call hierarchy errors', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.prepareCallHierarchy as Mock).mockRejectedValue( + new Error('Not supported'), + ); + + const invocation = tool.build({ + operation: 'prepareCallHierarchy', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Not supported'); + }); + }); + + describe('workspaceSymbol with references', () => { + it('fetches references for top match when available', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const refPath = resolvePath('src', 'other.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'TopWidget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + serverName: 'tsserver', + }, + ]; + const references: LspReference[] = [ + { ...createLocation(refPath, 5, 10), serverName: 'tsserver' }, + { ...createLocation(refPath, 20, 5) }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockResolvedValue(references); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'TopWidget', + }); + const result = await invocation.execute(abortSignal); + + // Should fetch references for top match + expect(client.references).toHaveBeenCalledWith( + symbols[0].location, + 'tsserver', + false, + expect.any(Number), + ); + expect(result.llmContent).toContain('References for top match'); + expect(result.llmContent).toContain('TopWidget'); + }); + + it('handles reference lookup failure gracefully', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'Widget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockRejectedValue( + new Error('References not supported'), + ); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + }); + const result = await invocation.execute(abortSignal); + + // Should still return symbols even if references fail + expect(result.llmContent).toContain('Widget'); + expect(result.llmContent).toContain('References lookup failed'); + }); + }); + + describe('returnDisplay verification', () => { + it('returns formatted display for definitions', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const definition: LspDefinition = { + ...createLocation(filePath, 10, 5), + serverName: 'tsserver', + }; + (client.definitions as Mock).mockResolvedValue([definition]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + // returnDisplay should be concise (without heading) + expect(result.returnDisplay).toBeDefined(); + expect(result.returnDisplay).toContain('1.'); + expect(result.returnDisplay).toContain('[tsserver]'); + }); + + it('returns formatted display for hover with trimmed content', async () => { + const client = createMockClient(); + const tool = createTool(client); + const hoverResult: LspHoverResult = { + contents: ' \n Type: string \n ', + }; + (client.hover as Mock).mockResolvedValue(hoverResult); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + // returnDisplay should be trimmed + expect(result.returnDisplay).toBe('Type: string'); + }); + }); + + describe('serverName and limit parameter passing', () => { + it('passes serverName to client methods', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + serverName: 'pylsp', + }); + await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.anything(), + 'pylsp', + expect.any(Number), + ); + }); + + it('passes custom limit to client methods', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + limit: 5, + }); + await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.anything(), + undefined, + 5, + ); + }); + }); + }); + + describe('schema compatibility with Claude Code', () => { + /** + * Claude Code LSP tool schema reference: + * { + * "name": "lsp", + * "input_schema": { + * "type": "object", + * "properties": { + * "operation": { "type": "string", "enum": [...] }, + * "filePath": { "type": "string" }, + * "line": { "type": "number" }, + * "character": { "type": "number" }, + * "includeDeclaration": { "type": "boolean" }, + * "query": { "type": "string" }, + * "callHierarchyItem": { ... } + * }, + * "required": ["operation"] + * } + * } + */ + + it('has correct tool name', () => { + const tool = createTool(); + expect(tool.schema.name).toBe('lsp'); + }); + + it('has operation as only required field', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + required?: string[]; + }; + expect(schema.required).toEqual(['operation']); + }); + + it('operation enum matches Claude Code exactly', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + operation?: { + enum?: string[]; + }; + }; + }; + const expectedOperations = [ + 'goToDefinition', + 'findReferences', + 'hover', + 'documentSymbol', + 'workspaceSymbol', + 'goToImplementation', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + ]; + expect(schema.properties?.operation?.enum).toEqual(expectedOperations); + }); + + it('has all Claude Code core properties', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: Record; + }; + const properties = Object.keys(schema.properties ?? {}); + + // Core properties that must match Claude Code + const coreProperties = [ + 'operation', + 'filePath', + 'line', + 'character', + 'includeDeclaration', + 'query', + 'callHierarchyItem', + ]; + + for (const prop of coreProperties) { + expect(properties).toContain(prop); + } + }); + + it('extension properties are documented', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: Record; + }; + const properties = Object.keys(schema.properties ?? {}); + + // Our extensions beyond Claude Code + const extensionProperties = ['serverName', 'limit']; + + // All properties should be either core or documented extensions + const knownProperties = [ + 'operation', + 'filePath', + 'line', + 'character', + 'includeDeclaration', + 'query', + 'callHierarchyItem', + ...extensionProperties, + ]; + + for (const prop of properties) { + expect(knownProperties).toContain(prop); + } + }); + + it('filePath property has correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + filePath?: { type?: string }; + }; + }; + expect(schema.properties?.filePath?.type).toBe('string'); + }); + + it('line and character properties have correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + line?: { type?: string }; + character?: { type?: string }; + }; + }; + expect(schema.properties?.line?.type).toBe('number'); + expect(schema.properties?.character?.type).toBe('number'); + }); + + it('includeDeclaration property has correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + includeDeclaration?: { type?: string }; + }; + }; + expect(schema.properties?.includeDeclaration?.type).toBe('boolean'); + }); + + it('callHierarchyItem has required structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspCallHierarchyItem?: { + type?: string; + properties?: Record; + required?: string[]; + }; + }; + }; + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.type).toBe('object'); + expect(itemDef?.required).toEqual([ + 'name', + 'uri', + 'range', + 'selectionRange', + ]); + expect(itemDef?.properties).toHaveProperty('name'); + expect(itemDef?.properties).toHaveProperty('kind'); + expect(itemDef?.properties).toHaveProperty('uri'); + expect(itemDef?.properties).toHaveProperty('range'); + expect(itemDef?.properties).toHaveProperty('selectionRange'); + }); + + it('supports rawKind for SymbolKind numeric preservation', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspCallHierarchyItem?: { + properties?: { + rawKind?: { type?: string }; + }; + }; + }; + }; + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.properties?.rawKind?.type).toBe('number'); + }); + + describe('schema definitions deep validation', () => { + it('has LspPosition definition with correct structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspPosition?: { + type?: string; + properties?: { + line?: { type?: string }; + character?: { type?: string }; + }; + required?: string[]; + }; + }; + }; + const posDef = schema.definitions?.LspPosition; + expect(posDef).toBeDefined(); + expect(posDef?.type).toBe('object'); + expect(posDef?.properties?.line?.type).toBe('number'); + expect(posDef?.properties?.character?.type).toBe('number'); + expect(posDef?.required).toEqual(['line', 'character']); + }); + + it('has LspRange definition with correct structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspRange?: { + type?: string; + properties?: { + start?: { $ref?: string }; + end?: { $ref?: string }; + }; + required?: string[]; + }; + }; + }; + const rangeDef = schema.definitions?.LspRange; + expect(rangeDef).toBeDefined(); + expect(rangeDef?.type).toBe('object'); + expect(rangeDef?.properties?.start?.$ref).toBe( + '#/definitions/LspPosition', + ); + expect(rangeDef?.properties?.end?.$ref).toBe( + '#/definitions/LspPosition', + ); + expect(rangeDef?.required).toEqual(['start', 'end']); + }); + + it('callHierarchyItem uses $ref for range fields', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + callHierarchyItem?: { $ref?: string }; + }; + definitions?: { + LspCallHierarchyItem?: { + properties?: { + range?: { $ref?: string }; + selectionRange?: { $ref?: string }; + }; + }; + }; + }; + // callHierarchyItem property should reference the definition + expect(schema.properties?.callHierarchyItem?.$ref).toBe( + '#/definitions/LspCallHierarchyItem', + ); + // range and selectionRange should use LspRange $ref + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.properties?.range?.$ref).toBe('#/definitions/LspRange'); + expect(itemDef?.properties?.selectionRange?.$ref).toBe( + '#/definitions/LspRange', + ); + }); + + it('all definitions are present and accounted for', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: Record; + }; + const definitionNames = Object.keys(schema.definitions ?? {}); + // Should have exactly these definitions + expect(definitionNames.sort()).toEqual([ + 'LspCallHierarchyItem', + 'LspPosition', + 'LspRange', + ]); + }); + }); + }); + + describe('invocation description', () => { + it('describes goToDefinition correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + // Uses formatted label "go-to-definition" + expect(invocation.getDescription()).toContain('go-to-definition'); + expect(invocation.getDescription()).toContain('src/app.ts:10:5'); + }); + + it('describes workspaceSymbol correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + }); + // Uses formatted label "workspace symbol search" + expect(invocation.getDescription()).toContain('workspace symbol search'); + expect(invocation.getDescription()).toContain('Widget'); + }); + + it('describes incomingCalls correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'incomingCalls', + callHierarchyItem: { + name: 'testFunc', + uri: 'file:///test.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + selectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }, + }); + // Uses formatted label "incoming calls" + expect(invocation.getDescription()).toContain('incoming calls'); + expect(invocation.getDescription()).toContain('testFunc'); + }); + }); +}); diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts new file mode 100644 index 000000000..41487830e --- /dev/null +++ b/packages/core/src/tools/lsp.ts @@ -0,0 +1,960 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspClient, + LspDefinition, + LspLocation, + LspRange, + LspReference, + LspSymbolInformation, +} from '../lsp/types.js'; + +/** + * Supported LSP operations. + */ +export type LspOperation = + | 'goToDefinition' + | 'findReferences' + | 'hover' + | 'documentSymbol' + | 'workspaceSymbol' + | 'goToImplementation' + | 'prepareCallHierarchy' + | 'incomingCalls' + | 'outgoingCalls'; + +/** + * Parameters for the unified LSP tool. + */ +export interface LspToolParams { + /** Operation to perform. */ + operation: LspOperation; + /** File path (absolute or workspace-relative). */ + filePath?: string; + /** 1-based line number when targeting a specific file location. */ + line?: number; + /** 1-based character/column number when targeting a specific file location. */ + character?: number; + /** Whether to include the declaration in reference results. */ + includeDeclaration?: boolean; + /** Query string for workspace symbol search. */ + query?: string; + /** Call hierarchy item from a previous call hierarchy operation. */ + callHierarchyItem?: LspCallHierarchyItem; + /** Optional server name override. */ + serverName?: string; + /** Optional maximum number of results. */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + } + | { error: string }; + +/** Operations that require filePath and line. */ +const LOCATION_REQUIRED_OPERATIONS = new Set([ + 'goToDefinition', + 'findReferences', + 'hover', + 'goToImplementation', + 'prepareCallHierarchy', +]); + +/** Operations that only require filePath. */ +const FILE_REQUIRED_OPERATIONS = new Set(['documentSymbol']); + +/** Operations that require query. */ +const QUERY_REQUIRED_OPERATIONS = new Set(['workspaceSymbol']); + +/** Operations that require callHierarchyItem. */ +const ITEM_REQUIRED_OPERATIONS = new Set([ + 'incomingCalls', + 'outgoingCalls', +]); + +class LspToolInvocation extends BaseToolInvocation { + constructor( + private readonly config: Config, + params: LspToolParams, + ) { + super(params); + } + + getDescription(): string { + const operationLabel = this.getOperationLabel(); + if (this.params.operation === 'workspaceSymbol') { + return `LSP ${operationLabel} for "${this.params.query ?? ''}"`; + } + if (this.params.operation === 'documentSymbol') { + return this.params.filePath + ? `LSP ${operationLabel} for ${this.params.filePath}` + : `LSP ${operationLabel}`; + } + if ( + this.params.operation === 'incomingCalls' || + this.params.operation === 'outgoingCalls' + ) { + return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`; + } + if (this.params.filePath && this.params.line !== undefined) { + return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.filePath) { + return `LSP ${operationLabel} for ${this.params.filePath}`; + } + return `LSP ${operationLabel}`; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`; + return { llmContent: message, returnDisplay: message }; + } + + switch (this.params.operation) { + case 'goToDefinition': + return this.executeDefinitions(client); + case 'findReferences': + return this.executeReferences(client); + case 'hover': + return this.executeHover(client); + case 'documentSymbol': + return this.executeDocumentSymbols(client); + case 'workspaceSymbol': + return this.executeWorkspaceSymbols(client); + case 'goToImplementation': + return this.executeImplementations(client); + case 'prepareCallHierarchy': + return this.executePrepareCallHierarchy(client); + case 'incomingCalls': + return this.executeIncomingCalls(client); + case 'outgoingCalls': + return this.executeOutgoingCalls(client); + default: { + const message = `Unsupported LSP operation: ${this.params.operation}`; + return { llmContent: message, returnDisplay: message }; + } + } + } + + private async executeDefinitions(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let definitions: LspDefinition[] = []; + try { + definitions = await client.definitions( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-definition failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!definitions.length) { + const message = `No definitions found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = definitions + .slice(0, limit) + .map( + (definition, index) => + `${index + 1}. ${this.formatLocationWithServer(definition, workspaceRoot)}`, + ); + + const heading = `Definitions for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeImplementations(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let implementations: LspDefinition[] = []; + try { + implementations = await client.implementations( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-implementation failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!implementations.length) { + const message = `No implementations found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = implementations + .slice(0, limit) + .map( + (implementation, index) => + `${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`, + ); + + const heading = `Implementations for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeReferences(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 50; + let references: LspReference[] = []; + try { + references = await client.references( + target.location, + this.params.serverName, + this.params.includeDeclaration ?? false, + limit, + ); + } catch (error) { + const message = `LSP find-references failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!references.length) { + const message = `No references found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = references + .slice(0, limit) + .map( + (reference, index) => + `${index + 1}. ${this.formatLocationWithServer(reference, workspaceRoot)}`, + ); + + const heading = `References for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeHover(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + let hoverText = ''; + try { + const result = await client.hover( + target.location, + this.params.serverName, + ); + if (result) { + hoverText = result.contents ?? ''; + } + } catch (error) { + const message = `LSP hover failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!hoverText || hoverText.trim().length === 0) { + const message = `No hover information found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const heading = `Hover for ${target.description}:`; + const content = hoverText.trim(); + return { + llmContent: `${heading}\n${content}`, + returnDisplay: content, + }; + } + + private async executeDocumentSymbols(client: LspClient): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const filePath = this.params.filePath ?? ''; + const uri = this.resolveUri(filePath, workspaceRoot); + if (!uri) { + const message = 'A valid filePath is required for document symbols.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 50; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.documentSymbols( + uri, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP document symbols failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!symbols.length) { + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const message = `No document symbols found for ${fileLabel}.`; + return { llmContent: message, returnDisplay: message }; + } + + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocationWithoutServer( + symbol.location, + workspaceRoot, + ); + const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : ''; + const kind = symbol.kind ? ` (${symbol.kind})` : ''; + const container = symbol.containerName + ? ` in ${symbol.containerName}` + : ''; + return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`; + }); + + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const heading = `Document symbols for ${fileLabel}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeWorkspaceSymbols( + client: LspClient, + ): Promise { + const limit = this.params.limit ?? 20; + const query = this.params.query ?? ''; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.workspaceSymbols(query, limit); + } catch (error) { + const message = `LSP workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!symbols.length) { + const message = `No symbols found for query "${query}".`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocationWithoutServer( + symbol.location, + workspaceRoot, + ); + const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : ''; + const kind = symbol.kind ? ` (${symbol.kind})` : ''; + const container = symbol.containerName + ? ` in ${symbol.containerName}` + : ''; + return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`; + }); + + const heading = `Found ${Math.min(symbols.length, limit)} of ${ + symbols.length + } symbols for query "${query}":`; + + // Also fetch references for the top match to provide additional context. + let referenceSection = ''; + const topSymbol = symbols[0]; + if (topSymbol) { + try { + const referenceLimit = Math.min(20, Math.max(limit, 5)); + const references = await client.references( + topSymbol.location, + topSymbol.serverName, + false, + referenceLimit, + ); + if (references.length > 0) { + const refLines = references.map((ref, index) => { + const location = this.formatLocationWithoutServer( + ref, + workspaceRoot, + ); + const serverSuffix = ref.serverName ? ` [${ref.serverName}]` : ''; + return `${index + 1}. ${location}${serverSuffix}`; + }); + referenceSection = [ + '', + `References for top match (${topSymbol.name}):`, + ...refLines, + ].join('\n'); + } + } catch (error) { + referenceSection = `\nReferences lookup failed: ${ + (error as Error)?.message || String(error) + }`; + } + } + + const llmParts = referenceSection + ? [heading, ...lines, referenceSection] + : [heading, ...lines]; + const displayParts = referenceSection + ? [...lines, referenceSection] + : [...lines]; + + return { + llmContent: llmParts.join('\n'), + returnDisplay: displayParts.join('\n'), + }; + } + + private async executePrepareCallHierarchy( + client: LspClient, + ): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let items: LspCallHierarchyItem[] = []; + try { + items = await client.prepareCallHierarchy( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP call hierarchy prepare failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!items.length) { + const message = `No call hierarchy items found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedItems = items.slice(0, limit); + const lines = slicedItems.map((item, index) => + this.formatCallHierarchyItemLine(item, index, workspaceRoot), + ); + + const heading = `Call hierarchy items for ${target.description}:`; + const jsonSection = this.formatJsonSection( + 'Call hierarchy items (JSON)', + slicedItems, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private async executeIncomingCalls(client: LspClient): Promise { + const item = this.params.callHierarchyItem; + if (!item) { + const message = 'callHierarchyItem is required for incomingCalls.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + const serverName = this.params.serverName ?? item.serverName; + let calls: LspCallHierarchyIncomingCall[] = []; + try { + calls = await client.incomingCalls(item, serverName, limit); + } catch (error) { + const message = `LSP incoming calls failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!calls.length) { + const message = `No incoming calls found for ${this.describeCallHierarchyItemFull( + item, + )}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedCalls = calls.slice(0, limit); + const lines = slicedCalls.map((call, index) => { + const targetItem = call.from; + const location = this.formatLocationWithServer( + { + uri: targetItem.uri, + range: targetItem.selectionRange, + serverName: targetItem.serverName, + }, + workspaceRoot, + ); + const kind = targetItem.kind ? ` (${targetItem.kind})` : ''; + const detail = targetItem.detail ? ` ${targetItem.detail}` : ''; + const rangeSuffix = this.formatCallRanges(call.fromRanges); + return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`; + }); + + const heading = `Incoming calls for ${this.describeCallHierarchyItemFull( + item, + )}:`; + const jsonSection = this.formatJsonSection( + 'Incoming calls (JSON)', + slicedCalls, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private async executeOutgoingCalls(client: LspClient): Promise { + const item = this.params.callHierarchyItem; + if (!item) { + const message = 'callHierarchyItem is required for outgoingCalls.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + const serverName = this.params.serverName ?? item.serverName; + let calls: LspCallHierarchyOutgoingCall[] = []; + try { + calls = await client.outgoingCalls(item, serverName, limit); + } catch (error) { + const message = `LSP outgoing calls failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!calls.length) { + const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull( + item, + )}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedCalls = calls.slice(0, limit); + const lines = slicedCalls.map((call, index) => { + const targetItem = call.to; + const location = this.formatLocationWithServer( + { + uri: targetItem.uri, + range: targetItem.selectionRange, + serverName: targetItem.serverName, + }, + workspaceRoot, + ); + const kind = targetItem.kind ? ` (${targetItem.kind})` : ''; + const detail = targetItem.detail ? ` ${targetItem.detail}` : ''; + const rangeSuffix = this.formatCallRanges(call.fromRanges); + return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`; + }); + + const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull( + item, + )}:`; + const jsonSection = this.formatJsonSection( + 'Outgoing calls (JSON)', + slicedCalls, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private resolveLocationTarget(): ResolvedTarget { + const filePath = this.params.filePath; + if (!filePath) { + return { + error: 'filePath is required for this operation.', + }; + } + if (typeof this.params.line !== 'number') { + return { + error: 'line is required for this operation.', + }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const uri = this.resolveUri(filePath, workspaceRoot); + if (!uri) { + return { + error: 'A valid filePath is required when specifying a line/character.', + }; + } + + const position = { + line: Math.max(0, Math.floor(this.params.line - 1)), + character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocationWithServer( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + }; + } + + private resolveUri(filePath: string, workspaceRoot: string): string | null { + if (!filePath) { + return null; + } + if (filePath.startsWith('file://') || filePath.includes('://')) { + return filePath; + } + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workspaceRoot, filePath); + return pathToFileURL(absolutePath).toString(); + } + + private formatLocationWithServer( + location: LspLocation & { serverName?: string }, + workspaceRoot: string, + ): string { + const start = location.range.start; + let filePath = location.uri; + + if (filePath.startsWith('file://')) { + filePath = fileURLToPath(filePath); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + + const serverSuffix = + location.serverName && location.serverName !== '' + ? ` [${location.serverName}]` + : ''; + + return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`; + } + + private formatLocationWithoutServer( + location: LspLocation, + workspaceRoot: string, + ): string { + const { uri, range } = location; + let filePath = uri; + if (uri.startsWith('file://')) { + filePath = fileURLToPath(uri); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + const line = (range.start.line ?? 0) + 1; + const character = (range.start.character ?? 0) + 1; + return `${filePath}:${line}:${character}`; + } + + private formatCallHierarchyItemLine( + item: LspCallHierarchyItem, + index: number, + workspaceRoot: string, + ): string { + const location = this.formatLocationWithServer( + { + uri: item.uri, + range: item.selectionRange, + serverName: item.serverName, + }, + workspaceRoot, + ); + const kind = item.kind ? ` (${item.kind})` : ''; + const detail = item.detail ? ` ${item.detail}` : ''; + return `${index + 1}. ${item.name}${kind}${detail} - ${location}`; + } + + private formatCallRanges(ranges: LspRange[]): string { + if (!ranges.length) { + return ''; + } + const formatted = ranges.map((range) => this.formatPosition(range.start)); + const maxShown = 3; + const shown = formatted.slice(0, maxShown); + const extra = + formatted.length > maxShown + ? `, +${formatted.length - maxShown} more` + : ''; + return ` (calls at ${shown.join(', ')}${extra})`; + } + + private formatPosition(position: LspRange['start']): string { + return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`; + } + + private formatUriForDisplay(uri: string, workspaceRoot: string): string { + let filePath = uri; + if (uri.startsWith('file://')) { + filePath = fileURLToPath(uri); + } + if (path.isAbsolute(filePath)) { + return path.relative(workspaceRoot, filePath) || '.'; + } + return filePath; + } + + private formatJsonSection(label: string, data: unknown): string { + return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`; + } + + private describeCallHierarchyItemShort(): string { + const item = this.params.callHierarchyItem; + if (!item) { + return 'call hierarchy item'; + } + return item.name || 'call hierarchy item'; + } + + private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string { + const workspaceRoot = this.config.getProjectRoot(); + const location = this.formatLocationWithServer( + { + uri: item.uri, + range: item.selectionRange, + serverName: item.serverName, + }, + workspaceRoot, + ); + return `${item.name} at ${location}`; + } + + private getOperationLabel(): string { + switch (this.params.operation) { + case 'goToDefinition': + return 'go-to-definition'; + case 'findReferences': + return 'find-references'; + case 'hover': + return 'hover'; + case 'documentSymbol': + return 'document symbols'; + case 'workspaceSymbol': + return 'workspace symbol search'; + case 'goToImplementation': + return 'go-to-implementation'; + case 'prepareCallHierarchy': + return 'prepare call hierarchy'; + case 'incomingCalls': + return 'incoming calls'; + case 'outgoingCalls': + return 'outgoing calls'; + default: + return this.params.operation; + } + } +} + +/** + * Unified LSP tool that supports multiple operations: + * - goToDefinition: Find where a symbol is defined + * - findReferences: Find all references to a symbol + * - hover: Get hover information (documentation, type info) + * - documentSymbol: Get all symbols in a document + * - workspaceSymbol: Search for symbols across the workspace + * - goToImplementation: Find implementations of an interface or abstract method + * - prepareCallHierarchy: Get call hierarchy item at a position + * - incomingCalls: Find all functions that call the given function + * - outgoingCalls: Find all functions called by the given function + */ +export class LspTool extends BaseDeclarativeTool { + static readonly Name = ToolNames.LSP; + + constructor(private readonly config: Config) { + super( + LspTool.Name, + ToolDisplayNames.LSP, + 'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.', + Kind.Other, + { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'LSP operation to execute.', + enum: [ + 'goToDefinition', + 'findReferences', + 'hover', + 'documentSymbol', + 'workspaceSymbol', + 'goToImplementation', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + ], + }, + filePath: { + type: 'string', + description: 'File path (absolute or workspace-relative).', + }, + line: { + type: 'number', + description: '1-based line number for the target location.', + }, + character: { + type: 'number', + description: + '1-based character/column number for the target location.', + }, + includeDeclaration: { + type: 'boolean', + description: + 'Include the declaration itself when looking up references.', + }, + query: { + type: 'string', + description: 'Symbol query for workspace symbol search.', + }, + callHierarchyItem: { + $ref: '#/definitions/LspCallHierarchyItem', + description: 'Call hierarchy item for incoming/outgoing calls.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + required: ['operation'], + definitions: { + LspPosition: { + type: 'object', + properties: { + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['line', 'character'], + }, + LspRange: { + type: 'object', + properties: { + start: { $ref: '#/definitions/LspPosition' }, + end: { $ref: '#/definitions/LspPosition' }, + }, + required: ['start', 'end'], + }, + LspCallHierarchyItem: { + type: 'object', + properties: { + name: { type: 'string' }, + kind: { type: 'string' }, + rawKind: { type: 'number' }, + detail: { type: 'string' }, + uri: { type: 'string' }, + range: { $ref: '#/definitions/LspRange' }, + selectionRange: { $ref: '#/definitions/LspRange' }, + data: {}, + serverName: { type: 'string' }, + }, + required: ['name', 'uri', 'range', 'selectionRange'], + }, + }, + }, + false, + false, + ); + } + + protected override validateToolParamValues( + params: LspToolParams, + ): string | null { + const operation = params.operation; + + if (LOCATION_REQUIRED_OPERATIONS.has(operation)) { + if (!params.filePath || params.filePath.trim() === '') { + return `filePath is required for ${operation}.`; + } + if (typeof params.line !== 'number') { + return `line is required for ${operation}.`; + } + } + + if (FILE_REQUIRED_OPERATIONS.has(operation)) { + if (!params.filePath || params.filePath.trim() === '') { + return `filePath is required for ${operation}.`; + } + } + + if (QUERY_REQUIRED_OPERATIONS.has(operation)) { + if (!params.query || params.query.trim() === '') { + return `query is required for ${operation}.`; + } + } + + if (ITEM_REQUIRED_OPERATIONS.has(operation)) { + if (!params.callHierarchyItem) { + return `callHierarchyItem is required for ${operation}.`; + } + } + + if (params.line !== undefined && params.line < 1) { + return 'line must be a positive number.'; + } + if (params.character !== undefined && params.character < 1) { + return 'character must be a positive number.'; + } + if (params.limit !== undefined && params.limit <= 0) { + return 'limit must be a positive number.'; + } + + return null; + } + + protected createInvocation( + params: LspToolParams, + ): ToolInvocation { + return new LspToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 1e0600b0a..d9a5ef772 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -28,6 +28,8 @@ export const ToolNames = { LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', LSP_FIND_REFERENCES: 'lsp_find_references', + /** Unified LSP tool supporting all LSP operations. */ + LSP: 'lsp', } as const; /** @@ -54,6 +56,8 @@ export const ToolDisplayNames = { LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol', LSP_GO_TO_DEFINITION: 'LspGoToDefinition', LSP_FIND_REFERENCES: 'LspFindReferences', + /** Unified LSP tool display name. */ + LSP: 'Lsp', } as const; // Migration from old tool names to new tool names diff --git a/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md b/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md new file mode 100644 index 000000000..e3660926e --- /dev/null +++ b/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md @@ -0,0 +1,255 @@ +# LSP 工具重构计划 + +## 背景 + +对比 Claude Code 的 LSP tool 定义和当前实现,发现以下关键差异: + +### Claude Code 的设计(目标) + +```json +{ + "name": "LSP", + "operations": [ + "goToDefinition", + "findReferences", + "hover", + "documentSymbol", + "workspaceSymbol", + "goToImplementation", + "prepareCallHierarchy", + "incomingCalls", + "outgoingCalls" + ], + "required_params": ["operation", "filePath", "line", "character"] +} +``` + +### 当前实现 + +- **分散的 3 个工具**:`lsp_go_to_definition`, `lsp_find_references`, `lsp_workspace_symbol` +- **支持 3 个操作**:goToDefinition, findReferences, workspaceSymbol +- **缺少 6 个操作**:hover, documentSymbol, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls + +--- + +## 重构目标 + +1. **统一工具设计**:将 3 个分散的工具合并为 1 个统一的 `LSP` 工具 +2. **扩展操作支持**:添加缺失的 6 个 LSP 操作 +3. **简化参数设计**:统一使用 operation + filePath + line + character 方式 +4. **保持向后兼容**:旧工具名称继续支持 + +--- + +## 实施步骤 + +### Step 1: 扩展类型定义 + +**文件**: `packages/core/src/lsp/types.ts` + +新增类型: + +```typescript +// Hover 结果 +interface LspHoverResult { + contents: string | { language: string; value: string }[]; + range?: LspRange; +} + +// Call Hierarchy 类型 +interface LspCallHierarchyItem { + name: string; + kind: number; + uri: string; + range: LspRange; + selectionRange: LspRange; + detail?: string; + data?: unknown; + serverName?: string; +} + +interface LspCallHierarchyIncomingCall { + from: LspCallHierarchyItem; + fromRanges: LspRange[]; +} + +interface LspCallHierarchyOutgoingCall { + to: LspCallHierarchyItem; + fromRanges: LspRange[]; +} +``` + +扩展 LspClient 接口: + +```typescript +interface LspClient { + // 现有方法 + workspaceSymbols(query, limit): Promise; + definitions(location, serverName, limit): Promise; + references( + location, + serverName, + includeDeclaration, + limit, + ): Promise; + + // 新增方法 + hover(location, serverName): Promise; + documentSymbols(uri, serverName, limit): Promise; + implementations(location, serverName, limit): Promise; + prepareCallHierarchy(location, serverName): Promise; + incomingCalls( + item, + serverName, + limit, + ): Promise; + outgoingCalls( + item, + serverName, + limit, + ): Promise; +} +``` + +### Step 2: 创建统一 LSP 工具 + +**新文件**: `packages/core/src/tools/lsp.ts` + +参数设计(采用灵活的操作特定验证): + +```typescript +interface LspToolParams { + operation: LspOperation; // 必填 + filePath?: string; // 位置类操作必填 + line?: number; // 精确位置操作必填 (1-based) + character?: number; // 可选 (1-based) + query?: string; // workspaceSymbol 必填 + callHierarchyItem?: object; // incomingCalls/outgoingCalls 必填 + serverName?: string; // 可选 + limit?: number; // 可选 + includeDeclaration?: boolean; // findReferences 可选 +} + +type LspOperation = + | 'goToDefinition' + | 'findReferences' + | 'hover' + | 'documentSymbol' + | 'workspaceSymbol' + | 'goToImplementation' + | 'prepareCallHierarchy' + | 'incomingCalls' + | 'outgoingCalls'; +``` + +各操作参数要求: +| 操作 | filePath | line | character | query | callHierarchyItem | +|------|----------|------|-----------|-------|-------------------| +| goToDefinition | 必填 | 必填 | 可选 | - | - | +| findReferences | 必填 | 必填 | 可选 | - | - | +| hover | 必填 | 必填 | 可选 | - | - | +| documentSymbol | 必填 | - | - | - | - | +| workspaceSymbol | - | - | - | 必填 | - | +| goToImplementation | 必填 | 必填 | 可选 | - | - | +| prepareCallHierarchy | 必填 | 必填 | 可选 | - | - | +| incomingCalls | - | - | - | - | 必填 | +| outgoingCalls | - | - | - | - | 必填 | + +### Step 3: 扩展 NativeLspService + +**文件**: `packages/cli/src/services/lsp/NativeLspService.ts` + +新增 6 个方法: + +1. `hover()` - 调用 `textDocument/hover` +2. `documentSymbols()` - 调用 `textDocument/documentSymbol` +3. `implementations()` - 调用 `textDocument/implementation` +4. `prepareCallHierarchy()` - 调用 `textDocument/prepareCallHierarchy` +5. `incomingCalls()` - 调用 `callHierarchy/incomingCalls` +6. `outgoingCalls()` - 调用 `callHierarchy/outgoingCalls` + +### Step 4: 更新工具名称映射 + +**文件**: `packages/core/src/tools/tool-names.ts` + +```typescript +export const ToolNames = { + LSP: 'lsp', // 新增 + // 保留旧名称(标记 deprecated) + LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', + LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', + LSP_FIND_REFERENCES: 'lsp_find_references', +} as const; + +export const ToolNamesMigration = { + lsp_go_to_definition: ToolNames.LSP, + lsp_find_references: ToolNames.LSP, + lsp_workspace_symbol: ToolNames.LSP, +} as const; +``` + +### Step 5: 更新 Config 工具注册 + +**文件**: `packages/core/src/config/config.ts` + +- 注册新的统一 `LspTool` +- 保留旧工具注册(向后兼容) +- 可通过配置选项禁用旧工具 + +### Step 6: 向后兼容处理 + +**文件**: 现有 3 个 LSP 工具文件 + +- 添加 `@deprecated` 标记 +- 添加 deprecation warning 日志 +- 可选:内部转发到新工具实现 + +--- + +## 关键文件列表 + +| 文件路径 | 操作 | +| --------------------------------------------------- | --------------------------- | +| `packages/core/src/lsp/types.ts` | 修改 - 扩展类型定义 | +| `packages/core/src/tools/lsp.ts` | 新建 - 统一 LSP 工具 | +| `packages/core/src/tools/tool-names.ts` | 修改 - 添加工具名称 | +| `packages/cli/src/services/lsp/NativeLspService.ts` | 修改 - 添加 6 个新方法 | +| `packages/core/src/config/config.ts` | 修改 - 注册新工具 | +| `packages/core/src/tools/lsp-*.ts` (3个) | 修改 - 添加 deprecated 标记 | + +--- + +## 验证方式 + +1. **单元测试**: + - 新 `LspTool` 参数验证测试 + - 各操作执行逻辑测试 + - 向后兼容测试 + +2. **集成测试**: + - TypeScript Language Server 测试所有 9 个操作 + - Python LSP 测试 + - 多服务器场景测试 + +3. **手动验证**: + - 在 VS Code 中测试各操作 + - 验证旧工具名称仍可使用 + - 验证 deprecation warning 输出 + +--- + +## 风险与缓解 + +| 风险 | 缓解措施 | +| --------------------------- | -------------------------------------- | +| 部分 LSP 服务器不支持新操作 | 独立 try-catch,返回清晰错误消息 | +| Call Hierarchy 两步流程复杂 | 文档说明使用方式,提供示例 | +| 向后兼容增加维护成本 | 设置明确弃用时间线,配置选项控制旧工具 | + +--- + +## 后续优化建议 + +1. 考虑是否需要支持更多 LSP 操作(如 `textDocument/rename`, `textDocument/formatting`) +2. 考虑添加 LSP 服务器能力查询,动态返回支持的操作列表 +3. 考虑优化 TypeScript Server warm-up 逻辑,减少首次调用延迟