From a67a8d027734b932cd3b2ded14632b28c4196ac6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 5 Jan 2026 01:42:05 +0800 Subject: [PATCH 01/15] wip(cli): support lsp --- .vscode/settings.json | 7 +- cclsp-integration-plan.md | 147 +++ package-lock.json | 8 + package.json | 1 + packages/cli/LSP_DEBUGGING_GUIDE.md | 107 ++ packages/cli/src/config/config.test.ts | 82 ++ packages/cli/src/config/config.ts | 82 +- packages/cli/src/config/lspSettingsSchema.ts | 38 + packages/cli/src/config/settings.ts | 52 +- packages/cli/src/config/settingsSchema.ts | 41 + packages/cli/src/gemini.tsx | 2 + .../src/services/lsp/LspConnectionFactory.ts | 358 ++++++ .../src/services/lsp/NativeLspService.test.ts | 126 ++ .../cli/src/services/lsp/NativeLspService.ts | 1075 +++++++++++++++++ packages/core/src/config/config.ts | 49 + packages/core/src/index.ts | 3 + packages/core/src/lsp/types.ts | 54 + .../core/src/tools/lsp-find-references.ts | 309 +++++ .../core/src/tools/lsp-go-to-definition.ts | 309 +++++ .../core/src/tools/lsp-workspace-symbol.ts | 180 +++ packages/core/src/tools/tool-names.ts | 8 + 21 files changed, 3035 insertions(+), 3 deletions(-) create mode 100644 cclsp-integration-plan.md create mode 100644 packages/cli/LSP_DEBUGGING_GUIDE.md create mode 100644 packages/cli/src/config/lspSettingsSchema.ts create mode 100644 packages/cli/src/services/lsp/LspConnectionFactory.ts create mode 100644 packages/cli/src/services/lsp/NativeLspService.test.ts create mode 100644 packages/cli/src/services/lsp/NativeLspService.ts create mode 100644 packages/core/src/lsp/types.ts create mode 100644 packages/core/src/tools/lsp-find-references.ts create mode 100644 packages/core/src/tools/lsp-go-to-definition.ts create mode 100644 packages/core/src/tools/lsp-workspace-symbol.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ea2735760..8331c3876 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,10 @@ "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "vitest.disableWorkspaceWarning": true + "vitest.disableWorkspaceWarning": true, + "lsp": { + "enabled": true, + "allowed": ["typescript-language-server"], + "excluded": ["gopls"] + } } diff --git a/cclsp-integration-plan.md b/cclsp-integration-plan.md new file mode 100644 index 000000000..7105653a7 --- /dev/null +++ b/cclsp-integration-plan.md @@ -0,0 +1,147 @@ +# Qwen Code CLI LSP 集成实现方案分析 + +## 1. 项目概述 + +本方案旨在将 LSP(Language Server Protocol)能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。 + +## 2. 技术方案对比 + +### 2.1 Piebald-AI/claude-code-lsps 方案 +- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由 +- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装 +- **安全**: LSP 子进程以用户权限运行,无内置信任门控 +- **功能覆盖**: 可以暴露完整的 LSP 表面(hover、诊断、代码操作、重命名等) + +### 2.2 原生 LSP 客户端方案(推荐方案) +- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接 +- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置 +- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示) +- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等) + +### 2.3 cclsp + MCP 方案(备选) +- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接 +- **用户配置**: 需要 MCP 配置 +- **安全**: 通过 MCP 安全控制 +- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具 + +## 3. 原生 LSP 集成详细计划 + +### 3.1 方案选择 +- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验 +- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接 +- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略 + +### 3.2 实现步骤 + +#### 3.2.1 创建原生 LSP 服务 +在 `packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理: +- 工作区语言检测 +- 自动发现和启动语言服务器 +- 与现有文档/编辑模型同步 +- LSP 能力直接暴露给代理 + +#### 3.2.2 配置支持 +- 支持内置预设配置(常见语言服务器) +- 支持用户自定义 `.lsp.json` 配置文件 +- 与 MCP 配置共存,共享信任控制 + +#### 3.2.3 集成启动流程 +- 在 `packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成 +- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制 +- 处理沙箱预检和主运行的重复调用问题 + +#### 3.2.4 功能标志配置 +- 在 `packages/cli/src/config/settingsSchema.ts` 中添加新的设置项 +- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能 +- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置 + +#### 3.2.5 安全控制 +- 与 MCP 共享相同的安全控制机制 +- 在信任工作区中自动启用,在非信任工作区中提示用户 +- 实现路径允许列表和进程启动确认 + +#### 3.2.6 错误处理与用户通知 +- 检测缺失的语言服务器并提供安装命令 +- 通过现有 MCP 状态 UI 显示错误信息 +- 实现重试/退避机制,检测沙箱环境并抑制自动启动 + +### 3.3 需要确认的不确定项 + +1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调 + +2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP + +3. **功能开关设计**:开关应该是全局级别的,LSP 和 MCP 可独立启用/禁用 + +4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑 + +5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步 + +6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项 + +7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制 + +### 3.4 安全考虑 + +- 与 MCP 共享相同的安全控制模型 +- 仅在受信任工作区中启用自动 LSP 功能 +- 提供用户确认机制用于启动新的 LSP 服务器 +- 防止路径劫持,使用安全的路径解析 + +### 3.5 高级 LSP 功能支持 + +- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等 +- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置 +- **性能优化**: 优化 LSP 服务器启动时间和内存使用 + +### 3.6 用户体验 + +- 提供安装提示而非自动安装 +- 在统一的状态界面显示 LSP 和 MCP 服务器状态 +- 提供独立开关让用户控制 LSP 和 MCP 功能 +- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息 + +## 4. 实施总结 + +### 4.1 已完成的工作 +1. **NativeLspService 类**:创建了核心服务类,包含语言检测、配置合并、LSP 连接管理等功能 +2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理 +3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测 +4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并 +5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证 +6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点 + +### 4.2 关键组件 + +#### 4.2.1 LspConnectionFactory +- 使用 `vscode-jsonrpc` 和 `vscode-languageserver-protocol` 实现 LSP 连接 +- 支持 stdio 传输方式,可以扩展支持 TCP 传输 +- 提供连接创建、初始化和关闭的完整生命周期管理 + +#### 4.2.2 NativeLspService +- **语言检测**:扫描项目文件和配置文件来识别编程语言 +- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置 +- **LSP 服务器管理**:启动、停止和状态管理 +- **安全控制**:与 MCP 共享的信任和确认机制 + +#### 4.2.3 配置架构 +- **内置预设**:为常见语言提供默认 LSP 服务器配置 +- **用户配置**:支持 `.lsp.json` 文件格式 +- **Claude 兼容**:可导入 Claude Code 的 LSP 配置 + +### 4.3 依赖管理 +- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信 +- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递 +- 使用 `vscode-languageserver-textdocument` 管理文档版本 + +### 4.4 安全特性 +- 工作区信任检查 +- 用户确认机制(对于非信任工作区) +- 命令存在性验证 +- 路径安全性检查 + +## 5. 总结 + +原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略,但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。 + +该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 330b90e08..5f9c347ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", + "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", @@ -10807,6 +10808,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index c239067ff..fd60b2a1c 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", + "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md new file mode 100644 index 000000000..7833e8b87 --- /dev/null +++ b/packages/cli/LSP_DEBUGGING_GUIDE.md @@ -0,0 +1,107 @@ +# LSP 调试指南 + +本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。 + +## 1. 启用调试模式 + +CLI 支持调试模式,可以提供额外的日志信息: + +```bash +# 使用 debug 标志运行 +qwen --debug [你的命令] + +# 或设置环境变量 +DEBUG=true qwen [你的命令] +DEBUG_MODE=true qwen [你的命令] +``` + +## 2. LSP 配置选项 + +LSP 功能通过设置系统配置,包含以下选项: + +- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`) +- `lsp.allowed`: 允许的 LSP 服务器名称白名单 +- `lsp.excluded`: 排除的 LSP 服务器名称黑名单 + +在 settings.json 中的示例配置: +```json +{ + "lsp": { + "enabled": true, + "allowed": ["typescript-language-server", "pylsp"], + "excluded": ["gopls"] + } +} +``` + +## 3. NativeLspService 调试功能 + +`NativeLspService` 类包含几个调试功能: + +### 3.1 控制台日志 +服务向控制台输出状态消息: +- `LSP 服务器 ${name} 启动成功` - 服务器成功启动 +- `LSP 服务器 ${name} 启动失败` - 服务器启动失败 +- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现 + +### 3.2 错误处理 +服务具有全面的错误处理和详细的错误消息 + +### 3.3 状态跟踪 +您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态 + +## 4. 调试命令 + +```bash +# 启用调试运行 +qwen --debug --prompt "调试 LSP 功能" + +# 检查在您的项目中检测到哪些 LSP 服务器 +# 系统会自动检测语言和相应的 LSP 服务器 +``` + +## 5. 手动 LSP 服务器配置 + +您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器: + +```json +{ + "python": { + "command": "pylsp", + "args": [], + "transport": "stdio", + "trustRequired": true + } +} +``` + +## 6. LSP 问题排查 + +### 6.1 检查 LSP 服务器是否已安装 +- 对于 TypeScript/JavaScript: `typescript-language-server` +- 对于 Python: `pylsp` +- 对于 Go: `gopls` + +### 6.2 验证工作区信任 +- LSP 服务器可能需要受信任的工作区才能启动 +- 检查 `security.folderTrust.enabled` 设置 + +### 6.3 查看日志 +- 查找以 `LSP 服务器` 开头的控制台消息 +- 检查命令存在性和路径安全性问题 + +## 7. LSP 服务启动流程 + +LSP 服务的启动遵循以下流程: + +1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言 +2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄 +3. **启动服务器**: `start()` 方法启动所有服务器句柄 +4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换 + +## 8. 调试技巧 + +- 使用 `--debug` 标志查看详细的启动过程 +- 检查工作区是否受信任(影响 LSP 服务器启动) +- 确认 LSP 服务器命令在系统 PATH 中可用 +- 使用 `getStatus()` 方法监控服务器运行状态 \ No newline at end of file diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0b95f7857..59ccd5509 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,6 +21,23 @@ import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.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, +})); + +vi.mock('../services/lsp/NativeLspService.js', () => ({ + NativeLspService: nativeLspServiceMock, +})); + vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi .fn() @@ -518,6 +535,16 @@ describe('loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); + mockDiscoverAndPrepare.mockReset(); + mockStartLsp.mockReset(); + mockWorkspaceSymbols.mockReset(); + mockWorkspaceSymbols.mockResolvedValue([]); + nativeLspServiceMock.mockReset(); + nativeLspServiceMock.mockImplementation(() => ({ + discoverAndPrepare: mockDiscoverAndPrepare, + start: mockStartLsp, + workspaceSymbols: mockWorkspaceSymbols, + })); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); }); @@ -587,6 +614,61 @@ describe('loadCliConfig', () => { expect(config.getShowMemoryUsage()).toBe(false); }); + it('should initialize native LSP service when enabled', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + lsp: { + enabled: true, + allowed: ['typescript-language-server'], + excluded: ['pylsp'], + }, + }; + + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + ); + + expect(config.isLspEnabled()).toBe(true); + 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 options = nativeLspServiceMock.mock.calls[0][5]; + expect(options?.allowedServers).toEqual(['typescript-language-server']); + expect(options?.excludedServers).toEqual(['pylsp']); + }); + + it('should skip native LSP startup when startLsp option is false', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { lsp: { enabled: true } }; + + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + undefined, + { startLsp: false }, + ); + + expect(config.isLspEnabled()).toBe(true); + expect(nativeLspServiceMock).not.toHaveBeenCalled(); + expect(mockDiscoverAndPrepare).not.toHaveBeenCalled(); + }); + it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7cd7d685a..0715725e6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -23,9 +23,11 @@ import { InputFormat, OutputFormat, SessionService, + ideContextStore, type ResumedSessionData, type FileFilteringOptions, type MCPServerConfig, + type LspClient, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; @@ -42,6 +44,7 @@ import { annotateActiveExtensions } from './extension.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +import { NativeLspService } from '../services/lsp/NativeLspService.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -147,6 +150,44 @@ export interface CliArgs { channel: string | undefined; } +export interface LoadCliConfigOptions { + /** + * Whether to start the native LSP service during config load. + * Disable when doing preflight runs (e.g., sandbox preparation). + */ + startLsp?: boolean; +} + +class NativeLspClient implements LspClient { + constructor(private readonly service: NativeLspService) {} + + workspaceSymbols(query: string, limit?: number) { + return this.service.workspaceSymbols(query, limit); + } + + definitions( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.definitions(location, serverName, limit); + } + + references( + location: Parameters[0], + serverName?: string, + includeDeclaration?: boolean, + limit?: number, + ) { + return this.service.references( + location, + serverName, + includeDeclaration, + limit, + ); + } +} + function normalizeOutputFormat( format: string | OutputFormat | undefined, ): OutputFormat | undefined { @@ -655,6 +696,7 @@ export async function loadCliConfig( extensionEnablementManager: ExtensionEnablementManager, argv: CliArgs, cwd: string = process.cwd(), + options: LoadCliConfigOptions = {}, ): Promise { const debugMode = isDebugMode(argv); @@ -731,6 +773,12 @@ export async function loadCliConfig( ); let mcpServers = mergeMcpServers(settings, activeExtensions); + + // LSP configuration derived from settings; defaults to disabled for safety. + const lspEnabled = settings.lsp?.enabled ?? false; + const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed; + const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded; + let lspClient: LspClient | undefined; const question = argv.promptInteractive || argv.prompt || ''; const inputFormat: InputFormat = (argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT; @@ -934,7 +982,7 @@ export async function loadCliConfig( } } - return new Config({ + const config = new Config({ sessionId, sessionData, embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, @@ -1037,7 +1085,39 @@ export async function loadCliConfig( // always be true and the settings file can never disable recording. chatRecording: argv.chatRecording ?? settings.general?.chatRecording ?? true, + lsp: { + enabled: lspEnabled, + allowed: lspAllowed, + excluded: lspExcluded, + }, }); + + const shouldStartLsp = options.startLsp ?? true; + if (shouldStartLsp && lspEnabled) { + try { + const lspService = new NativeLspService( + config, + config.getWorkspaceContext(), + appEvents, + fileService, + ideContextStore, + { + allowedServers: lspAllowed, + excludedServers: lspExcluded, + requireTrustedWorkspace: folderTrust, + }, + ); + + await lspService.discoverAndPrepare(); + await lspService.start(); + lspClient = new NativeLspClient(lspService); + config.setLspClient(lspClient); + } catch (err) { + logger.warn('Failed to initialize native LSP service:', err); + } + } + + return config; } function allowedMcpServers( diff --git a/packages/cli/src/config/lspSettingsSchema.ts b/packages/cli/src/config/lspSettingsSchema.ts new file mode 100644 index 000000000..c8d3f1b33 --- /dev/null +++ b/packages/cli/src/config/lspSettingsSchema.ts @@ -0,0 +1,38 @@ +import type { JSONSchema7 } from 'json-schema'; + +export const lspSettingsSchema: JSONSchema7 = { + type: 'object', + properties: { + 'lsp.enabled': { + type: 'boolean', + default: true, + description: '启用 LSP 语言服务器协议支持' + }, + 'lsp.allowed': { + type: 'array', + items: { + type: 'string' + }, + default: [], + description: '允许运行的 LSP 服务器列表' + }, + 'lsp.excluded': { + type: 'array', + items: { + type: 'string' + }, + default: [], + description: '禁止运行的 LSP 服务器列表' + }, + 'lsp.autoDetect': { + type: 'boolean', + default: true, + description: '自动检测项目语言并启动相应 LSP 服务器' + }, + 'lsp.serverTimeout': { + type: 'number', + default: 10000, + description: 'LSP 服务器启动超时时间(毫秒)' + } + } +}; \ No newline at end of file diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ae29074b2..1f49fadd4 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -160,6 +160,34 @@ export function getSystemDefaultsPath(): string { ); } +function getVsCodeSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, '.vscode', 'settings.json'); +} + +function loadVsCodeSettings(workspaceDir: string): Settings { + const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir); + try { + if (fs.existsSync(vscodeSettingsPath)) { + const content = fs.readFileSync(vscodeSettingsPath, 'utf-8'); + const rawSettings: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof rawSettings !== 'object' || + rawSettings === null || + Array.isArray(rawSettings) + ) { + console.error(`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`); + return {}; + } + + return rawSettings as Settings; + } + } catch (error: unknown) { + console.error(`Error loading VS Code settings from ${vscodeSettingsPath}:`, getErrorMessage(error)); + } + return {}; +} + export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { @@ -632,6 +660,9 @@ export function loadSettings( workspaceDir, ).getWorkspaceSettingsPath(); + // Load VS Code settings as an additional source of configuration + const vscodeSettings = loadVsCodeSettings(workspaceDir); + const loadAndMigrate = ( filePath: string, scope: SettingScope, @@ -736,6 +767,14 @@ export function loadSettings( userSettings = resolveEnvVarsInObject(userResult.settings); workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); + // Merge VS Code settings into workspace settings (VS Code settings take precedence) + workspaceSettings = customDeepMerge( + getMergeStrategyForPath, + {}, + workspaceSettings, + vscodeSettings, + ) as Settings; + // Support legacy theme names if (userSettings.ui?.theme === 'VS') { userSettings.ui.theme = DefaultLight.name; @@ -749,11 +788,13 @@ export function loadSettings( } // For the initial trust check, we can only use user and system settings. + // We also include VS Code settings as they may contain trust-related settings const initialTrustCheckSettings = customDeepMerge( getMergeStrategyForPath, {}, systemSettings, userSettings, + vscodeSettings, // Include VS Code settings ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; @@ -767,9 +808,18 @@ export function loadSettings( isTrusted, ); + // Add VS Code settings to the temp merged settings for environment loading + // Since loadEnvironment depends on settings, we need to consider VS Code settings as well + const tempMergedSettingsWithVsCode = customDeepMerge( + getMergeStrategyForPath, + {}, + tempMergedSettings, + vscodeSettings, + ) as Settings; + // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle - loadEnvironment(tempMergedSettings); + loadEnvironment(tempMergedSettingsWithVsCode); // Create LoadedSettings first diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2fe467ba9..c392caf1f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1007,6 +1007,47 @@ const SETTINGS_SCHEMA = { }, }, }, + lsp: { + type: 'object', + label: 'LSP', + category: 'LSP', + requiresRestart: true, + default: {}, + description: 'Settings for the native Language Server Protocol integration.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable LSP', + category: 'LSP', + requiresRestart: true, + default: false, + description: + 'Enable the native LSP client to connect to language servers discovered in the workspace.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allow LSP Servers', + category: 'LSP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Optional allowlist of LSP server names. If set, only matching servers will start.', + showInDialog: false, + }, + excluded: { + type: 'array', + label: 'Exclude LSP Servers', + category: 'LSP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Optional blocklist of LSP server names that should not start.', + showInDialog: false, + }, + }, + }, useSmartEdit: { type: 'boolean', label: 'Use Smart Edit', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b05f12453..0aeb285a0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -248,6 +248,8 @@ export async function main() { [], new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), argv, + undefined, + { startLsp: false }, ); if ( diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts new file mode 100644 index 000000000..e18262ed6 --- /dev/null +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -0,0 +1,358 @@ +import * as cp from 'node:child_process'; +import * as net from 'node:net'; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + timer: NodeJS.Timeout; +} + +class JsonRpcConnection { + private buffer = ''; + private nextId = 1; + private disposed = false; + private pendingRequests = new Map(); + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; + private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + + constructor( + private readonly writer: (data: string) => void, + private readonly disposer?: () => void, + ) {} + + listen(readable: NodeJS.ReadableStream): void { + readable.on('data', (chunk: Buffer) => this.handleData(chunk)); + readable.on('error', (error) => + this.disposePending( + error instanceof Error ? error : new Error(String(error)), + ), + ); + } + + send(message: JsonRpcMessage): void { + this.writeMessage(message); + } + + onNotification(handler: (notification: JsonRpcMessage) => void): void { + this.notificationHandlers.push(handler); + } + + onRequest(handler: (request: JsonRpcMessage) => Promise): void { + this.requestHandlers.push(handler); + } + + async initialize(params: unknown): Promise { + return this.sendRequest('initialize', params); + } + + async shutdown(): Promise { + try { + await this.sendRequest('shutdown', {}); + } catch (_error) { + // Ignore shutdown errors – the server may already be gone. + } finally { + this.end(); + } + } + + request(method: string, params: unknown): Promise { + return this.sendRequest(method, params); + } + + end(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.disposePending(); + this.disposer?.(); + } + + private sendRequest(method: string, params: unknown): Promise { + if (this.disposed) { + return Promise.resolve(undefined); + } + + const id = this.nextId++; + const payload: JsonRpcMessage = { + jsonrpc: '2.0', + id, + method, + params, + }; + + const requestPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`LSP request timeout: ${method}`)); + }, 15000); + + this.pendingRequests.set(id, { resolve, reject, timer }); + }); + + this.writeMessage(payload); + return requestPromise; + } + + private async handleServerRequest(message: JsonRpcMessage): Promise { + const handler = this.requestHandlers[this.requestHandlers.length - 1]; + if (!handler) { + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: `Method not supported: ${message.method}`, + }, + }); + return; + } + + try { + const result = await handler(message); + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + result: result ?? null, + }); + } catch (error) { + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: (error as Error).message ?? 'Internal error', + }, + }); + } + } + + private handleData(chunk: Buffer): void { + if (this.disposed) { + return; + } + + this.buffer += chunk.toString('utf8'); + + while (true) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + break; + } + + const header = this.buffer.slice(0, headerEnd); + const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header); + if (!lengthMatch) { + this.buffer = this.buffer.slice(headerEnd + 4); + continue; + } + + const contentLength = Number(lengthMatch[1]); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + + if (this.buffer.length < messageEnd) { + break; + } + + const body = this.buffer.slice(messageStart, messageEnd); + this.buffer = this.buffer.slice(messageEnd); + + try { + const message = JSON.parse(body); + this.routeMessage(message); + } catch { + // ignore malformed messages + } + } + } + + private routeMessage(message: JsonRpcMessage): void { + if (typeof message?.id !== 'undefined' && !message.method) { + const pending = this.pendingRequests.get(message.id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(message.id); + if (message.error) { + pending.reject( + new Error(message.error.message || 'LSP request failed'), + ); + } else { + pending.resolve(message.result); + } + return; + } + + if (message?.method && typeof message.id !== 'undefined') { + void this.handleServerRequest(message); + return; + } + + if (message?.method) { + for (const handler of this.notificationHandlers) { + try { + handler(message); + } catch { + // ignore handler errors + } + } + } + } + + private writeMessage(message: JsonRpcMessage): void { + if (this.disposed) { + return; + } + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`; + this.writer(header + json); + } + + private disposePending(error?: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error ?? new Error('LSP connection closed')); + } + this.pendingRequests.clear(); + } +} + +interface LspConnection { + connection: JsonRpcConnection; + process?: cp.ChildProcess; + socket?: net.Socket; +} + +interface JsonRpcMessage { + jsonrpc: string; + id?: number | string; + method?: string; + params?: any; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +export class LspConnectionFactory { + /** + * 创建基于 stdio 的 LSP 连接 + */ + static async createStdioConnection( + command: string, + args: string[], + options?: cp.SpawnOptions, + ): Promise { + return new Promise((resolve, reject) => { + const spawnOptions: cp.SpawnOptions = { + stdio: 'pipe', + ...options, + }; + const processInstance = cp.spawn(command, args, spawnOptions); + + const timeoutId = setTimeout(() => { + reject(new Error('LSP server spawn timeout')); + if (!processInstance.killed) { + processInstance.kill(); + } + }, 10000); + + processInstance.once('error', (error) => { + clearTimeout(timeoutId); + reject(new Error(`Failed to spawn LSP server: ${error.message}`)); + }); + + processInstance.once('spawn', () => { + clearTimeout(timeoutId); + + if (!processInstance.stdout || !processInstance.stdin) { + reject(new Error('LSP server stdio not available')); + return; + } + + const connection = new JsonRpcConnection( + (payload) => processInstance.stdin?.write(payload), + () => processInstance.stdin?.end(), + ); + + connection.listen(processInstance.stdout); + processInstance.once('exit', () => connection.end()); + processInstance.once('close', () => connection.end()); + + resolve({ + connection, + process: processInstance, + }); + }); + }); + } + + /** + * 创建基于 TCP 的 LSP 连接 + */ + static async createTcpConnection( + host: string, + port: number, + ): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + + const timeoutId = setTimeout(() => { + reject(new Error('LSP server connection timeout')); + socket.destroy(); + }, 10000); + + const onError = (error: Error) => { + clearTimeout(timeoutId); + reject(new Error(`Failed to connect to LSP server: ${error.message}`)); + }; + + socket.once('error', onError); + + socket.on('connect', () => { + clearTimeout(timeoutId); + socket.off('error', onError); + + const connection = new JsonRpcConnection( + (payload) => socket.write(payload), + () => socket.destroy(), + ); + connection.listen(socket); + socket.once('close', () => connection.end()); + socket.once('error', () => connection.end()); + + resolve({ + connection, + socket, + }); + }); + }); + } + + /** + * 关闭 LSP 连接 + */ + static async closeConnection(lspConnection: LspConnection): Promise { + if (lspConnection.connection) { + try { + await lspConnection.connection.shutdown(); + } catch (e) { + console.warn('LSP shutdown failed:', e); + } finally { + lspConnection.connection.end(); + } + } + + if (lspConnection.process && !lspConnection.process.killed) { + lspConnection.process.kill(); + } + + if (lspConnection.socket && !lspConnection.socket.destroyed) { + lspConnection.socket.destroy(); + } + } +} diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts new file mode 100644 index 000000000..1fadd620a --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { NativeLspService } from './NativeLspService.js'; +import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; +import { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import { EventEmitter } from 'events'; +import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; +import { IdeContextStore } from '@qwen-code/qwen-code-core'; + +// 模拟依赖项 +class MockConfig { + rootPath = '/test/workspace'; + + isTrustedFolder(): boolean { + return true; + } + + get(key: string) { + return undefined; + } + + getProjectRoot(): string { + return this.rootPath; + } +} + +class MockWorkspaceContext { + rootPath = '/test/workspace'; + + async fileExists(path: string): Promise { + return path.endsWith('.json') || path.includes('package.json'); + } + + async readFile(path: string): Promise { + if (path.includes('.lsp.json')) { + return JSON.stringify({ + 'typescript': { + 'command': 'typescript-language-server', + 'args': ['--stdio'], + 'transport': 'stdio' + } + }); + } + return '{}'; + } + + resolvePath(path: string): string { + return this.rootPath + '/' + path; + } + + isPathWithinWorkspace(path: string): boolean { + return true; + } + + getDirectories(): string[] { + return [this.rootPath]; + } +} + +class MockFileDiscoveryService { + async discoverFiles(root: string, options: any): Promise { + // 模拟发现一些文件 + return [ + '/test/workspace/src/index.ts', + '/test/workspace/src/utils.ts', + '/test/workspace/server.py', + '/test/workspace/main.go' + ]; + } + + shouldIgnoreFile(): boolean { + return false; + } +} + +class MockIdeContextStore { + // 模拟 IDE 上下文存储 +} + +describe('NativeLspService', () => { + let lspService: NativeLspService; + let mockConfig: MockConfig; + let mockWorkspace: MockWorkspaceContext; + let mockFileDiscovery: MockFileDiscoveryService; + let mockIdeStore: MockIdeContextStore; + let eventEmitter: EventEmitter; + + beforeEach(() => { + mockConfig = new MockConfig(); + mockWorkspace = new MockWorkspaceContext(); + mockFileDiscovery = new MockFileDiscoveryService(); + mockIdeStore = new MockIdeContextStore(); + eventEmitter = new EventEmitter(); + + lspService = new NativeLspService( + mockConfig as any, + mockWorkspace as any, + eventEmitter, + mockFileDiscovery as any, + mockIdeStore as any + ); + }); + + test('should initialize correctly', () => { + expect(lspService).toBeDefined(); + }); + + test('should detect languages from workspace files', async () => { + // 这个测试需要修改,因为我们无法直接访问私有方法 + await lspService.discoverAndPrepare(); + const status = lspService.getStatus(); + + // 检查服务是否已准备就绪 + expect(status).toBeDefined(); + }); + + test('should merge built-in presets with user configs', async () => { + await lspService.discoverAndPrepare(); + + const status = lspService.getStatus(); + // 检查服务是否已准备就绪 + expect(status).toBeDefined(); + }); +}); + +// 注意:实际的单元测试需要适当的测试框架配置 +// 这里只是一个结构示例 diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts new file mode 100644 index 000000000..aca87e3e6 --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -0,0 +1,1075 @@ +import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; +import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; +import type { IdeContextStore } from '@qwen-code/qwen-code-core'; +import { LspConnectionFactory } from './LspConnectionFactory.js'; +import type { + LspLocation, + LspDefinition, + LspReference, + LspSymbolInformation, +} from '@qwen-code/qwen-code-core'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import { globSync } from 'glob'; + +// 定义 LSP 初始化选项的类型 +interface LspInitializationOptions { + [key: string]: any; +} + +// 定义 LSP 服务器配置类型 +interface LspServerConfig { + name: string; + languages: string[]; + command: string; + args: string[]; + transport: 'stdio' | 'tcp'; + initializationOptions?: LspInitializationOptions; + rootUri: string; + trustRequired?: boolean; +} + +// 定义 LSP 连接接口 +interface LspConnectionInterface { + listen: (readable: NodeJS.ReadableStream) => void; + send: (message: any) => void; + onNotification: (handler: (notification: any) => void) => void; + onRequest: (handler: (request: any) => Promise) => void; + request: (method: string, params: any) => Promise; + initialize: (params: any) => Promise; + shutdown: () => Promise; + end: () => void; +} + +// 定义 LSP 服务器状态 +type LspServerStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; + +// 定义 LSP 服务器句柄 +interface LspServerHandle { + config: LspServerConfig; + status: LspServerStatus; + connection?: LspConnectionInterface; + process?: ChildProcess; + error?: Error; + warmedUp?: boolean; +} + +interface NativeLspServiceOptions { + allowedServers?: string[]; + excludedServers?: string[]; + requireTrustedWorkspace?: boolean; + workspaceRoot?: string; +} + +export class NativeLspService { + private serverHandles: Map = new Map(); + private config: CoreConfig; + private workspaceContext: WorkspaceContext; + private fileDiscoveryService: FileDiscoveryService; + private allowedServers?: string[]; + private excludedServers?: string[]; + private requireTrustedWorkspace: boolean; + private workspaceRoot: string; + + constructor( + config: CoreConfig, + workspaceContext: WorkspaceContext, + _eventEmitter: EventEmitter, // 未使用,用下划线前缀 + fileDiscoveryService: FileDiscoveryService, + _ideContextStore: IdeContextStore, // 未使用,用下划线前缀 + options: NativeLspServiceOptions = {}, + ) { + this.config = config; + this.workspaceContext = workspaceContext; + this.fileDiscoveryService = fileDiscoveryService; + this.allowedServers = options.allowedServers?.filter(Boolean); + this.excludedServers = options.excludedServers?.filter(Boolean); + this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; + this.workspaceRoot = + options.workspaceRoot ?? (config as any).getProjectRoot(); + } + + /** + * 发现并准备 LSP 服务器 + */ + async discoverAndPrepare(): Promise { + const workspaceTrusted = this.config.isTrustedFolder(); + this.serverHandles.clear(); + + // 检查工作区是否受信任 + if (this.requireTrustedWorkspace && !workspaceTrusted) { + console.log('工作区不受信任,跳过 LSP 服务器发现'); + return; + } + + // 检测工作区中的语言 + const detectedLanguages = await this.detectLanguages(); + + // 合并配置:内置预设 + 用户 .lsp.json + 可选 cclsp 兼容转换 + const serverConfigs = await this.mergeConfigs(detectedLanguages); + + // 创建服务器句柄 + for (const config of serverConfigs) { + this.serverHandles.set(config.name, { + config, + status: 'NOT_STARTED', + }); + } + } + + /** + * 启动所有 LSP 服务器 + */ + async start(): Promise { + for (const [name, handle] of this.serverHandles) { + await this.startServer(name, handle); + } + } + + /** + * 停止所有 LSP 服务器 + */ + async stop(): Promise { + for (const [name, handle] of this.serverHandles) { + await this.stopServer(name, handle); + } + this.serverHandles.clear(); + } + + /** + * 获取 LSP 服务器状态 + */ + getStatus(): Map { + const statusMap = new Map(); + for (const [name, handle] of this.serverHandles) { + statusMap.set(name, handle.status); + } + return statusMap; + } + + /** + * Workspace symbol search across all ready LSP servers. + */ + async workspaceSymbols( + query: string, + limit = 50, + ): Promise { + const results: LspSymbolInformation[] = []; + + for (const [serverName, handle] of this.serverHandles) { + if (handle.status !== 'READY' || !handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + let response = await handle.connection.request('workspace/symbol', { + query, + }); + if ( + this.isTypescriptServer(handle) && + this.isNoProjectErrorResponse(response) + ) { + await this.warmupTypescriptServer(handle, true); + response = await handle.connection.request('workspace/symbol', { + query, + }); + } + if (!Array.isArray(response)) { + continue; + } + for (const item of response) { + const symbol = this.normalizeSymbolResult(item, serverName); + if (symbol) { + results.push(symbol); + } + if (results.length >= limit) { + return results.slice(0, limit); + } + } + } catch (error) { + console.warn(`LSP workspace/symbol failed for ${serverName}:`, error); + } + } + + return results.slice(0, limit); + } + + /** + * 跳转到定义 + */ + async definitions( + 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); + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/definition', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const definitions: LspDefinition[] = []; + for (const def of candidates) { + const normalized = this.normalizeLocationResult(def, name); + if (normalized) { + definitions.push(normalized); + if (definitions.length >= limit) { + return definitions.slice(0, limit); + } + } + } + if (definitions.length > 0) { + return definitions.slice(0, limit); + } + } catch (error) { + console.warn(`LSP textDocument/definition failed for ${name}:`, error); + } + } + + return []; + } + + /** + * 查找引用 + */ + async references( + location: LspLocation, + serverName?: string, + includeDeclaration = false, + 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); + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/references', + { + textDocument: { uri: location.uri }, + position: location.range.start, + context: { includeDeclaration }, + }, + ); + if (!Array.isArray(response)) { + continue; + } + const refs: LspReference[] = []; + for (const ref of response) { + const normalized = this.normalizeLocationResult(ref, name); + if (normalized) { + refs.push(normalized); + } + if (refs.length >= limit) { + return refs.slice(0, limit); + } + } + if (refs.length > 0) { + return refs.slice(0, limit); + } + } catch (error) { + console.warn(`LSP textDocument/references failed for ${name}:`, error); + } + } + + return []; + } + + /** + * 检测工作区中的编程语言 + */ + private async detectLanguages(): Promise { + const patterns = ['**/*.{js,ts,jsx,tsx,py,go,rs,java,cpp,php,rb,cs}']; + const excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + ]; + + const files = new Set(); + const searchRoots = this.workspaceContext.getDirectories(); + + for (const root of searchRoots) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: excludePatterns, + absolute: true, + nodir: true, + }); + + for (const match of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(match)) { + continue; + } + files.add(match); + } + } catch (_error) { + // Ignore glob errors for missing/invalid directories + } + } + } + + // 统计不同语言的文件数量 + const languageCounts = new Map(); + for (const file of files) { + const ext = path.extname(file).slice(1).toLowerCase(); + if (ext) { + const lang = this.mapExtensionToLanguage(ext); + if (lang) { + languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); + } + } + } + + // 也可以通过特定的配置文件来检测语言 + const rootMarkers = await this.detectRootMarkers(); + for (const marker of rootMarkers) { + const lang = this.mapMarkerToLanguage(marker); + if (lang) { + // 使用安全的数字操作避免 NaN + const currentCount = languageCounts.get(lang) || 0; + languageCounts.set(lang, currentCount + 100); // 给配置文件更高的权重 + } + } + + // 返回检测到的语言,按数量排序 + return Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([lang]) => lang); + } + + /** + * 检测根目录标记文件 + */ + private async detectRootMarkers(): Promise { + const markers = new Set(); + const commonMarkers = [ + 'package.json', + 'tsconfig.json', + 'pyproject.toml', + 'go.mod', + 'Cargo.toml', + 'pom.xml', + 'build.gradle', + 'composer.json', + 'Gemfile', + 'mix.exs', + 'deno.json', + ]; + + for (const root of this.workspaceContext.getDirectories()) { + for (const marker of commonMarkers) { + try { + const fullPath = path.join(root, marker); + if (fs.existsSync(fullPath)) { + markers.add(marker); + } + } catch (_error) { + // ignore missing files + } + } + } + + return Array.from(markers); + } + + /** + * 将文件扩展名映射到编程语言 + */ + private mapExtensionToLanguage(ext: string): string | null { + const extToLang: { [key: string]: string } = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + php: 'php', + rb: 'ruby', + cs: 'csharp', + vue: 'vue', + svelte: 'svelte', + html: 'html', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + }; + + return extToLang[ext] || null; + } + + /** + * 将根目录标记映射到编程语言 + */ + private mapMarkerToLanguage(marker: string): string | null { + const markerToLang: { [key: string]: string } = { + 'package.json': 'javascript', + 'tsconfig.json': 'typescript', + 'pyproject.toml': 'python', + 'go.mod': 'go', + 'Cargo.toml': 'rust', + 'pom.xml': 'java', + 'build.gradle': 'java', + 'composer.json': 'php', + Gemfile: 'ruby', + '*.sln': 'csharp', + 'mix.exs': 'elixir', + 'deno.json': 'deno', + }; + + return markerToLang[marker] || null; + } + + private normalizeLocationResult( + item: any, + serverName: string, + ): LspReference | null { + const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const range = + item?.range ?? + item?.targetSelectionRange ?? + item?.targetRange ?? + item?.target?.range; + + if (!uri || !range?.start || !range?.end) { + return null; + } + + return { + uri, + range: { + start: { + line: Number(range.start.line ?? 0), + character: Number(range.start.character ?? 0), + }, + end: { + line: Number(range.end.line ?? 0), + character: Number(range.end.character ?? 0), + }, + }, + serverName, + }; + } + + private normalizeSymbolResult( + item: any, + serverName: string, + ): LspSymbolInformation | null { + const location = item?.location ?? item?.target ?? item; + const range = + location?.range ?? location?.targetRange ?? item?.range ?? undefined; + + if (!location?.uri || !range?.start || !range?.end) { + return null; + } + + return { + name: item?.name ?? item?.label ?? 'symbol', + kind: item?.kind ? String(item.kind) : undefined, + containerName: item?.containerName ?? item?.container, + location: { + uri: location.uri, + range: { + start: { + line: Number(range.start.line ?? 0), + character: Number(range.start.character ?? 0), + }, + end: { + line: Number(range.end.line ?? 0), + character: Number(range.end.character ?? 0), + }, + }, + }, + serverName, + }; + } + + /** + * 合并配置:内置预设 + 用户配置 + 兼容层 + */ + private async mergeConfigs( + detectedLanguages: string[], + ): Promise { + // 内置预设配置 + const presets = this.getBuiltInPresets(detectedLanguages); + + // 用户 .lsp.json 配置(如果存在) + const userConfigs = await this.loadUserConfigs(); + + // 合并配置,用户配置优先级更高 + const mergedConfigs = [...presets]; + + for (const userConfig of userConfigs) { + // 查找是否有同名的预设配置,如果有则替换 + const existingIndex = mergedConfigs.findIndex( + (c) => c.name === userConfig.name, + ); + if (existingIndex !== -1) { + mergedConfigs[existingIndex] = userConfig; + } else { + mergedConfigs.push(userConfig); + } + } + + return mergedConfigs; + } + + /** + * 获取内置预设配置 + */ + private getBuiltInPresets(detectedLanguages: string[]): LspServerConfig[] { + const presets: LspServerConfig[] = []; + + // 将目录路径转换为文件 URI 格式 + const rootUri = pathToFileURL(this.workspaceRoot).toString(); + + // 根据检测到的语言生成对应的 LSP 服务器配置 + if ( + detectedLanguages.includes('typescript') || + detectedLanguages.includes('javascript') + ) { + presets.push({ + name: 'typescript-language-server', + languages: [ + 'typescript', + 'javascript', + 'typescriptreact', + 'javascriptreact', + ], + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('python')) { + presets.push({ + name: 'pylsp', + languages: ['python'], + command: 'pylsp', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('go')) { + presets.push({ + name: 'gopls', + languages: ['go'], + command: 'gopls', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + // 可以根据需要添加更多语言的预设配置 + + return presets; + } + + /** + * 加载用户 .lsp.json 配置 + */ + private async loadUserConfigs(): Promise { + const configs: LspServerConfig[] = []; + + try { + const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); + if (fs.existsSync(lspConfigPath)) { + 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 [ + string, + any, + ]) { + // 转换为文件 URI 格式 + const rootUri = pathToFileURL(this.workspaceRoot).toString(); + + // 验证 command 不为 undefined + if (!serverSpec.command) { + console.warn(`LSP 配置错误: ${langId} 缺少 command 属性`); + continue; + } + + const serverConfig: LspServerConfig = { + name: serverSpec.command, + languages: [langId], + command: serverSpec.command, + args: serverSpec.args || [], + transport: serverSpec.transport || 'stdio', + initializationOptions: serverSpec.initializationOptions, + rootUri: rootUri, + trustRequired: serverSpec.trustRequired ?? true, + }; + + configs.push(serverConfig); + } + } + } + } catch (e) { + console.warn('加载用户 .lsp.json 配置失败:', e); + } + + return configs; + } + + /** + * 启动单个 LSP 服务器 + */ + private async startServer( + name: string, + handle: LspServerHandle, + ): Promise { + if (this.excludedServers?.includes(name)) { + console.log(`LSP 服务器 ${name} 在排除列表中,跳过启动`); + handle.status = 'FAILED'; + return; + } + + if (this.allowedServers && !this.allowedServers.includes(name)) { + console.log(`LSP 服务器 ${name} 不在允许列表中,跳过启动`); + handle.status = 'FAILED'; + return; + } + + const workspaceTrusted = this.config.isTrustedFolder(); + if ( + (this.requireTrustedWorkspace || handle.config.trustRequired) && + !workspaceTrusted + ) { + console.log(`LSP 服务器 ${name} 需要受信任的工作区,跳过启动`); + handle.status = 'FAILED'; + return; + } + + // 请求用户确认 + const consent = await this.requestUserConsent( + name, + handle.config, + workspaceTrusted, + ); + if (!consent) { + console.log(`用户拒绝启动 LSP 服务器 ${name}`); + handle.status = 'FAILED'; + return; + } + + // 检查命令是否存在 + if (!(await this.commandExists(handle.config.command))) { + console.warn(`LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`); + handle.status = 'FAILED'; + return; + } + + // 检查路径安全性 + if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + console.warn( + `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } + + try { + handle.status = 'IN_PROGRESS'; + + // 创建 LSP 连接 + const connection = await this.createLspConnection(handle.config); + handle.connection = connection.connection; + handle.process = connection.process; + + // 初始化 LSP 服务器 + await this.initializeLspServer(connection, handle.config); + + handle.status = 'READY'; + console.log(`LSP 服务器 ${name} 启动成功`); + } catch (error) { + handle.status = 'FAILED'; + handle.error = error as Error; + console.error(`LSP 服务器 ${name} 启动失败:`, error); + } + } + + /** + * 停止单个 LSP 服务器 + */ + private async stopServer( + name: string, + handle: LspServerHandle, + ): Promise { + if (handle.connection) { + try { + await handle.connection.shutdown(); + handle.connection.end(); + } catch (error) { + console.error(`关闭 LSP 服务器 ${name} 时出错:`, error); + } + } else if (handle.process && !handle.process.killed) { + handle.process.kill(); + } + handle.connection = undefined; + handle.process = undefined; + handle.status = 'NOT_STARTED'; + } + + /** + * 创建 LSP 连接 + */ + private async createLspConnection(config: LspServerConfig): Promise<{ + connection: LspConnectionInterface; + process: ChildProcess; + shutdown: () => Promise; + exit: () => void; + initialize: (params: any) => Promise; + }> { + if (config.transport === 'stdio') { + // 修复:使用 cwd 作为 cwd 而不是 rootUri + const lspConnection = await LspConnectionFactory.createStdioConnection( + config.command, + config.args, + { cwd: this.workspaceRoot }, + ); + + return { + connection: lspConnection.connection as LspConnectionInterface, + process: lspConnection.process as ChildProcess, + shutdown: async () => { + await lspConnection.connection.shutdown(); + }, + exit: () => { + if (lspConnection.process && !lspConnection.process.killed) { + (lspConnection.process as ChildProcess).kill(); + } + lspConnection.connection.end(); + }, + initialize: async (params: any) => { + return lspConnection.connection.initialize(params); + }, + }; + } else if (config.transport === 'tcp') { + // 如果需要 TCP 支持,可以扩展此部分 + throw new Error('TCP transport not yet implemented'); + } else { + throw new Error(`Unsupported transport: ${config.transport}`); + } + } + + /** + * 初始化 LSP 服务器 + */ + private async initializeLspServer( + connection: Awaited>, + config: LspServerConfig, + ): Promise { + const workspaceFolder = { + name: path.basename(this.workspaceRoot) || this.workspaceRoot, + uri: config.rootUri, + }; + + const initializeParams = { + processId: process.pid, + rootUri: config.rootUri, + rootPath: this.workspaceRoot, + workspaceFolders: [workspaceFolder], + capabilities: { + textDocument: { + completion: { dynamicRegistration: true }, + hover: { dynamicRegistration: true }, + definition: { dynamicRegistration: true }, + references: { dynamicRegistration: true }, + documentSymbol: { dynamicRegistration: true }, + codeAction: { dynamicRegistration: true }, + }, + workspace: { + workspaceFolders: { supported: true }, + }, + }, + initializationOptions: config.initializationOptions, + }; + + await connection.initialize(initializeParams); + + // Send initialized notification and workspace folders change to help servers (e.g. tsserver) + // create projects in the correct workspace. + connection.connection.send({ + jsonrpc: '2.0', + method: 'initialized', + params: {}, + }); + connection.connection.send({ + jsonrpc: '2.0', + method: 'workspace/didChangeWorkspaceFolders', + params: { + event: { + added: [workspaceFolder], + removed: [], + }, + }, + }); + + // Warm up TypeScript server by opening a workspace file so it can create a project. + if (config.name.includes('typescript')) { + try { + const tsFile = this.findFirstTypescriptFile(); + if (tsFile) { + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const text = fs.readFileSync(tsFile, 'utf-8'); + connection.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + } + } catch (error) { + console.warn('TypeScript LSP warm-up failed:', error); + } + } + } + + /** + * 检查命令是否存在 + */ + private async commandExists(command: string): Promise { + // 实现命令存在性检查 + return new Promise((resolve) => { + let settled = false; + const child = spawn(command, ['--version'], { + stdio: ['ignore', 'ignore', 'ignore'], + cwd: this.workspaceRoot, + }); + + child.on('error', () => { + settled = true; + resolve(false); + }); + + child.on('exit', (code) => { + if (settled) { + return; + } + // 如果命令存在,通常会返回 0 或其他非错误码 + // 有些命令的 --version 选项可能返回非 0,但不会抛出错误 + resolve(code !== 127); // 127 通常表示命令不存在 + }); + + // 设置超时,避免长时间等待 + setTimeout(() => { + settled = true; + child.kill(); + resolve(false); + }, 2000); + }); + } + + /** + * 检查路径安全性 + */ + private isPathSafe(command: string, workspacePath: 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); + return ( + resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || + resolvedPath === resolvedWorkspacePath + ); + } + + /** + * 请求用户确认启动 LSP 服务器 + */ + private async requestUserConsent( + serverName: string, + serverConfig: LspServerConfig, + workspaceTrusted: boolean, + ): Promise { + if (workspaceTrusted) { + return true; // 在受信任工作区中自动允许 + } + + if (this.requireTrustedWorkspace || serverConfig.trustRequired) { + console.log( + `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command})`, + ); + return false; + } + + console.log( + `未受信任的工作区,LSP 服务器 ${serverName} 标记为 trustRequired=false,将谨慎尝试启动`, + ); + return true; + } + + /** + * Find a representative TypeScript/JavaScript file to warm up tsserver. + */ + private findFirstTypescriptFile(): string | undefined { + const patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx']; + const excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + ]; + + for (const root of this.workspaceContext.getDirectories()) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: excludePatterns, + absolute: true, + nodir: true, + }); + for (const file of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(file)) { + continue; + } + return file; + } + } catch (_error) { + // ignore glob errors + } + } + } + + return undefined; + } + + private isTypescriptServer(handle: LspServerHandle): boolean { + return handle.config.name.includes('typescript'); + } + + private isNoProjectErrorResponse(response: any): boolean { + if (!response) { + return false; + } + const message = + typeof response === 'string' + ? response + : typeof response?.message === 'string' + ? response.message + : ''; + return message.includes('No Project'); + } + + /** + * Ensure tsserver has at least one file open so navto/navtree requests succeed. + */ + private async warmupTypescriptServer( + handle: LspServerHandle, + force = false, + ): Promise { + if (!handle.connection || !this.isTypescriptServer(handle)) { + return; + } + if (handle.warmedUp && !force) { + return; + } + const tsFile = this.findFirstTypescriptFile(); + if (!tsFile) { + return; + } + handle.warmedUp = true; + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : tsFile.endsWith('.jsx') + ? 'javascriptreact' + : tsFile.endsWith('.js') + ? 'javascript' + : 'typescript'; + try { + const text = fs.readFileSync(tsFile, 'utf-8'); + handle.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + // Give tsserver a moment to build the project. + await new Promise((resolve) => setTimeout(resolve, 150)); + } catch (error) { + console.warn('TypeScript server warm-up failed:', error); + } + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..33231de94 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -61,6 +61,10 @@ import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { WebSearchTool } from '../tools/web-search/index.js'; 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 type { LspClient } from '../lsp/types.js'; // Other modules import { ideContextStore } from '../ide/ideContext.js'; @@ -281,6 +285,12 @@ export interface ConfigParameters { toolCallCommand?: string; mcpServerCommand?: string; mcpServers?: Record; + lsp?: { + enabled?: boolean; + allowed?: string[]; + excluded?: string[]; + }; + lspClient?: LspClient; userMemory?: string; geminiMdFileCount?: number; approvalMode?: ApprovalMode; @@ -413,6 +423,10 @@ export class Config { private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; private mcpServers: Record | undefined; + private readonly lspEnabled: boolean; + private readonly lspAllowed?: string[]; + private readonly lspExcluded?: string[]; + private lspClient?: LspClient; private sessionSubagents: SubagentConfig[]; private userMemory: string; private sdkMode: boolean; @@ -521,6 +535,10 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.lspEnabled = params.lsp?.enabled ?? false; + this.lspAllowed = params.lsp?.allowed?.filter(Boolean); + this.lspExcluded = params.lsp?.excluded?.filter(Boolean); + this.lspClient = params.lspClient; this.sessionSubagents = params.sessionSubagents ?? []; this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; @@ -896,6 +914,32 @@ export class Config { this.mcpServers = { ...this.mcpServers, ...servers }; } + isLspEnabled(): boolean { + return this.lspEnabled; + } + + getLspAllowed(): string[] | undefined { + return this.lspAllowed; + } + + getLspExcluded(): string[] | undefined { + return this.lspExcluded; + } + + getLspClient(): LspClient | undefined { + return this.lspClient; + } + + /** + * Allows wiring an LSP client after Config construction but before initialize(). + */ + setLspClient(client: LspClient | undefined): void { + if (this.initialized) { + throw new Error('Cannot set LSP client after initialization'); + } + this.lspClient = client; + } + getSessionSubagents(): SubagentConfig[] { return this.sessionSubagents; } @@ -1403,6 +1447,11 @@ export class Config { if (this.getWebSearchConfig()) { registerCoreTool(WebSearchTool, this); } + if (this.isLspEnabled() && this.getLspClient()) { + registerCoreTool(LspGoToDefinitionTool, this); + registerCoreTool(LspFindReferencesTool, this); + registerCoreTool(LspWorkspaceSymbolTool, this); + } await registry.discoverAllTools(); console.debug('ToolRegistry created', registry.getAllToolNames()); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 56680403b..2ec73e236 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export * from './skills/index.js'; // Export prompt logic export * from './prompts/mcp-prompts.js'; +export * from './lsp/types.js'; // Export specific tool logic export * from './tools/read-file.js'; @@ -99,6 +100,8 @@ export * from './tools/memoryTool.js'; export * from './tools/shell.js'; export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; +export * from './tools/lsp-go-to-definition.js'; +export * from './tools/lsp-find-references.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts new file mode 100644 index 000000000..2a412d660 --- /dev/null +++ b/packages/core/src/lsp/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface LspPosition { + line: number; + character: number; +} + +export interface LspRange { + start: LspPosition; + end: LspPosition; +} + +export interface LspLocation { + uri: string; + range: LspRange; +} + +export interface LspLocationWithServer extends LspLocation { + serverName?: string; +} + +export interface LspSymbolInformation { + name: string; + kind?: string; + location: LspLocation; + containerName?: string; + serverName?: string; +} + +export interface LspReference extends LspLocationWithServer {} + +export interface LspDefinition extends LspLocationWithServer {} + +export interface LspClient { + workspaceSymbols( + query: string, + limit?: number, + ): Promise; + definitions( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + references( + location: LspLocation, + serverName?: string, + includeDeclaration?: boolean, + limit?: number, + ): Promise; +} diff --git a/packages/core/src/tools/lsp-find-references.ts b/packages/core/src/tools/lsp-find-references.ts new file mode 100644 index 000000000..078586e49 --- /dev/null +++ b/packages/core/src/tools/lsp-find-references.ts @@ -0,0 +1,309 @@ +/** + * @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 { + LspClient, + LspLocation, + LspReference, +} from '../lsp/types.js'; + +export interface LspFindReferencesParams { + /** + * Symbol name to resolve if a file/position is not provided. + */ + symbol?: string; + /** + * File path (absolute or workspace-relative). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + file?: string; + /** + * File URI (e.g., file:///path/to/file). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + uri?: 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 results (default: false). + */ + includeDeclaration?: boolean; + /** + * Optional server name override. + */ + serverName?: string; + /** + * Optional maximum number of results. + */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + serverName?: string; + fromSymbol: boolean; + } + | { error: string }; + +class LspFindReferencesInvocation extends BaseToolInvocation< + LspFindReferencesParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspFindReferencesParams, + ) { + super(params); + } + + getDescription(): string { + if (this.params.symbol) { + return `LSP find-references(查引用) for symbol "${this.params.symbol}"`; + } + if (this.params.file && this.params.line !== undefined) { + return `LSP find-references(查引用) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.uri && this.params.line !== undefined) { + return `LSP find-references(查引用) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; + } + return 'LSP find-references(查引用)'; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP find-references is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const target = await this.resolveTarget(client); + 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, + target.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) => { + return `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`; + }); + + const heading = `References for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async resolveTarget( + client: Pick, + ): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const lineProvided = typeof this.params.line === 'number'; + const character = this.params.character ?? 1; + + if ((this.params.file || this.params.uri) && lineProvided) { + const uri = this.resolveUri(workspaceRoot); + if (!uri) { + return { + error: + 'A valid file path or URI is required when specifying a line/character.', + }; + } + const position = { + line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), + character: Math.max(0, Math.floor(character - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocation( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + serverName: this.params.serverName, + fromSymbol: false, + }; + } + + if (this.params.symbol) { + try { + const symbols = await client.workspaceSymbols(this.params.symbol, 5); + if (!symbols.length) { + return { + error: `No symbols found for query "${this.params.symbol}".`, + }; + } + const top = symbols[0]; + return { + location: top.location, + description: `symbol "${this.params.symbol}"`, + serverName: this.params.serverName ?? top.serverName, + fromSymbol: true, + }; + } catch (error) { + return { + error: `Workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`, + }; + } + } + + return { + error: + 'Provide a symbol name or a file plus line (and optional character) to use find-references.', + }; + } + + private resolveUri(workspaceRoot: string): string | null { + if (this.params.uri) { + if ( + this.params.uri.startsWith('file://') || + this.params.uri.includes('://') + ) { + return this.params.uri; + } + const absoluteUriPath = path.isAbsolute(this.params.uri) + ? this.params.uri + : path.resolve(workspaceRoot, this.params.uri); + return pathToFileURL(absoluteUriPath).toString(); + } + + if (this.params.file) { + const absolutePath = path.isAbsolute(this.params.file) + ? this.params.file + : path.resolve(workspaceRoot, this.params.file); + return pathToFileURL(absolutePath).toString(); + } + + return null; + } + + private formatLocation( + location: LspReference | (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}`; + } +} + +export class LspFindReferencesTool extends BaseDeclarativeTool< + LspFindReferencesParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_FIND_REFERENCES; + + constructor(private readonly config: Config) { + super( + LspFindReferencesTool.Name, + ToolDisplayNames.LSP_FIND_REFERENCES, + 'Use LSP find-references for a symbol or a specific file location(查引用,优先于 grep 搜索)。', + Kind.Other, + { + type: 'object', + properties: { + symbol: { + type: 'string', + description: + 'Symbol name to resolve when a file/position is not provided.', + }, + file: { + type: 'string', + description: + 'File path (absolute or workspace-relative). Requires `line`.', + }, + uri: { + type: 'string', + description: + 'File URI (file:///...). Requires `line` when provided.', + }, + 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.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + }, + false, + false, + ); + } + + protected createInvocation( + params: LspFindReferencesParams, + ): ToolInvocation { + return new LspFindReferencesInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/lsp-go-to-definition.ts b/packages/core/src/tools/lsp-go-to-definition.ts new file mode 100644 index 000000000..cfbc92d32 --- /dev/null +++ b/packages/core/src/tools/lsp-go-to-definition.ts @@ -0,0 +1,309 @@ +/** + * @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 { + LspClient, + LspDefinition, + LspLocation, +} from '../lsp/types.js'; + +export interface LspGoToDefinitionParams { + /** + * Symbol name to resolve if a file/position is not provided. + */ + symbol?: string; + /** + * File path (absolute or workspace-relative). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + file?: string; + /** + * File URI (e.g., file:///path/to/file). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + uri?: 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; + /** + * Optional server name override. + */ + serverName?: string; + /** + * Optional maximum number of results. + */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + serverName?: string; + fromSymbol: boolean; + } + | { error: string }; + +class LspGoToDefinitionInvocation extends BaseToolInvocation< + LspGoToDefinitionParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspGoToDefinitionParams, + ) { + super(params); + } + + getDescription(): string { + if (this.params.symbol) { + return `LSP go-to-definition(跳转定义) for symbol "${this.params.symbol}"`; + } + if (this.params.file && this.params.line !== undefined) { + return `LSP go-to-definition(跳转定义) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.uri && this.params.line !== undefined) { + return `LSP go-to-definition(跳转定义) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; + } + return 'LSP go-to-definition(跳转定义)'; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP go-to-definition is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const target = await this.resolveTarget(client); + 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, + target.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-definition failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + // Fallback to the resolved symbol location if the server does not return definitions. + if (!definitions.length && target.fromSymbol) { + definitions = [ + { + ...target.location, + serverName: target.serverName, + }, + ]; + } + + 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) => { + return `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`; + }); + + const heading = `Definitions for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async resolveTarget( + client: Pick, + ): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const lineProvided = typeof this.params.line === 'number'; + const character = this.params.character ?? 1; + + if ((this.params.file || this.params.uri) && lineProvided) { + const uri = this.resolveUri(workspaceRoot); + if (!uri) { + return { + error: + 'A valid file path or URI is required when specifying a line/character.', + }; + } + const position = { + line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), + character: Math.max(0, Math.floor(character - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocation( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + serverName: this.params.serverName, + fromSymbol: false, + }; + } + + if (this.params.symbol) { + try { + const symbols = await client.workspaceSymbols(this.params.symbol, 5); + if (!symbols.length) { + return { + error: `No symbols found for query "${this.params.symbol}".`, + }; + } + const top = symbols[0]; + return { + location: top.location, + description: `symbol "${this.params.symbol}"`, + serverName: this.params.serverName ?? top.serverName, + fromSymbol: true, + }; + } catch (error) { + return { + error: `Workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`, + }; + } + } + + return { + error: + 'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.', + }; + } + + private resolveUri(workspaceRoot: string): string | null { + if (this.params.uri) { + if ( + this.params.uri.startsWith('file://') || + this.params.uri.includes('://') + ) { + return this.params.uri; + } + const absoluteUriPath = path.isAbsolute(this.params.uri) + ? this.params.uri + : path.resolve(workspaceRoot, this.params.uri); + return pathToFileURL(absoluteUriPath).toString(); + } + + if (this.params.file) { + const absolutePath = path.isAbsolute(this.params.file) + ? this.params.file + : path.resolve(workspaceRoot, this.params.file); + return pathToFileURL(absolutePath).toString(); + } + + return null; + } + + private formatLocation( + location: LspDefinition | (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}`; + } +} + +export class LspGoToDefinitionTool extends BaseDeclarativeTool< + LspGoToDefinitionParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_GO_TO_DEFINITION; + + constructor(private readonly config: Config) { + super( + LspGoToDefinitionTool.Name, + ToolDisplayNames.LSP_GO_TO_DEFINITION, + 'Use LSP go-to-definition for a symbol or a specific file location(跳转定义,优先于 grep 搜索)。', + Kind.Other, + { + type: 'object', + properties: { + symbol: { + type: 'string', + description: + 'Symbol name to resolve when a file/position is not provided.', + }, + file: { + type: 'string', + description: + 'File path (absolute or workspace-relative). Requires `line`.', + }, + uri: { + type: 'string', + description: + 'File URI (file:///...). Requires `line` when provided.', + }, + 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.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + }, + false, + false, + ); + } + + protected createInvocation( + params: LspGoToDefinitionParams, + ): ToolInvocation { + return new LspGoToDefinitionInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/lsp-workspace-symbol.ts b/packages/core/src/tools/lsp-workspace-symbol.ts new file mode 100644 index 000000000..be016a02d --- /dev/null +++ b/packages/core/src/tools/lsp-workspace-symbol.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath } 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 { LspSymbolInformation } from '../lsp/types.js'; + +export interface LspWorkspaceSymbolParams { + /** + * Query string to search symbols (e.g., function or class name). + */ + query: string; + /** + * Maximum number of results to return. + */ + limit?: number; +} + +class LspWorkspaceSymbolInvocation extends BaseToolInvocation< + LspWorkspaceSymbolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspWorkspaceSymbolParams, + ) { + super(params); + } + + getDescription(): string { + return `LSP workspace symbol search(按名称找定义/实现/引用) for "${this.params.query}"`; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP workspace symbol search is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.workspaceSymbols(this.params.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 "${this.params.query}".`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocation(symbol, 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 "${this.params.query}":`; + + 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.formatLocation( + { location: ref, name: '', kind: undefined }, + 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 formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) { + const { uri, range } = symbol.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}`; + } +} + +export class LspWorkspaceSymbolTool extends BaseDeclarativeTool< + LspWorkspaceSymbolParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL; + + constructor(private readonly config: Config) { + super( + LspWorkspaceSymbolTool.Name, + ToolDisplayNames.LSP_WORKSPACE_SYMBOL, + 'Search workspace symbols via LSP(查找定义/实现/引用,按名称定位符号,优先于 grep)。', + Kind.Other, + { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Symbol name query, e.g., function/class/variable name to search.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + required: ['query'], + }, + false, + false, + ); + } + + protected createInvocation( + params: LspWorkspaceSymbolParams, + ): ToolInvocation { + return new LspWorkspaceSymbolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 8cd1de541..1e0600b0a 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,9 @@ export const ToolNames = { WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', LS: 'list_directory', + LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', + LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', + LSP_FIND_REFERENCES: 'lsp_find_references', } as const; /** @@ -48,6 +51,9 @@ export const ToolDisplayNames = { WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', LS: 'ListFiles', + LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol', + LSP_GO_TO_DEFINITION: 'LspGoToDefinition', + LSP_FIND_REFERENCES: 'LspFindReferences', } as const; // Migration from old tool names to new tool names @@ -56,6 +62,8 @@ export const ToolDisplayNames = { export const ToolNamesMigration = { search_file_content: ToolNames.GREP, // Legacy name from grep tool replace: ToolNames.EDIT, // Legacy name from edit tool + go_to_definition: ToolNames.LSP_GO_TO_DEFINITION, + find_references: ToolNames.LSP_FIND_REFERENCES, } as const; // Migration from old tool display names to new tool display names From d1d215b82e9649a287af80d8cf595a9103d82932 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 5 Jan 2026 10:18:24 +0800 Subject: [PATCH 02/15] wip(cli): support lsp --- .../src/services/lsp/LspConnectionFactory.ts | 13 +-- .../src/services/lsp/NativeLspService.test.ts | 35 ++++---- .../cli/src/services/lsp/NativeLspService.ts | 87 +++++++++++-------- packages/core/src/lsp/types.ts | 4 +- 4 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index e18262ed6..00832adb5 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -12,8 +12,11 @@ class JsonRpcConnection { private nextId = 1; private disposed = false; private pendingRequests = new Map(); - private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; - private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = + []; + private requestHandlers: Array< + (request: JsonRpcMessage) => Promise + > = []; constructor( private readonly writer: (data: string) => void, @@ -229,12 +232,12 @@ interface JsonRpcMessage { jsonrpc: string; id?: number | string; method?: string; - params?: any; - result?: any; + params?: unknown; + result?: unknown; error?: { code: number; message: string; - data?: any; + data?: unknown; }; } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index 1fadd620a..521f4c2e0 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { NativeLspService } from './NativeLspService.js'; -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, +} from '@qwen-code/qwen-code-core'; import { EventEmitter } from 'events'; -import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import { IdeContextStore } from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { @@ -33,11 +35,11 @@ class MockWorkspaceContext { async readFile(path: string): Promise { if (path.includes('.lsp.json')) { return JSON.stringify({ - 'typescript': { - 'command': 'typescript-language-server', - 'args': ['--stdio'], - 'transport': 'stdio' - } + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, }); } return '{}'; @@ -57,13 +59,16 @@ class MockWorkspaceContext { } class MockFileDiscoveryService { - async discoverFiles(root: string, options: any): Promise { + async discoverFiles( + root: string, + options: Record, + ): Promise { // 模拟发现一些文件 return [ '/test/workspace/src/index.ts', '/test/workspace/src/utils.ts', '/test/workspace/server.py', - '/test/workspace/main.go' + '/test/workspace/main.go', ]; } @@ -92,11 +97,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as any, - mockWorkspace as any, + mockConfig as CoreConfig, + mockWorkspace as WorkspaceContext, eventEmitter, - mockFileDiscovery as any, - mockIdeStore as any + mockFileDiscovery as FileDiscoveryService, + mockIdeStore as IdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index aca87e3e6..041decc35 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -1,15 +1,18 @@ -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; -import type { EventEmitter } from 'events'; -import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import type { IdeContextStore } from '@qwen-code/qwen-code-core'; -import { LspConnectionFactory } from './LspConnectionFactory.js'; import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, LspLocation, LspDefinition, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import { + LspConnectionFactory, + type JsonRpcMessage, +} from './LspConnectionFactory.js'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -18,7 +21,7 @@ import { globSync } from 'glob'; // 定义 LSP 初始化选项的类型 interface LspInitializationOptions { - [key: string]: any; + [key: string]: unknown; } // 定义 LSP 服务器配置类型 @@ -36,11 +39,11 @@ interface LspServerConfig { // 定义 LSP 连接接口 interface LspConnectionInterface { listen: (readable: NodeJS.ReadableStream) => void; - send: (message: any) => void; - onNotification: (handler: (notification: any) => void) => void; - onRequest: (handler: (request: any) => Promise) => void; - request: (method: string, params: any) => Promise; - initialize: (params: any) => Promise; + send: (message: JsonRpcMessage) => void; + onNotification: (handler: (notification: JsonRpcMessage) => void) => void; + onRequest: (handler: (request: JsonRpcMessage) => Promise) => void; + request: (method: string, params: unknown) => Promise; + initialize: (params: unknown) => Promise; shutdown: () => Promise; end: () => void; } @@ -90,7 +93,7 @@ export class NativeLspService { this.excludedServers = options.excludedServers?.filter(Boolean); this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; this.workspaceRoot = - options.workspaceRoot ?? (config as any).getProjectRoot(); + options.workspaceRoot ?? (config as CoreConfig).getProjectRoot(); } /** @@ -462,15 +465,19 @@ export class NativeLspService { } private normalizeLocationResult( - item: any, + item: unknown, serverName: string, ): LspReference | null { - const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const itemObj = item as Record; + const uri = + itemObj?.uri ?? + itemObj?.targetUri ?? + (itemObj?.target as Record)?.uri; const range = - item?.range ?? - item?.targetSelectionRange ?? - item?.targetRange ?? - item?.target?.range; + itemObj?.range ?? + itemObj?.targetSelectionRange ?? + itemObj?.targetRange ?? + (itemObj?.target as Record)?.range; if (!uri || !range?.start || !range?.end) { return null; @@ -493,12 +500,17 @@ export class NativeLspService { } private normalizeSymbolResult( - item: any, + item: unknown, serverName: string, ): LspSymbolInformation | null { - const location = item?.location ?? item?.target ?? item; + const itemObj = item as Record; + const location = itemObj?.location ?? itemObj?.target ?? itemObj; + const locationObj = location as Record; const range = - location?.range ?? location?.targetRange ?? item?.range ?? undefined; + locationObj?.range ?? + locationObj?.targetRange ?? + itemObj?.range ?? + undefined; if (!location?.uri || !range?.start || !range?.end) { return null; @@ -581,7 +593,7 @@ export class NativeLspService { args: ['--stdio'], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -594,7 +606,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -607,7 +619,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -633,7 +645,7 @@ export class NativeLspService { if (userConfig && typeof userConfig === 'object') { for (const [langId, serverSpec] of Object.entries(userConfig) as [ string, - any, + Record, ]) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); @@ -651,7 +663,7 @@ export class NativeLspService { args: serverSpec.args || [], transport: serverSpec.transport || 'stdio', initializationOptions: serverSpec.initializationOptions, - rootUri: rootUri, + rootUri, trustRequired: serverSpec.trustRequired ?? true, }; @@ -715,7 +727,9 @@ export class NativeLspService { } // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + if ( + !this.isPathSafe(handle.config.command, (this.config as CoreConfig).cwd) + ) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -773,7 +787,7 @@ export class NativeLspService { process: ChildProcess; shutdown: () => Promise; exit: () => void; - initialize: (params: any) => Promise; + initialize: (params: unknown) => Promise; }> { if (config.transport === 'stdio') { // 修复:使用 cwd 作为 cwd 而不是 rootUri @@ -795,9 +809,8 @@ export class NativeLspService { } lspConnection.connection.end(); }, - initialize: async (params: any) => { - return lspConnection.connection.initialize(params); - }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), }; } else if (config.transport === 'tcp') { // 如果需要 TCP 支持,可以扩展此部分 @@ -866,7 +879,9 @@ export class NativeLspService { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : 'typescript'; const text = fs.readFileSync(tsFile, 'utf-8'); connection.connection.send({ jsonrpc: '2.0', @@ -1013,15 +1028,15 @@ export class NativeLspService { return handle.config.name.includes('typescript'); } - private isNoProjectErrorResponse(response: any): boolean { + private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; } const message = typeof response === 'string' ? response - : typeof response?.message === 'string' - ? response.message + : typeof (response as Record)?.message === 'string' + ? ((response as Record).message as string) : ''; return message.includes('No Project'); } diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 2a412d660..239962a77 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -31,9 +31,9 @@ export interface LspSymbolInformation { serverName?: string; } -export interface LspReference extends LspLocationWithServer {} +export type LspReference = LspLocationWithServer; -export interface LspDefinition extends LspLocationWithServer {} +export type LspDefinition = LspLocationWithServer; export interface LspClient { workspaceSymbols( From 5a907c3415d4c54e2fe8748ed896724e86aa73c4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 15:21:33 +0800 Subject: [PATCH 03/15] wip(cli): support lsp --- .../src/services/lsp/LspConnectionFactory.ts | 15 ++- .../src/services/lsp/NativeLspService.test.ts | 43 +++--- .../cli/src/services/lsp/NativeLspService.ts | 124 +++++++++++------- packages/core/src/lsp/types.ts | 8 +- .../core/src/tools/lsp-find-references.ts | 15 +-- .../core/src/tools/lsp-go-to-definition.ts | 15 +-- 6 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index e18262ed6..ccee42d06 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -11,9 +11,12 @@ class JsonRpcConnection { private buffer = ''; private nextId = 1; private disposed = false; - private pendingRequests = new Map(); - private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; - private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + private pendingRequests = new Map(); + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = + []; + private requestHandlers: Array< + (request: JsonRpcMessage) => Promise + > = []; constructor( private readonly writer: (data: string) => void, @@ -229,12 +232,12 @@ interface JsonRpcMessage { jsonrpc: string; id?: number | string; method?: string; - params?: any; - result?: any; + params?: unknown; + result?: unknown; error?: { code: number; message: string; - data?: any; + data?: unknown; }; } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index 1fadd620a..c6479bfbb 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,10 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { NativeLspService } from './NativeLspService.js'; -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import { WorkspaceContext } from '@qwen-code/qwen-code-core'; import { EventEmitter } from 'events'; -import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import { IdeContextStore } from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { @@ -14,7 +9,7 @@ class MockConfig { return true; } - get(key: string) { + get(_key: string) { return undefined; } @@ -26,28 +21,28 @@ class MockConfig { class MockWorkspaceContext { rootPath = '/test/workspace'; - async fileExists(path: string): Promise { - return path.endsWith('.json') || path.includes('package.json'); + async fileExists(_path: string): Promise { + return _path.endsWith('.json') || _path.includes('package.json'); } - async readFile(path: string): Promise { - if (path.includes('.lsp.json')) { + async readFile(_path: string): Promise { + if (_path.includes('.lsp.json')) { return JSON.stringify({ - 'typescript': { - 'command': 'typescript-language-server', - 'args': ['--stdio'], - 'transport': 'stdio' - } + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, }); } return '{}'; } - resolvePath(path: string): string { - return this.rootPath + '/' + path; + resolvePath(_path: string): string { + return this.rootPath + '/' + _path; } - isPathWithinWorkspace(path: string): boolean { + isPathWithinWorkspace(_path: string): boolean { return true; } @@ -57,13 +52,13 @@ class MockWorkspaceContext { } class MockFileDiscoveryService { - async discoverFiles(root: string, options: any): Promise { + async discoverFiles(_root: string, _options: unknown): Promise { // 模拟发现一些文件 return [ '/test/workspace/src/index.ts', '/test/workspace/src/utils.ts', '/test/workspace/server.py', - '/test/workspace/main.go' + '/test/workspace/main.go', ]; } @@ -92,11 +87,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as any, - mockWorkspace as any, + mockConfig as MockConfig, + mockWorkspace as MockWorkspaceContext, eventEmitter, - mockFileDiscovery as any, - mockIdeStore as any + mockFileDiscovery as MockFileDiscoveryService, + mockIdeStore as MockIdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index aca87e3e6..f15f2b2b5 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -1,15 +1,15 @@ -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; -import type { EventEmitter } from 'events'; -import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import type { IdeContextStore } from '@qwen-code/qwen-code-core'; -import { LspConnectionFactory } from './LspConnectionFactory.js'; import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, LspLocation, LspDefinition, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import { LspConnectionFactory } from './LspConnectionFactory.js'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -18,7 +18,7 @@ import { globSync } from 'glob'; // 定义 LSP 初始化选项的类型 interface LspInitializationOptions { - [key: string]: any; + [key: string]: unknown; } // 定义 LSP 服务器配置类型 @@ -36,11 +36,11 @@ interface LspServerConfig { // 定义 LSP 连接接口 interface LspConnectionInterface { listen: (readable: NodeJS.ReadableStream) => void; - send: (message: any) => void; - onNotification: (handler: (notification: any) => void) => void; - onRequest: (handler: (request: any) => Promise) => void; - request: (method: string, params: any) => Promise; - initialize: (params: any) => Promise; + send: (message: unknown) => void; + onNotification: (handler: (notification: unknown) => void) => void; + onRequest: (handler: (request: unknown) => Promise) => void; + request: (method: string, params: unknown) => Promise; + initialize: (params: unknown) => Promise; shutdown: () => Promise; end: () => void; } @@ -90,7 +90,8 @@ export class NativeLspService { this.excludedServers = options.excludedServers?.filter(Boolean); this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; this.workspaceRoot = - options.workspaceRoot ?? (config as any).getProjectRoot(); + options.workspaceRoot ?? + (config as { getProjectRoot: () => string }).getProjectRoot(); } /** @@ -462,30 +463,38 @@ export class NativeLspService { } private normalizeLocationResult( - item: any, + item: unknown, serverName: string, ): LspReference | null { - const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const itemObj = item as Record; + const uri = + itemObj?.uri ?? + itemObj?.targetUri ?? + (itemObj?.target as Record)?.uri; const range = - item?.range ?? - item?.targetSelectionRange ?? - item?.targetRange ?? - item?.target?.range; + itemObj?.range ?? + itemObj?.targetSelectionRange ?? + itemObj?.targetRange ?? + (itemObj?.target as Record)?.range; if (!uri || !range?.start || !range?.end) { return null; } + const rangeObj = range as Record; + const start = rangeObj.start as { line?: number; character?: number }; + const end = rangeObj.end as { line?: number; character?: number }; + return { - uri, + uri: uri as string, range: { start: { - line: Number(range.start.line ?? 0), - character: Number(range.start.character ?? 0), + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), }, end: { - line: Number(range.end.line ?? 0), - character: Number(range.end.character ?? 0), + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), }, }, serverName, @@ -493,31 +502,40 @@ export class NativeLspService { } private normalizeSymbolResult( - item: any, + item: unknown, serverName: string, ): LspSymbolInformation | null { - const location = item?.location ?? item?.target ?? item; + const itemObj = item as Record; + const location = itemObj?.location ?? itemObj?.target ?? item; + const locationObj = location as Record; const range = - location?.range ?? location?.targetRange ?? item?.range ?? undefined; + locationObj?.range ?? + locationObj?.targetRange ?? + itemObj?.range ?? + undefined; - if (!location?.uri || !range?.start || !range?.end) { + if (!locationObj?.uri || !range?.start || !range?.end) { return null; } + const rangeObj = range as Record; + const start = rangeObj.start as { line?: number; character?: number }; + const end = rangeObj.end as { line?: number; character?: number }; + return { - name: item?.name ?? item?.label ?? 'symbol', - kind: item?.kind ? String(item.kind) : undefined, - containerName: item?.containerName ?? item?.container, + name: (itemObj?.name ?? itemObj?.label ?? 'symbol') as string, + kind: itemObj?.kind ? String(itemObj.kind) : undefined, + containerName: itemObj?.containerName ?? itemObj?.container, location: { - uri: location.uri, + uri: locationObj.uri as string, range: { start: { - line: Number(range.start.line ?? 0), - character: Number(range.start.character ?? 0), + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), }, end: { - line: Number(range.end.line ?? 0), - character: Number(range.end.character ?? 0), + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), }, }, }, @@ -581,7 +599,7 @@ export class NativeLspService { args: ['--stdio'], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -594,7 +612,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -607,7 +625,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -633,7 +651,7 @@ export class NativeLspService { if (userConfig && typeof userConfig === 'object') { for (const [langId, serverSpec] of Object.entries(userConfig) as [ string, - any, + Record, ]) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); @@ -651,7 +669,7 @@ export class NativeLspService { args: serverSpec.args || [], transport: serverSpec.transport || 'stdio', initializationOptions: serverSpec.initializationOptions, - rootUri: rootUri, + rootUri, trustRequired: serverSpec.trustRequired ?? true, }; @@ -715,7 +733,12 @@ export class NativeLspService { } // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + if ( + !this.isPathSafe( + handle.config.command, + (this.config as { cwd: string }).cwd, + ) + ) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -773,7 +796,7 @@ export class NativeLspService { process: ChildProcess; shutdown: () => Promise; exit: () => void; - initialize: (params: any) => Promise; + initialize: (params: unknown) => Promise; }> { if (config.transport === 'stdio') { // 修复:使用 cwd 作为 cwd 而不是 rootUri @@ -795,9 +818,8 @@ export class NativeLspService { } lspConnection.connection.end(); }, - initialize: async (params: any) => { - return lspConnection.connection.initialize(params); - }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), }; } else if (config.transport === 'tcp') { // 如果需要 TCP 支持,可以扩展此部分 @@ -866,7 +888,9 @@ export class NativeLspService { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : 'typescript'; const text = fs.readFileSync(tsFile, 'utf-8'); connection.connection.send({ jsonrpc: '2.0', @@ -1013,15 +1037,15 @@ export class NativeLspService { return handle.config.name.includes('typescript'); } - private isNoProjectErrorResponse(response: any): boolean { + private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; } const message = typeof response === 'string' ? response - : typeof response?.message === 'string' - ? response.message + : typeof (response as Record)?.message === 'string' + ? ((response as Record).message as string) : ''; return message.includes('No Project'); } diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 2a412d660..309ad43b9 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -31,9 +31,13 @@ export interface LspSymbolInformation { serverName?: string; } -export interface LspReference extends LspLocationWithServer {} +export interface LspReference extends LspLocationWithServer { + readonly serverName?: string; +} -export interface LspDefinition extends LspLocationWithServer {} +export interface LspDefinition extends LspLocationWithServer { + readonly serverName?: string; +} export interface LspClient { workspaceSymbols( diff --git a/packages/core/src/tools/lsp-find-references.ts b/packages/core/src/tools/lsp-find-references.ts index 078586e49..5f7127dba 100644 --- a/packages/core/src/tools/lsp-find-references.ts +++ b/packages/core/src/tools/lsp-find-references.ts @@ -10,11 +10,7 @@ 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 { - LspClient, - LspLocation, - LspReference, -} from '../lsp/types.js'; +import type { LspClient, LspLocation, LspReference } from '../lsp/types.js'; export interface LspFindReferencesParams { /** @@ -121,9 +117,12 @@ class LspFindReferencesInvocation extends BaseToolInvocation< } const workspaceRoot = this.config.getProjectRoot(); - const lines = references.slice(0, limit).map((reference, index) => { - return `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`; - }); + const lines = references + .slice(0, limit) + .map( + (reference, index) => + `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`, + ); const heading = `References for ${target.description}:`; return { diff --git a/packages/core/src/tools/lsp-go-to-definition.ts b/packages/core/src/tools/lsp-go-to-definition.ts index cfbc92d32..54e093545 100644 --- a/packages/core/src/tools/lsp-go-to-definition.ts +++ b/packages/core/src/tools/lsp-go-to-definition.ts @@ -10,11 +10,7 @@ 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 { - LspClient, - LspDefinition, - LspLocation, -} from '../lsp/types.js'; +import type { LspClient, LspDefinition, LspLocation } from '../lsp/types.js'; export interface LspGoToDefinitionParams { /** @@ -126,9 +122,12 @@ class LspGoToDefinitionInvocation extends BaseToolInvocation< } const workspaceRoot = this.config.getProjectRoot(); - const lines = definitions.slice(0, limit).map((definition, index) => { - return `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`; - }); + const lines = definitions + .slice(0, limit) + .map( + (definition, index) => + `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`, + ); const heading = `Definitions for ${target.description}:`; return { From c4e6c096dce3269337c40ddfa107b9e79534eac1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 19:59:19 +0800 Subject: [PATCH 04/15] feat(cli): improve LSP service implementation with type safety and iteration fixes - Fix iteration over Map and Set collections by using Array.from() to avoid potential modification during iteration issues - Add proper type casting for test mocks to ensure type safety - Add null checks and type guards for LSP reference and symbol processing - Improve type annotations for LSP server status and configuration objects - Update path validation to use workspace root instead of config.cwd These changes improve the robustness and type safety of the LSP service implementation. --- .../src/services/lsp/LspConnectionFactory.ts | 2 +- .../src/services/lsp/NativeLspService.test.ts | 8 +- .../cli/src/services/lsp/NativeLspService.ts | 136 ++++++++++-------- 3 files changed, 84 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index ccee42d06..9f2e4c9b8 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -214,7 +214,7 @@ class JsonRpcConnection { } private disposePending(error?: Error): void { - for (const [, pending] of this.pendingRequests) { + for (const [, pending] of Array.from(this.pendingRequests)) { clearTimeout(pending.timer); pending.reject(error ?? new Error('LSP connection closed')); } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index c6479bfbb..acac65b98 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -87,11 +87,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as MockConfig, - mockWorkspace as MockWorkspaceContext, + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, eventEmitter, - mockFileDiscovery as MockFileDiscoveryService, - mockIdeStore as MockIdeContextStore, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index f15f2b2b5..fe2da4498 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -117,7 +117,7 @@ export class NativeLspService { for (const config of serverConfigs) { this.serverHandles.set(config.name, { config, - status: 'NOT_STARTED', + status: 'NOT_STARTED' as LspServerStatus, }); } } @@ -126,7 +126,7 @@ export class NativeLspService { * 启动所有 LSP 服务器 */ async start(): Promise { - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { await this.startServer(name, handle); } } @@ -135,7 +135,7 @@ export class NativeLspService { * 停止所有 LSP 服务器 */ async stop(): Promise { - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { await this.stopServer(name, handle); } this.serverHandles.clear(); @@ -146,7 +146,7 @@ export class NativeLspService { */ getStatus(): Map { const statusMap = new Map(); - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { statusMap.set(name, handle.status); } return statusMap; @@ -161,7 +161,7 @@ export class NativeLspService { ): Promise { const results: LspSymbolInformation[] = []; - for (const [serverName, handle] of this.serverHandles) { + for (const [serverName, handle] of Array.from(this.serverHandles)) { if (handle.status !== 'READY' || !handle.connection) { continue; } @@ -348,7 +348,7 @@ export class NativeLspService { // 统计不同语言的文件数量 const languageCounts = new Map(); - for (const file of files) { + for (const file of Array.from(files)) { const ext = path.extname(file).slice(1).toLowerCase(); if (ext) { const lang = this.mapExtensionToLanguage(ext); @@ -466,27 +466,33 @@ export class NativeLspService { item: unknown, serverName: string, ): LspReference | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; - const uri = - itemObj?.uri ?? - itemObj?.targetUri ?? - (itemObj?.target as Record)?.uri; - const range = - itemObj?.range ?? - itemObj?.targetSelectionRange ?? - itemObj?.targetRange ?? - (itemObj?.target as Record)?.range; + const uri = (itemObj['uri'] ?? + itemObj['targetUri'] ?? + (itemObj['target'] as Record)?.['uri']) as + | string + | undefined; + + const range = (itemObj['range'] ?? + itemObj['targetSelectionRange'] ?? + itemObj['targetRange'] ?? + (itemObj['target'] as Record)?.['range']) as + | { start?: unknown; end?: unknown } + | undefined; if (!uri || !range?.start || !range?.end) { return null; } - const rangeObj = range as Record; - const start = rangeObj.start as { line?: number; character?: number }; - const end = rangeObj.end as { line?: number; character?: number }; + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; return { - uri: uri as string, + uri, range: { start: { line: Number(start?.line ?? 0), @@ -505,29 +511,37 @@ export class NativeLspService { item: unknown, serverName: string, ): LspSymbolInformation | null { - const itemObj = item as Record; - const location = itemObj?.location ?? itemObj?.target ?? item; - const locationObj = location as Record; - const range = - locationObj?.range ?? - locationObj?.targetRange ?? - itemObj?.range ?? - undefined; - - if (!locationObj?.uri || !range?.start || !range?.end) { + if (!item || typeof item !== 'object') { return null; } - const rangeObj = range as Record; - const start = rangeObj.start as { line?: number; character?: number }; - const end = rangeObj.end as { line?: number; character?: number }; + const itemObj = item as Record; + const location = itemObj['location'] ?? itemObj['target'] ?? item; + if (!location || typeof location !== 'object') { + return null; + } + + const locationObj = location as Record; + const range = (locationObj['range'] ?? + locationObj['targetRange'] ?? + itemObj['range'] ?? + undefined) as { start?: unknown; end?: unknown } | undefined; + + if (!locationObj['uri'] || !range?.start || !range?.end) { + return null; + } + + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; return { - name: (itemObj?.name ?? itemObj?.label ?? 'symbol') as string, - kind: itemObj?.kind ? String(itemObj.kind) : undefined, - containerName: itemObj?.containerName ?? itemObj?.container, + name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, + kind: itemObj['kind'] ? String(itemObj['kind']) : undefined, + containerName: (itemObj['containerName'] ?? itemObj['container']) as + | string + | undefined, location: { - uri: locationObj.uri as string, + uri: locationObj['uri'] as string, range: { start: { line: Number(start?.line ?? 0), @@ -649,28 +663,41 @@ export class NativeLspService { // 验证并转换用户配置为内部格式 if (userConfig && typeof userConfig === 'object') { - for (const [langId, serverSpec] of Object.entries(userConfig) as [ - string, - Record, - ]) { + for (const [langId, serverSpec] of Object.entries( + userConfig, + ) as Array<[string, Record]>) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); - // 验证 command 不为 undefined - if (!serverSpec.command) { - console.warn(`LSP 配置错误: ${langId} 缺少 command 属性`); + // 驗證 command 不為 undefined + if (!(serverSpec as Record)['command']) { + console.warn(`LSP 配置錯誤: ${langId} 缺少 command 屬性`); continue; } const serverConfig: LspServerConfig = { - name: serverSpec.command, + name: (serverSpec as Record)[ + 'command' + ] as string, languages: [langId], - command: serverSpec.command, - args: serverSpec.args || [], - transport: serverSpec.transport || 'stdio', - initializationOptions: serverSpec.initializationOptions, + 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.trustRequired ?? true, + trustRequired: + ((serverSpec as Record)[ + 'trustRequired' + ] as boolean) ?? true, }; configs.push(serverConfig); @@ -733,12 +760,7 @@ export class NativeLspService { } // 检查路径安全性 - if ( - !this.isPathSafe( - handle.config.command, - (this.config as { cwd: string }).cwd, - ) - ) { + if (!this.isPathSafe(handle.config.command, this.workspaceRoot)) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -1044,8 +1066,8 @@ export class NativeLspService { const message = typeof response === 'string' ? response - : typeof (response as Record)?.message === 'string' - ? ((response as Record).message as string) + : typeof (response as Record)['message'] === 'string' + ? ((response as Record)['message'] as string) : ''; return message.includes('No Project'); } From d9328fa478e47bb92d118f0b894b0904804c9e91 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 18 Jan 2026 19:34:17 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80LSP=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=B9=B6=E6=89=A9=E5=B1=95=E6=93=8D=E4=BD=9C=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建统一的LSP工具,整合了之前的多个分散LSP工具 - 增加对更多LSP操作的支持,包括hover、documentSymbol、goToImplementation等 - 扩展LSP类型定义,支持Call Hierarchy等高级功能 - 更新配置和测试文件以适配新的LSP工具架构 - 保持向后兼容性,同时引入新工具名称映射 Co-authored-by: Qwen-Coder 此更改是LSP工具重构计划的一部分,旨在提供更统一和功能完备的LSP集成体验。 --- packages/cli/LSP_DEBUGGING_GUIDE.md | 39 +- packages/cli/src/config/config.test.ts | 59 +- packages/cli/src/config/config.ts | 63 + packages/cli/src/config/settingsSchema.ts | 11 + .../src/services/lsp/LspConnectionFactory.ts | 36 +- .../src/services/lsp/NativeLspService.test.ts | 6 + .../cli/src/services/lsp/NativeLspService.ts | 1456 +++++++++++++++-- packages/core/src/config/config.ts | 4 + packages/core/src/lsp/types.ts | 120 ++ packages/core/src/tools/lsp.test.ts | 1220 ++++++++++++++ packages/core/src/tools/lsp.ts | 960 +++++++++++ packages/core/src/tools/tool-names.ts | 4 + .../LSP_REFACTORING_PLAN.md | 255 +++ 13 files changed, 4092 insertions(+), 141 deletions(-) create mode 100644 packages/core/src/tools/lsp.test.ts create mode 100644 packages/core/src/tools/lsp.ts create mode 100644 packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md 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 逻辑,减少首次调用延迟 From 92cbb50473f1cddcb99d76f2a1d56858472f6124 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 21 Jan 2026 01:15:59 +0800 Subject: [PATCH 06/15] wip: lsp --- docs/users/configuration/settings.md | 17 + docs/users/features/_meta.ts | 1 + packages/cli/src/config/config.ts | 37 + .../cli/src/services/lsp/NativeLspService.ts | 714 ++++++++++++++++++ packages/core/src/lsp/types.ts | 182 +++++ packages/core/src/tools/lsp.ts | 253 ++++++- 6 files changed, 1201 insertions(+), 3 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 85902eaf2..3885f7ee3 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -287,6 +287,23 @@ If you are experiencing performance issues with file searching (e.g., with `@` c > > **Security Note for MCP servers:** These settings use simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. +#### lsp + +Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details. + +| Setting | Type | Description | Default | +| ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ----------- | +| `lsp.enabled` | boolean | Enable/disable LSP support. | `true` | +| `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` | +| `lsp.serverTimeout`| number | LSP server startup timeout in milliseconds. | `10000` | +| `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` | +| `lsp.excluded` | array of strings | A denylist of LSP servers to exclude. A server listed in both is excluded. | `[]` | +| `lsp.languageServers` | object | Custom language server configurations. See the [LSP documentation](../features/lsp#custom-language-servers) for configuration format. | `{}` | + +> [!note] +> +> **Security Note for LSP servers:** LSP servers run with your user permissions and can execute code. They are only started in trusted workspaces by default. You can configure per-server trust requirements in the `.lsp.json` configuration file. + #### security | Setting | Type | Description | Default | diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 0cc6d63a8..0155b3ba4 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -8,6 +8,7 @@ export default { }, 'approval-mode': 'Approval Mode', mcp: 'MCP', + lsp: 'LSP (Language Server Protocol)', 'token-caching': 'Token Caching', sandbox: 'Sandboxing', language: 'i18n', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3eba6e2cf..7a461ecb8 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -252,6 +252,43 @@ class NativeLspClient implements LspClient { ) { return this.service.outgoingCalls(item, serverName, limit); } + + /** + * Get diagnostics for a specific document. + */ + diagnostics(uri: string, serverName?: string) { + return this.service.diagnostics(uri, serverName); + } + + /** + * Get diagnostics for all open documents in the workspace. + */ + workspaceDiagnostics(serverName?: string, limit?: number) { + return this.service.workspaceDiagnostics(serverName, limit); + } + + /** + * Get code actions available at a specific location. + */ + codeActions( + uri: string, + range: Parameters[1], + context: Parameters[2], + serverName?: string, + limit?: number, + ) { + return this.service.codeActions(uri, range, context, serverName, limit); + } + + /** + * Apply a workspace edit (from code action or other sources). + */ + applyWorkspaceEdit( + edit: Parameters[0], + serverName?: string, + ) { + return this.service.applyWorkspaceEdit(edit, serverName); + } } function normalizeOutputFormat( diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index 77445a2f8..da670cb79 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -6,12 +6,20 @@ import type { LspCallHierarchyIncomingCall, LspCallHierarchyItem, LspCallHierarchyOutgoingCall, + LspCodeAction, + LspCodeActionContext, + LspCodeActionKind, LspDefinition, + LspDiagnostic, + LspDiagnosticSeverity, + LspFileDiagnostics, LspHoverResult, LspLocation, LspRange, LspReference, LspSymbolInformation, + LspTextEdit, + LspWorkspaceEdit, } from '@qwen-code/qwen-code-core'; import type { EventEmitter } from 'events'; import { LspConnectionFactory } from './LspConnectionFactory.js'; @@ -113,6 +121,32 @@ const SYMBOL_KIND_LABELS: Record = { 26: 'TypeParameter', }; +/** + * Diagnostic severity labels for converting numeric LSP DiagnosticSeverity to readable strings. + * Based on the LSP specification. + */ +const DIAGNOSTIC_SEVERITY_LABELS: Record = { + 1: 'error', + 2: 'warning', + 3: 'information', + 4: 'hint', +}; + +/** + * Code action kind labels from LSP specification. + */ +const CODE_ACTION_KIND_LABELS: Record = { + '': 'quickfix', + quickfix: 'quickfix', + refactor: 'refactor', + 'refactor.extract': 'refactor.extract', + 'refactor.inline': 'refactor.inline', + 'refactor.rewrite': 'refactor.rewrite', + source: 'source', + 'source.organizeImports': 'source.organizeImports', + 'source.fixAll': 'source.fixAll', +}; + const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000; const DEFAULT_LSP_MAX_RESTARTS = 3; @@ -696,6 +730,686 @@ export class NativeLspService { return []; } + /** + * 获取文档的诊断信息 + */ + async diagnostics( + uri: string, + serverName?: string, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + const allDiagnostics: LspDiagnostic[] = []; + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + + // Request pull diagnostics if the server supports it + const response = await handle.connection.request( + 'textDocument/diagnostic', + { + textDocument: { uri }, + }, + ); + + if (response && typeof response === 'object') { + const responseObj = response as Record; + const items = responseObj['items']; + if (Array.isArray(items)) { + for (const item of items) { + const normalized = this.normalizeDiagnostic(item, name); + if (normalized) { + allDiagnostics.push(normalized); + } + } + } + } + } catch (error) { + // Fall back to cached diagnostics from publishDiagnostics notifications + // This is handled by the notification handler if implemented + console.warn( + `LSP textDocument/diagnostic failed for ${name}:`, + error, + ); + } + } + + return allDiagnostics; + } + + /** + * 获取工作区所有文档的诊断信息 + */ + async workspaceDiagnostics( + serverName?: string, + limit = 100, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + const results: LspFileDiagnostics[] = []; + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + + // Request workspace diagnostics if supported + const response = await handle.connection.request( + 'workspace/diagnostic', + { + previousResultIds: [], + }, + ); + + if (response && typeof response === 'object') { + const responseObj = response as Record; + const items = responseObj['items']; + if (Array.isArray(items)) { + for (const item of items) { + if (results.length >= limit) { + break; + } + const normalized = this.normalizeFileDiagnostics(item, name); + if (normalized && normalized.diagnostics.length > 0) { + results.push(normalized); + } + } + } + } + } catch (error) { + console.warn( + `LSP workspace/diagnostic failed for ${name}:`, + error, + ); + } + + if (results.length >= limit) { + break; + } + } + + return results.slice(0, limit); + } + + /** + * 获取指定位置的代码操作 + */ + async codeActions( + uri: string, + range: LspRange, + context: LspCodeActionContext, + serverName?: string, + limit = 20, + ): 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); + + // Convert context diagnostics to LSP format + const lspDiagnostics = context.diagnostics.map((d) => + this.denormalizeDiagnostic(d), + ); + + const response = await handle.connection.request( + 'textDocument/codeAction', + { + textDocument: { uri }, + range, + context: { + diagnostics: lspDiagnostics, + only: context.only, + triggerKind: + context.triggerKind === 'automatic' + ? 2 // CodeActionTriggerKind.Automatic + : 1, // CodeActionTriggerKind.Invoked + }, + }, + ); + + if (!Array.isArray(response)) { + continue; + } + + const actions: LspCodeAction[] = []; + for (const item of response) { + const normalized = this.normalizeCodeAction(item, name); + if (normalized) { + actions.push(normalized); + if (actions.length >= limit) { + break; + } + } + } + + if (actions.length > 0) { + return actions.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/codeAction failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 应用工作区编辑 + */ + async applyWorkspaceEdit( + edit: LspWorkspaceEdit, + serverName?: string, + ): Promise { + // Apply edits locally - this doesn't go through LSP server + // Instead, it applies the edits to the file system + try { + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + await this.applyTextEdits(uri, edits); + } + } + + if (edit.documentChanges) { + for (const docChange of edit.documentChanges) { + await this.applyTextEdits(docChange.textDocument.uri, docChange.edits); + } + } + + return true; + } catch (error) { + console.error('Failed to apply workspace edit:', error); + return false; + } + } + + /** + * 应用文本编辑到文件 + */ + private async applyTextEdits( + uri: string, + edits: LspTextEdit[], + ): Promise { + const filePath = uri.startsWith('file://') + ? uri.replace(/^file:\/\//, '') + : uri; + + // Read the current file content + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + // File doesn't exist, treat as empty + content = ''; + } + + // Sort edits in reverse order to apply from end to start + const sortedEdits = [...edits].sort((a, b) => { + if (a.range.start.line !== b.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + + const lines = content.split('\n'); + + for (const edit of sortedEdits) { + const { range, newText } = edit; + const startLine = range.start.line; + const endLine = range.end.line; + const startChar = range.start.character; + const endChar = range.end.character; + + // Get the affected lines + const startLineText = lines[startLine] ?? ''; + const endLineText = lines[endLine] ?? ''; + + // Build the new content + const before = startLineText.slice(0, startChar); + const after = endLineText.slice(endChar); + + // Replace the range with new text + const newLines = (before + newText + after).split('\n'); + + // Replace affected lines + lines.splice(startLine, endLine - startLine + 1, ...newLines); + } + + // Write back to file + fs.writeFileSync(filePath, lines.join('\n'), 'utf-8'); + } + + /** + * 规范化诊断结果 + */ + private normalizeDiagnostic( + item: unknown, + serverName: string, + ): LspDiagnostic | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const range = this.normalizeRange(itemObj['range']); + if (!range) { + return null; + } + + const message = + typeof itemObj['message'] === 'string' + ? (itemObj['message'] as string) + : ''; + if (!message) { + return null; + } + + const severityNum = + typeof itemObj['severity'] === 'number' + ? (itemObj['severity'] as number) + : undefined; + const severity = severityNum + ? DIAGNOSTIC_SEVERITY_LABELS[severityNum] + : undefined; + + const code = itemObj['code']; + const codeValue = + typeof code === 'string' || typeof code === 'number' ? code : undefined; + + const source = + typeof itemObj['source'] === 'string' + ? (itemObj['source'] as string) + : undefined; + + const tags = this.normalizeDiagnosticTags(itemObj['tags']); + const relatedInfo = this.normalizeDiagnosticRelatedInfo( + itemObj['relatedInformation'], + ); + + return { + range, + severity, + code: codeValue, + source, + message, + tags: tags.length > 0 ? tags : undefined, + relatedInformation: relatedInfo.length > 0 ? relatedInfo : undefined, + serverName, + }; + } + + /** + * 将诊断转换回 LSP 格式 + */ + private denormalizeDiagnostic( + diagnostic: LspDiagnostic, + ): Record { + const severityMap: Record = { + error: 1, + warning: 2, + information: 3, + hint: 4, + }; + + return { + range: diagnostic.range, + message: diagnostic.message, + severity: diagnostic.severity + ? severityMap[diagnostic.severity] + : undefined, + code: diagnostic.code, + source: diagnostic.source, + }; + } + + /** + * 规范化诊断标签 + */ + private normalizeDiagnosticTags( + tags: unknown, + ): Array<'unnecessary' | 'deprecated'> { + if (!Array.isArray(tags)) { + return []; + } + + const result: Array<'unnecessary' | 'deprecated'> = []; + for (const tag of tags) { + if (tag === 1) { + result.push('unnecessary'); + } else if (tag === 2) { + result.push('deprecated'); + } + } + return result; + } + + /** + * 规范化诊断相关信息 + */ + private normalizeDiagnosticRelatedInfo( + info: unknown, + ): Array<{ location: LspLocation; message: string }> { + if (!Array.isArray(info)) { + return []; + } + + const result: Array<{ location: LspLocation; message: string }> = []; + for (const item of info) { + if (!item || typeof item !== 'object') { + continue; + } + const itemObj = item as Record; + const location = itemObj['location']; + if (!location || typeof location !== 'object') { + continue; + } + const locObj = location as Record; + const uri = locObj['uri']; + const range = this.normalizeRange(locObj['range']); + const message = itemObj['message']; + + if (typeof uri === 'string' && range && typeof message === 'string') { + result.push({ + location: { uri, range }, + message, + }); + } + } + return result; + } + + /** + * 规范化文件诊断结果 + */ + private normalizeFileDiagnostics( + item: unknown, + serverName: string, + ): LspFileDiagnostics | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const uri = + typeof itemObj['uri'] === 'string' ? (itemObj['uri'] as string) : ''; + if (!uri) { + return null; + } + + const items = itemObj['items']; + if (!Array.isArray(items)) { + return null; + } + + const diagnostics: LspDiagnostic[] = []; + for (const diagItem of items) { + const normalized = this.normalizeDiagnostic(diagItem, serverName); + if (normalized) { + diagnostics.push(normalized); + } + } + + return { + uri, + diagnostics, + serverName, + }; + } + + /** + * 规范化代码操作结果 + */ + private normalizeCodeAction( + item: unknown, + serverName: string, + ): LspCodeAction | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + + // Check if this is a Command instead of CodeAction + if (itemObj['command'] && typeof itemObj['title'] === 'string' && !itemObj['kind']) { + // This is a raw Command, wrap it + return { + title: itemObj['title'] as string, + command: { + title: itemObj['title'] as string, + command: (itemObj['command'] as string) ?? '', + arguments: itemObj['arguments'] as unknown[] | undefined, + }, + serverName, + }; + } + + const title = + typeof itemObj['title'] === 'string' ? (itemObj['title'] as string) : ''; + if (!title) { + return null; + } + + const kind = + typeof itemObj['kind'] === 'string' + ? (CODE_ACTION_KIND_LABELS[itemObj['kind'] as string] ?? + (itemObj['kind'] as LspCodeActionKind)) + : undefined; + + const isPreferred = + typeof itemObj['isPreferred'] === 'boolean' + ? (itemObj['isPreferred'] as boolean) + : undefined; + + const edit = this.normalizeWorkspaceEdit(itemObj['edit']); + const command = this.normalizeCommand(itemObj['command']); + + const diagnostics: LspDiagnostic[] = []; + if (Array.isArray(itemObj['diagnostics'])) { + for (const diag of itemObj['diagnostics']) { + const normalized = this.normalizeDiagnostic(diag, serverName); + if (normalized) { + diagnostics.push(normalized); + } + } + } + + return { + title, + kind, + diagnostics: diagnostics.length > 0 ? diagnostics : undefined, + isPreferred, + edit: edit ?? undefined, + command: command ?? undefined, + data: itemObj['data'], + serverName, + }; + } + + /** + * 规范化工作区编辑 + */ + private normalizeWorkspaceEdit( + edit: unknown, + ): LspWorkspaceEdit | null { + if (!edit || typeof edit !== 'object') { + return null; + } + + const editObj = edit as Record; + const result: LspWorkspaceEdit = {}; + + // Handle changes (map of URI to TextEdit[]) + if (editObj['changes'] && typeof editObj['changes'] === 'object') { + const changes = editObj['changes'] as Record; + result.changes = {}; + for (const [uri, edits] of Object.entries(changes)) { + if (Array.isArray(edits)) { + const normalizedEdits: LspTextEdit[] = []; + for (const e of edits) { + const normalized = this.normalizeTextEdit(e); + if (normalized) { + normalizedEdits.push(normalized); + } + } + if (normalizedEdits.length > 0) { + result.changes[uri] = normalizedEdits; + } + } + } + } + + // Handle documentChanges + if (Array.isArray(editObj['documentChanges'])) { + result.documentChanges = []; + for (const docChange of editObj['documentChanges']) { + const normalized = this.normalizeTextDocumentEdit(docChange); + if (normalized) { + result.documentChanges.push(normalized); + } + } + } + + if ( + (!result.changes || Object.keys(result.changes).length === 0) && + (!result.documentChanges || result.documentChanges.length === 0) + ) { + return null; + } + + return result; + } + + /** + * 规范化文本编辑 + */ + private normalizeTextEdit(edit: unknown): LspTextEdit | null { + if (!edit || typeof edit !== 'object') { + return null; + } + + const editObj = edit as Record; + const range = this.normalizeRange(editObj['range']); + if (!range) { + return null; + } + + const newText = + typeof editObj['newText'] === 'string' + ? (editObj['newText'] as string) + : ''; + + return { range, newText }; + } + + /** + * 规范化文本文档编辑 + */ + private normalizeTextDocumentEdit( + docEdit: unknown, + ): { textDocument: { uri: string; version?: number | null }; edits: LspTextEdit[] } | null { + if (!docEdit || typeof docEdit !== 'object') { + return null; + } + + const docEditObj = docEdit as Record; + const textDocument = docEditObj['textDocument']; + if (!textDocument || typeof textDocument !== 'object') { + return null; + } + + const textDocObj = textDocument as Record; + const uri = + typeof textDocObj['uri'] === 'string' + ? (textDocObj['uri'] as string) + : ''; + if (!uri) { + return null; + } + + const version = + typeof textDocObj['version'] === 'number' + ? (textDocObj['version'] as number) + : null; + + const edits = docEditObj['edits']; + if (!Array.isArray(edits)) { + return null; + } + + const normalizedEdits: LspTextEdit[] = []; + for (const e of edits) { + const normalized = this.normalizeTextEdit(e); + if (normalized) { + normalizedEdits.push(normalized); + } + } + + if (normalizedEdits.length === 0) { + return null; + } + + return { + textDocument: { uri, version }, + edits: normalizedEdits, + }; + } + + /** + * 规范化命令 + */ + private normalizeCommand( + cmd: unknown, + ): { title: string; command: string; arguments?: unknown[] } | null { + if (!cmd || typeof cmd !== 'object') { + return null; + } + + const cmdObj = cmd as Record; + const title = + typeof cmdObj['title'] === 'string' ? (cmdObj['title'] as string) : ''; + const command = + typeof cmdObj['command'] === 'string' + ? (cmdObj['command'] as string) + : ''; + + if (!command) { + return null; + } + + const args = Array.isArray(cmdObj['arguments']) + ? (cmdObj['arguments'] as unknown[]) + : undefined; + + return { title, command, arguments: args }; + } + /** * 检测工作区中的编程语言 */ diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 936a784ac..1602b286c 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -95,6 +95,153 @@ export interface LspCallHierarchyOutgoingCall { fromRanges: LspRange[]; } +/** + * Diagnostic severity levels from LSP specification. + */ +export type LspDiagnosticSeverity = 'error' | 'warning' | 'information' | 'hint'; + +/** + * A diagnostic message from a language server. + */ +export interface LspDiagnostic { + /** The range at which the diagnostic applies. */ + range: LspRange; + /** The diagnostic's severity (error, warning, information, hint). */ + severity?: LspDiagnosticSeverity; + /** The diagnostic's code (string or number). */ + code?: string | number; + /** A human-readable string describing the source (e.g., 'typescript'). */ + source?: string; + /** The diagnostic's message. */ + message: string; + /** Additional metadata about the diagnostic. */ + tags?: LspDiagnosticTag[]; + /** Related diagnostic information. */ + relatedInformation?: LspDiagnosticRelatedInformation[]; + /** The LSP server that provided this diagnostic. */ + serverName?: string; +} + +/** + * Diagnostic tags from LSP specification. + */ +export type LspDiagnosticTag = 'unnecessary' | 'deprecated'; + +/** + * Related diagnostic information. + */ +export interface LspDiagnosticRelatedInformation { + /** The location of the related diagnostic. */ + location: LspLocation; + /** The message of the related diagnostic. */ + message: string; +} + +/** + * A file's diagnostics grouped by URI. + */ +export interface LspFileDiagnostics { + /** The document URI. */ + uri: string; + /** The diagnostics for this document. */ + diagnostics: LspDiagnostic[]; + /** The LSP server that provided these diagnostics. */ + serverName?: string; +} + +/** + * A code action represents a change that can be performed in code. + */ +export interface LspCodeAction { + /** A short, human-readable title for this code action. */ + title: string; + /** The kind of the code action (quickfix, refactor, etc.). */ + kind?: LspCodeActionKind; + /** The diagnostics that this code action resolves. */ + diagnostics?: LspDiagnostic[]; + /** Marks this as a preferred action. */ + isPreferred?: boolean; + /** The workspace edit this code action performs. */ + edit?: LspWorkspaceEdit; + /** A command this code action executes. */ + command?: LspCommand; + /** Opaque data used by the server for subsequent resolve calls. */ + data?: unknown; + /** The LSP server that provided this code action. */ + serverName?: string; +} + +/** + * Code action kinds from LSP specification. + */ +export type LspCodeActionKind = + | 'quickfix' + | 'refactor' + | 'refactor.extract' + | 'refactor.inline' + | 'refactor.rewrite' + | 'source' + | 'source.organizeImports' + | 'source.fixAll' + | string; + +/** + * A workspace edit represents changes to many resources managed in the workspace. + */ +export interface LspWorkspaceEdit { + /** Holds changes to existing documents. */ + changes?: Record; + /** Versioned document changes (more precise control). */ + documentChanges?: LspTextDocumentEdit[]; +} + +/** + * A text edit applicable to a document. + */ +export interface LspTextEdit { + /** The range of the text document to be manipulated. */ + range: LspRange; + /** The string to be inserted (empty string for delete). */ + newText: string; +} + +/** + * Describes textual changes on a single text document. + */ +export interface LspTextDocumentEdit { + /** The text document to change. */ + textDocument: { + uri: string; + version?: number | null; + }; + /** The edits to be applied. */ + edits: LspTextEdit[]; +} + +/** + * A command represents a reference to a command. + */ +export interface LspCommand { + /** Title of the command. */ + title: string; + /** The identifier of the actual command handler. */ + command: string; + /** Arguments to the command handler. */ + arguments?: unknown[]; +} + +/** + * Context for code action requests. + */ +export interface LspCodeActionContext { + /** The diagnostics for which code actions are requested. */ + diagnostics: LspDiagnostic[]; + /** Requested kinds of code actions to return. */ + only?: LspCodeActionKind[]; + /** The reason why code actions were requested. */ + triggerKind?: 'invoked' | 'automatic'; +} + export interface LspClient { /** * Search for symbols across the workspace. @@ -175,4 +322,39 @@ export interface LspClient { serverName?: string, limit?: number, ): Promise; + + /** + * Get diagnostics for a specific document. + */ + diagnostics( + uri: string, + serverName?: string, + ): Promise; + + /** + * Get diagnostics for all open documents in the workspace. + */ + workspaceDiagnostics( + serverName?: string, + limit?: number, + ): Promise; + + /** + * Get code actions available at a specific location. + */ + codeActions( + uri: string, + range: LspRange, + context: LspCodeActionContext, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Apply a workspace edit (from code action or other sources). + */ + applyWorkspaceEdit( + edit: LspWorkspaceEdit, + serverName?: string, + ): Promise; } diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts index 41487830e..d53b473d6 100644 --- a/packages/core/src/tools/lsp.ts +++ b/packages/core/src/tools/lsp.ts @@ -15,7 +15,12 @@ import type { LspCallHierarchyItem, LspCallHierarchyOutgoingCall, LspClient, + LspCodeAction, + LspCodeActionContext, + LspCodeActionKind, LspDefinition, + LspDiagnostic, + LspFileDiagnostics, LspLocation, LspRange, LspReference, @@ -34,7 +39,10 @@ export type LspOperation = | 'goToImplementation' | 'prepareCallHierarchy' | 'incomingCalls' - | 'outgoingCalls'; + | 'outgoingCalls' + | 'diagnostics' + | 'workspaceDiagnostics' + | 'codeActions'; /** * Parameters for the unified LSP tool. @@ -48,6 +56,10 @@ export interface LspToolParams { line?: number; /** 1-based character/column number when targeting a specific file location. */ character?: number; + /** End line for range-based operations (1-based). */ + endLine?: number; + /** End character for range-based operations (1-based). */ + endCharacter?: number; /** Whether to include the declaration in reference results. */ includeDeclaration?: boolean; /** Query string for workspace symbol search. */ @@ -58,6 +70,10 @@ export interface LspToolParams { serverName?: string; /** Optional maximum number of results. */ limit?: number; + /** Diagnostics for code action context. */ + diagnostics?: LspDiagnostic[]; + /** Code action kinds to filter by. */ + codeActionKinds?: LspCodeActionKind[]; } type ResolvedTarget = @@ -77,7 +93,10 @@ const LOCATION_REQUIRED_OPERATIONS = new Set([ ]); /** Operations that only require filePath. */ -const FILE_REQUIRED_OPERATIONS = new Set(['documentSymbol']); +const FILE_REQUIRED_OPERATIONS = new Set([ + 'documentSymbol', + 'diagnostics', +]); /** Operations that require query. */ const QUERY_REQUIRED_OPERATIONS = new Set(['workspaceSymbol']); @@ -88,6 +107,12 @@ const ITEM_REQUIRED_OPERATIONS = new Set([ 'outgoingCalls', ]); +/** Operations that require filePath and range for code actions. */ +const RANGE_REQUIRED_OPERATIONS = new Set(['codeActions']); + +/** Operations that don't require specific parameters. */ +const NO_PARAM_OPERATIONS = new Set(['workspaceDiagnostics']); + class LspToolInvocation extends BaseToolInvocation { constructor( private readonly config: Config, @@ -147,6 +172,12 @@ class LspToolInvocation extends BaseToolInvocation { return this.executeIncomingCalls(client); case 'outgoingCalls': return this.executeOutgoingCalls(client); + case 'diagnostics': + return this.executeDiagnostics(client); + case 'workspaceDiagnostics': + return this.executeWorkspaceDiagnostics(client); + case 'codeActions': + return this.executeCodeActions(client); default: { const message = `Unsupported LSP operation: ${this.params.operation}`; return { llmContent: message, returnDisplay: message }; @@ -608,6 +639,162 @@ class LspToolInvocation extends BaseToolInvocation { }; } + private async executeDiagnostics(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 diagnostics.'; + return { llmContent: message, returnDisplay: message }; + } + + let diagnostics: LspDiagnostic[] = []; + try { + diagnostics = await client.diagnostics(uri, this.params.serverName); + } catch (error) { + const message = `LSP diagnostics failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!diagnostics.length) { + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const message = `No diagnostics found for ${fileLabel}.`; + return { llmContent: message, returnDisplay: message }; + } + + const lines = diagnostics.map((diag, index) => { + const severity = diag.severity ? `[${diag.severity.toUpperCase()}]` : ''; + const position = `${diag.range.start.line + 1}:${diag.range.start.character + 1}`; + const code = diag.code ? ` (${diag.code})` : ''; + const source = diag.source ? ` [${diag.source}]` : ''; + return `${index + 1}. ${severity} ${position}${code}${source}: ${diag.message}`; + }); + + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const heading = `Diagnostics for ${fileLabel} (${diagnostics.length} issues):`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeWorkspaceDiagnostics( + client: LspClient, + ): Promise { + const limit = this.params.limit ?? 50; + let fileDiagnostics: LspFileDiagnostics[] = []; + try { + fileDiagnostics = await client.workspaceDiagnostics( + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP workspace diagnostics failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!fileDiagnostics.length) { + const message = 'No diagnostics found in the workspace.'; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines: string[] = []; + let totalIssues = 0; + + for (const fileDiag of fileDiagnostics) { + const fileLabel = this.formatUriForDisplay(fileDiag.uri, workspaceRoot); + const serverSuffix = fileDiag.serverName ? ` [${fileDiag.serverName}]` : ''; + lines.push(`\n${fileLabel}${serverSuffix}:`); + + for (const diag of fileDiag.diagnostics) { + const severity = diag.severity ? `[${diag.severity.toUpperCase()}]` : ''; + const position = `${diag.range.start.line + 1}:${diag.range.start.character + 1}`; + const code = diag.code ? ` (${diag.code})` : ''; + lines.push(` ${severity} ${position}${code}: ${diag.message}`); + totalIssues++; + } + } + + const heading = `Workspace diagnostics (${totalIssues} issues in ${fileDiagnostics.length} files):`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeCodeActions(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 code actions.'; + return { llmContent: message, returnDisplay: message }; + } + + // Build range from params + const startLine = Math.max(0, (this.params.line ?? 1) - 1); + const startChar = Math.max(0, (this.params.character ?? 1) - 1); + const endLine = Math.max(0, (this.params.endLine ?? this.params.line ?? 1) - 1); + const endChar = Math.max(0, (this.params.endCharacter ?? this.params.character ?? 1) - 1); + + const range: LspRange = { + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar }, + }; + + // Build context + const context: LspCodeActionContext = { + diagnostics: this.params.diagnostics ?? [], + only: this.params.codeActionKinds, + triggerKind: 'invoked', + }; + + const limit = this.params.limit ?? 20; + let actions: LspCodeAction[] = []; + try { + actions = await client.codeActions( + uri, + range, + context, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP code actions failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!actions.length) { + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const message = `No code actions available at ${fileLabel}:${startLine + 1}:${startChar + 1}.`; + return { llmContent: message, returnDisplay: message }; + } + + const lines = actions.slice(0, limit).map((action, index) => { + const kind = action.kind ? ` [${action.kind}]` : ''; + const preferred = action.isPreferred ? ' ★' : ''; + const hasEdit = action.edit ? ' (has edit)' : ''; + const hasCommand = action.command ? ' (has command)' : ''; + const serverSuffix = action.serverName ? ` [${action.serverName}]` : ''; + return `${index + 1}. ${action.title}${kind}${preferred}${hasEdit}${hasCommand}${serverSuffix}`; + }); + + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const heading = `Code actions at ${fileLabel}:${startLine + 1}:${startChar + 1}:`; + const jsonSection = this.formatJsonSection('Code actions (JSON)', actions.slice(0, limit)); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + private resolveLocationTarget(): ResolvedTarget { const filePath = this.params.filePath; if (!filePath) { @@ -787,6 +974,12 @@ class LspToolInvocation extends BaseToolInvocation { return 'incoming calls'; case 'outgoingCalls': return 'outgoing calls'; + case 'diagnostics': + return 'diagnostics'; + case 'workspaceDiagnostics': + return 'workspace diagnostics'; + case 'codeActions': + return 'code actions'; default: return this.params.operation; } @@ -804,6 +997,9 @@ class LspToolInvocation extends BaseToolInvocation { * - 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 + * - diagnostics: Get diagnostic messages (errors, warnings) for a file + * - workspaceDiagnostics: Get all diagnostic messages across the workspace + * - codeActions: Get available code actions (quick fixes, refactorings) at a location */ export class LspTool extends BaseDeclarativeTool { static readonly Name = ToolNames.LSP; @@ -812,7 +1008,7 @@ export class LspTool extends BaseDeclarativeTool { super( LspTool.Name, ToolDisplayNames.LSP, - 'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.', + 'Unified LSP operations for definitions, references, hover, symbols, call hierarchy, diagnostics, and code actions.', Kind.Other, { type: 'object', @@ -830,6 +1026,9 @@ export class LspTool extends BaseDeclarativeTool { 'prepareCallHierarchy', 'incomingCalls', 'outgoingCalls', + 'diagnostics', + 'workspaceDiagnostics', + 'codeActions', ], }, filePath: { @@ -845,6 +1044,14 @@ export class LspTool extends BaseDeclarativeTool { description: '1-based character/column number for the target location.', }, + endLine: { + type: 'number', + description: '1-based end line number for range-based operations.', + }, + endCharacter: { + type: 'number', + description: '1-based end character for range-based operations.', + }, includeDeclaration: { type: 'boolean', description: @@ -866,6 +1073,16 @@ export class LspTool extends BaseDeclarativeTool { type: 'number', description: 'Optional maximum number of results to return.', }, + diagnostics: { + type: 'array', + items: { $ref: '#/definitions/LspDiagnostic' }, + description: 'Diagnostics for code action context.', + }, + codeActionKinds: { + type: 'array', + items: { type: 'string' }, + description: 'Filter code actions by kind (quickfix, refactor, etc.).', + }, }, required: ['operation'], definitions: { @@ -900,6 +1117,21 @@ export class LspTool extends BaseDeclarativeTool { }, required: ['name', 'uri', 'range', 'selectionRange'], }, + LspDiagnostic: { + type: 'object', + properties: { + range: { $ref: '#/definitions/LspRange' }, + severity: { + type: 'string', + enum: ['error', 'warning', 'information', 'hint'], + }, + code: { type: ['string', 'number'] }, + source: { type: 'string' }, + message: { type: 'string' }, + serverName: { type: 'string' }, + }, + required: ['range', 'message'], + }, }, }, false, @@ -939,12 +1171,27 @@ export class LspTool extends BaseDeclarativeTool { } } + if (RANGE_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 (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.endLine !== undefined && params.endLine < 1) { + return 'endLine must be a positive number.'; + } + if (params.endCharacter !== undefined && params.endCharacter < 1) { + return 'endCharacter must be a positive number.'; + } if (params.limit !== undefined && params.limit <= 0) { return 'limit must be a positive number.'; } From d07557403017c35684647ed7b457ca774211ea61 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 21 Jan 2026 01:16:08 +0800 Subject: [PATCH 07/15] wip: lsp --- docs/users/features/lsp.md | 383 ++++++++ .../lsp/NativeLspService.integration.test.ts | 818 ++++++++++++++++++ 2 files changed, 1201 insertions(+) create mode 100644 docs/users/features/lsp.md create mode 100644 packages/cli/src/services/lsp/NativeLspService.integration.test.ts diff --git a/docs/users/features/lsp.md b/docs/users/features/lsp.md new file mode 100644 index 000000000..61e063223 --- /dev/null +++ b/docs/users/features/lsp.md @@ -0,0 +1,383 @@ +# Language Server Protocol (LSP) Support + +Qwen Code provides native Language Server Protocol (LSP) support, enabling advanced code intelligence features like go-to-definition, find references, diagnostics, and code actions. This integration allows the AI agent to understand your code more deeply and provide more accurate assistance. + +## Overview + +LSP support in Qwen Code works by connecting to language servers that understand your code. When you work with TypeScript, Python, Go, or other supported languages, Qwen Code can automatically start the appropriate language server and use it to: + +- Navigate to symbol definitions +- Find all references to a symbol +- Get hover information (documentation, type info) +- View diagnostic messages (errors, warnings) +- Access code actions (quick fixes, refactorings) +- Analyze call hierarchies + +## Quick Start + +LSP is enabled by default in Qwen Code. For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system. + +### Prerequisites + +You need to have the language server for your programming language installed: + +| Language | Language Server | Install Command | +|----------|----------------|-----------------| +| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` | +| Python | pylsp | `pip install python-lsp-server` | +| Go | gopls | `go install golang.org/x/tools/gopls@latest` | +| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) | + +## Configuration + +### Settings + +You can configure LSP behavior in your `settings.json`: + +```json +{ + "lsp": { + "enabled": true, + "autoDetect": true, + "serverTimeout": 10000, + "allowed": [], + "excluded": [] + } +} +``` + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `lsp.enabled` | boolean | `true` | Enable/disable LSP support | +| `lsp.autoDetect` | boolean | `true` | Automatically detect and start language servers | +| `lsp.serverTimeout` | number | `10000` | Server startup timeout in milliseconds | +| `lsp.allowed` | string[] | `[]` | Allow only these servers (empty = allow all) | +| `lsp.excluded` | string[] | `[]` | Exclude these servers from starting | + +### Custom Language Servers + +You can configure custom language servers using a `.lsp.json` file in your project root: + +```json +{ + "languageServers": { + "my-custom-lsp": { + "languages": ["mylang"], + "command": "my-lsp-server", + "args": ["--stdio"], + "transport": "stdio", + "initializationOptions": {}, + "settings": {} + } + } +} +``` + +#### Configuration Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `languages` | string[] | Yes | Languages this server handles | +| `command` | string | Yes* | Command to start the server | +| `args` | string[] | No | Command line arguments | +| `transport` | string | No | Transport type: `stdio` (default), `tcp`, or `socket` | +| `env` | object | No | Environment variables | +| `initializationOptions` | object | No | LSP initialization options | +| `settings` | object | No | Server settings | +| `workspaceFolder` | string | No | Override workspace folder | +| `startupTimeout` | number | No | Startup timeout in ms | +| `shutdownTimeout` | number | No | Shutdown timeout in ms | +| `restartOnCrash` | boolean | No | Auto-restart on crash | +| `maxRestarts` | number | No | Maximum restart attempts | +| `trustRequired` | boolean | No | Require trusted workspace | + +*Required for `stdio` transport + +#### TCP/Socket Transport + +For servers that use TCP or Unix socket transport: + +```json +{ + "languageServers": { + "remote-lsp": { + "languages": ["custom"], + "transport": "tcp", + "socket": { + "host": "127.0.0.1", + "port": 9999 + } + } + } +} +``` + +## Available LSP Operations + +Qwen Code exposes LSP functionality through the unified `lsp` tool. Here are the available operations: + +### Code Navigation + +#### Go to Definition +Find where a symbol is defined. + +``` +Operation: goToDefinition +Parameters: + - filePath: Path to the file + - line: Line number (1-based) + - character: Column number (1-based) +``` + +#### Find References +Find all references to a symbol. + +``` +Operation: findReferences +Parameters: + - filePath: Path to the file + - line: Line number (1-based) + - character: Column number (1-based) + - includeDeclaration: Include the declaration itself (optional) +``` + +#### Go to Implementation +Find implementations of an interface or abstract method. + +``` +Operation: goToImplementation +Parameters: + - filePath: Path to the file + - line: Line number (1-based) + - character: Column number (1-based) +``` + +### Symbol Information + +#### Hover +Get documentation and type information for a symbol. + +``` +Operation: hover +Parameters: + - filePath: Path to the file + - line: Line number (1-based) + - character: Column number (1-based) +``` + +#### Document Symbols +Get all symbols in a document. + +``` +Operation: documentSymbol +Parameters: + - filePath: Path to the file +``` + +#### Workspace Symbol Search +Search for symbols across the workspace. + +``` +Operation: workspaceSymbol +Parameters: + - query: Search query string + - limit: Maximum results (optional) +``` + +### Call Hierarchy + +#### Prepare Call Hierarchy +Get the call hierarchy item at a position. + +``` +Operation: prepareCallHierarchy +Parameters: + - filePath: Path to the file + - line: Line number (1-based) + - character: Column number (1-based) +``` + +#### Incoming Calls +Find all functions that call the given function. + +``` +Operation: incomingCalls +Parameters: + - callHierarchyItem: Item from prepareCallHierarchy +``` + +#### Outgoing Calls +Find all functions called by the given function. + +``` +Operation: outgoingCalls +Parameters: + - callHierarchyItem: Item from prepareCallHierarchy +``` + +### Diagnostics + +#### File Diagnostics +Get diagnostic messages (errors, warnings) for a file. + +``` +Operation: diagnostics +Parameters: + - filePath: Path to the file +``` + +#### Workspace Diagnostics +Get all diagnostic messages across the workspace. + +``` +Operation: workspaceDiagnostics +Parameters: + - limit: Maximum results (optional) +``` + +### Code Actions + +#### Get Code Actions +Get available code actions (quick fixes, refactorings) at a location. + +``` +Operation: codeActions +Parameters: + - filePath: Path to the file + - line: Start line number (1-based) + - character: Start column number (1-based) + - endLine: End line number (optional, defaults to line) + - endCharacter: End column (optional, defaults to character) + - diagnostics: Diagnostics to get actions for (optional) + - codeActionKinds: Filter by action kind (optional) +``` + +Code action kinds: +- `quickfix` - Quick fixes for errors/warnings +- `refactor` - Refactoring operations +- `refactor.extract` - Extract to function/variable +- `refactor.inline` - Inline function/variable +- `source` - Source code actions +- `source.organizeImports` - Organize imports +- `source.fixAll` - Fix all auto-fixable issues + +## Security + +LSP servers are only started in trusted workspaces by default. This is because language servers run with your user permissions and can execute code. + +### Trust Controls + +- **Trusted Workspace**: LSP servers start automatically +- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` + +To mark a workspace as trusted, use the `/trust` command or configure trusted folders in settings. + +### Server Allowlists + +You can restrict which servers are allowed to run: + +```json +{ + "lsp": { + "allowed": ["typescript-language-server", "gopls"], + "excluded": ["untrusted-server"] + } +} +``` + +## Troubleshooting + +### Server Not Starting + +1. **Check if the server is installed**: Run the command manually to verify +2. **Check the PATH**: Ensure the server binary is in your system PATH +3. **Check workspace trust**: The workspace must be trusted for LSP +4. **Check logs**: Look for error messages in the console output + +### Slow Performance + +1. **Large projects**: Consider excluding `node_modules` and other large directories +2. **Server timeout**: Increase `lsp.serverTimeout` for slow servers +3. **Multiple servers**: Exclude unused language servers + +### No Results + +1. **Server not ready**: The server may still be indexing +2. **File not saved**: Save your file for the server to pick up changes +3. **Wrong language**: Check if the correct server is running for your language + +### Debugging + +Enable debug logging to see LSP communication: + +```bash +DEBUG=lsp* qwen +``` + +Or check the LSP debugging guide at `packages/cli/LSP_DEBUGGING_GUIDE.md`. + +## Claude Code Compatibility + +Qwen Code supports Claude Code-style `.lsp.json` configuration files. If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes. + +### Legacy Format + +The legacy format (used by earlier versions) is still supported but deprecated: + +```json +{ + "typescript": { + "command": "typescript-language-server", + "args": ["--stdio"], + "transport": "stdio" + } +} +``` + +We recommend migrating to the new `languageServers` format: + +```json +{ + "languageServers": { + "typescript-language-server": { + "languages": ["typescript", "javascript"], + "command": "typescript-language-server", + "args": ["--stdio"], + "transport": "stdio" + } + } +} +``` + +## Best Practices + +1. **Install language servers globally**: This ensures they're available in all projects +2. **Use project-specific settings**: Configure server options per project when needed +3. **Keep servers updated**: Update your language servers regularly for best results +4. **Trust wisely**: Only trust workspaces from trusted sources + +## FAQ + +### Q: How do I know which language servers are running? + +Use the `/lsp status` command to see all configured and running language servers. + +### Q: Can I use multiple language servers for the same file type? + +Yes, but only one will be used for each operation. The first server that returns results wins. + +### Q: Does LSP work in sandbox mode? + +LSP servers run outside the sandbox to access your code. They're subject to workspace trust controls. + +### Q: How do I disable LSP for a specific project? + +Add to your project's `.qwen/settings.json`: + +```json +{ + "lsp": { + "enabled": false + } +} +``` diff --git a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts new file mode 100644 index 000000000..bb0a30b64 --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts @@ -0,0 +1,818 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { NativeLspService } from './NativeLspService.js'; +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, + LspLocation, + LspDiagnostic, +} from '@qwen-code/qwen-code-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * Mock LSP server responses for integration testing. + * This simulates real LSP server behavior without requiring an actual server. + */ +const MOCK_LSP_RESPONSES = { + 'initialize': { + capabilities: { + textDocumentSync: 1, + completionProvider: {}, + hoverProvider: true, + definitionProvider: true, + referencesProvider: true, + documentSymbolProvider: true, + workspaceSymbolProvider: true, + codeActionProvider: true, + diagnosticProvider: { + interFileDependencies: true, + workspaceDiagnostics: true, + }, + }, + serverInfo: { + name: 'mock-lsp-server', + version: '1.0.0', + }, + }, + 'textDocument/definition': [ + { + uri: 'file:///test/workspace/src/types.ts', + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 20 }, + }, + }, + ], + 'textDocument/references': [ + { + uri: 'file:///test/workspace/src/app.ts', + range: { + start: { line: 5, character: 10 }, + end: { line: 5, character: 20 }, + }, + }, + { + uri: 'file:///test/workspace/src/utils.ts', + range: { + start: { line: 15, character: 5 }, + end: { line: 15, character: 15 }, + }, + }, + ], + 'textDocument/hover': { + contents: { + kind: 'markdown', + value: '```typescript\nfunction testFunc(): void\n```\n\nA test function.', + }, + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 8 }, + }, + }, + 'textDocument/documentSymbol': [ + { + name: 'TestClass', + kind: 5, // Class + range: { + start: { line: 0, character: 0 }, + end: { line: 20, character: 1 }, + }, + selectionRange: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 15 }, + }, + children: [ + { + name: 'constructor', + kind: 9, // Constructor + range: { + start: { line: 2, character: 2 }, + end: { line: 4, character: 3 }, + }, + selectionRange: { + start: { line: 2, character: 2 }, + end: { line: 2, character: 13 }, + }, + }, + ], + }, + ], + 'workspace/symbol': [ + { + name: 'TestClass', + kind: 5, // Class + location: { + uri: 'file:///test/workspace/src/test.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 20, character: 1 }, + }, + }, + }, + { + name: 'testFunction', + kind: 12, // Function + location: { + uri: 'file:///test/workspace/src/utils.ts', + range: { + start: { line: 5, character: 0 }, + end: { line: 10, character: 1 }, + }, + }, + containerName: 'utils', + }, + ], + 'textDocument/implementation': [ + { + uri: 'file:///test/workspace/src/impl.ts', + range: { + start: { line: 20, character: 0 }, + end: { line: 40, character: 1 }, + }, + }, + ], + 'textDocument/prepareCallHierarchy': [ + { + name: 'testFunction', + kind: 12, // Function + detail: '(param: string) => void', + uri: 'file:///test/workspace/src/utils.ts', + range: { + start: { line: 5, character: 0 }, + end: { line: 10, character: 1 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 21 }, + }, + }, + ], + 'callHierarchy/incomingCalls': [ + { + from: { + name: 'callerFunction', + kind: 12, + uri: 'file:///test/workspace/src/caller.ts', + range: { + start: { line: 10, character: 0 }, + end: { line: 15, character: 1 }, + }, + selectionRange: { + start: { line: 10, character: 9 }, + end: { line: 10, character: 23 }, + }, + }, + fromRanges: [ + { + start: { line: 12, character: 2 }, + end: { line: 12, character: 16 }, + }, + ], + }, + ], + 'callHierarchy/outgoingCalls': [ + { + to: { + name: 'helperFunction', + kind: 12, + uri: 'file:///test/workspace/src/helper.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 5, character: 1 }, + }, + selectionRange: { + start: { line: 0, character: 9 }, + end: { line: 0, character: 23 }, + }, + }, + fromRanges: [ + { + start: { line: 7, character: 2 }, + end: { line: 7, character: 16 }, + }, + ], + }, + ], + 'textDocument/diagnostic': { + kind: 'full', + items: [ + { + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 10 }, + }, + severity: 1, // Error + code: 'TS2304', + source: 'typescript', + message: "Cannot find name 'undeclaredVar'.", + }, + { + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 15 }, + }, + severity: 2, // Warning + code: 'TS6133', + source: 'typescript', + message: "'unusedVar' is declared but its value is never read.", + tags: [1], // Unnecessary + }, + ], + }, + 'workspace/diagnostic': { + items: [ + { + kind: 'full', + uri: 'file:///test/workspace/src/app.ts', + items: [ + { + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 10 }, + }, + severity: 1, + code: 'TS2304', + source: 'typescript', + message: "Cannot find name 'undeclaredVar'.", + }, + ], + }, + { + kind: 'full', + uri: 'file:///test/workspace/src/utils.ts', + items: [ + { + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 15 }, + }, + severity: 2, + code: 'TS6133', + source: 'typescript', + message: "'unusedVar' is declared but its value is never read.", + }, + ], + }, + ], + }, + 'textDocument/codeAction': [ + { + title: "Add missing import 'React'", + kind: 'quickfix', + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + severity: 1, + message: "Cannot find name 'React'.", + }, + ], + edit: { + changes: { + 'file:///test/workspace/src/app.tsx': [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: "import React from 'react';\n", + }, + ], + }, + }, + isPreferred: true, + }, + { + title: 'Organize imports', + kind: 'source.organizeImports', + edit: { + changes: { + 'file:///test/workspace/src/app.tsx': [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 5, character: 0 }, + }, + newText: "import { Component } from 'react';\nimport { helper } from './utils';\n", + }, + ], + }, + }, + }, + ], +}; + +/** + * Mock configuration for testing. + */ +class MockConfig { + rootPath = '/test/workspace'; + private trusted = true; + + isTrustedFolder(): boolean { + return this.trusted; + } + + setTrusted(trusted: boolean): void { + this.trusted = trusted; + } + + get(_key: string) { + return undefined; + } + + getProjectRoot(): string { + return this.rootPath; + } +} + +/** + * Mock workspace context for testing. + */ +class MockWorkspaceContext { + rootPath = '/test/workspace'; + + async fileExists(filePath: string): Promise { + return ( + filePath.endsWith('.json') || + filePath.includes('package.json') || + filePath.includes('.ts') + ); + } + + async readFile(filePath: string): Promise { + if (filePath.includes('.lsp.json')) { + return JSON.stringify({ + 'mock-lsp': { + languages: ['typescript', 'javascript'], + command: 'mock-lsp-server', + args: ['--stdio'], + transport: 'stdio', + }, + }); + } + return '{}'; + } + + resolvePath(relativePath: string): string { + return this.rootPath + '/' + relativePath; + } + + isPathWithinWorkspace(_path: string): boolean { + return true; + } + + getDirectories(): string[] { + return [this.rootPath]; + } +} + +/** + * Mock file discovery service for testing. + */ +class MockFileDiscoveryService { + async discoverFiles(_root: string, _options: unknown): Promise { + return [ + '/test/workspace/src/index.ts', + '/test/workspace/src/app.ts', + '/test/workspace/src/utils.ts', + '/test/workspace/src/types.ts', + ]; + } + + shouldIgnoreFile(file: string): boolean { + return file.includes('node_modules') || file.includes('.git'); + } +} + +/** + * Mock IDE context store for testing. + */ +class MockIdeContextStore {} + +describe('NativeLspService Integration Tests', () => { + let lspService: NativeLspService; + let mockConfig: MockConfig; + let mockWorkspace: MockWorkspaceContext; + let mockFileDiscovery: MockFileDiscoveryService; + let mockIdeStore: MockIdeContextStore; + let eventEmitter: EventEmitter; + + beforeEach(() => { + mockConfig = new MockConfig(); + mockWorkspace = new MockWorkspaceContext(); + mockFileDiscovery = new MockFileDiscoveryService(); + mockIdeStore = new MockIdeContextStore(); + eventEmitter = new EventEmitter(); + + lspService = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + { + workspaceRoot: mockWorkspace.rootPath, + }, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Service Lifecycle', () => { + it('should initialize service correctly', () => { + expect(lspService).toBeDefined(); + }); + + it('should discover and prepare without errors', async () => { + await expect(lspService.discoverAndPrepare()).resolves.not.toThrow(); + }); + + it('should return status after discovery', async () => { + await lspService.discoverAndPrepare(); + const status = lspService.getStatus(); + expect(status).toBeDefined(); + expect(status instanceof Map).toBe(true); + }); + + it('should skip discovery for untrusted workspace', async () => { + mockConfig.setTrusted(false); + const untrustedService = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + { + workspaceRoot: mockWorkspace.rootPath, + requireTrustedWorkspace: true, + }, + ); + + await untrustedService.discoverAndPrepare(); + const status = untrustedService.getStatus(); + expect(status.size).toBe(0); + }); + }); + + describe('Configuration Merging', () => { + it('should detect TypeScript/JavaScript in workspace', async () => { + await lspService.discoverAndPrepare(); + const status = lspService.getStatus(); + + // Should have detected TypeScript based on mock file discovery + // The exact server name depends on built-in presets + expect(status.size).toBeGreaterThanOrEqual(0); + }); + + it('should respect allowed servers list', async () => { + const restrictedService = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + { + workspaceRoot: mockWorkspace.rootPath, + allowedServers: ['typescript-language-server'], + }, + ); + + await restrictedService.discoverAndPrepare(); + const status = restrictedService.getStatus(); + + // Only allowed servers should be present + for (const [name] of status) { + expect( + name === 'typescript-language-server' || + status.get(name) === 'FAILED' + ).toBe(true); + } + }); + + it('should respect excluded servers list', async () => { + const restrictedService = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + { + workspaceRoot: mockWorkspace.rootPath, + excludedServers: ['pylsp'], + }, + ); + + await restrictedService.discoverAndPrepare(); + const status = restrictedService.getStatus(); + + // pylsp should not be present or should be FAILED + const pylspStatus = status.get('pylsp'); + expect(pylspStatus !== 'READY').toBe(true); + }); + }); + + describe('LSP Operations - Mock Responses', () => { + // Note: These tests verify the structure of expected responses + // In a real integration test, you would mock the connection or use a real server + + it('should format definition response correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/definition']; + expect(response).toHaveLength(1); + expect(response[0]).toHaveProperty('uri'); + expect(response[0]).toHaveProperty('range'); + expect(response[0].range.start).toHaveProperty('line'); + expect(response[0].range.start).toHaveProperty('character'); + }); + + it('should format references response correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/references']; + expect(response).toHaveLength(2); + for (const ref of response) { + expect(ref).toHaveProperty('uri'); + expect(ref).toHaveProperty('range'); + } + }); + + it('should format hover response correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/hover']; + expect(response).toHaveProperty('contents'); + expect(response.contents).toHaveProperty('value'); + expect(response.contents.value).toContain('testFunc'); + }); + + it('should format document symbols correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/documentSymbol']; + expect(response).toHaveLength(1); + expect(response[0].name).toBe('TestClass'); + expect(response[0].kind).toBe(5); // Class + expect(response[0].children).toHaveLength(1); + }); + + it('should format workspace symbols correctly', () => { + const response = MOCK_LSP_RESPONSES['workspace/symbol']; + expect(response).toHaveLength(2); + expect(response[0].name).toBe('TestClass'); + expect(response[1].name).toBe('testFunction'); + expect(response[1].containerName).toBe('utils'); + }); + + it('should format call hierarchy items correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/prepareCallHierarchy']; + expect(response).toHaveLength(1); + expect(response[0].name).toBe('testFunction'); + expect(response[0]).toHaveProperty('detail'); + expect(response[0]).toHaveProperty('range'); + expect(response[0]).toHaveProperty('selectionRange'); + }); + + it('should format incoming calls correctly', () => { + const response = MOCK_LSP_RESPONSES['callHierarchy/incomingCalls']; + expect(response).toHaveLength(1); + expect(response[0].from.name).toBe('callerFunction'); + expect(response[0].fromRanges).toHaveLength(1); + }); + + it('should format outgoing calls correctly', () => { + const response = MOCK_LSP_RESPONSES['callHierarchy/outgoingCalls']; + expect(response).toHaveLength(1); + expect(response[0].to.name).toBe('helperFunction'); + expect(response[0].fromRanges).toHaveLength(1); + }); + + it('should format diagnostics correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/diagnostic']; + expect(response.items).toHaveLength(2); + expect(response.items[0].severity).toBe(1); // Error + expect(response.items[0].code).toBe('TS2304'); + expect(response.items[1].severity).toBe(2); // Warning + expect(response.items[1].tags).toContain(1); // Unnecessary + }); + + it('should format workspace diagnostics correctly', () => { + const response = MOCK_LSP_RESPONSES['workspace/diagnostic']; + expect(response.items).toHaveLength(2); + expect(response.items[0].uri).toContain('app.ts'); + expect(response.items[1].uri).toContain('utils.ts'); + }); + + it('should format code actions correctly', () => { + const response = MOCK_LSP_RESPONSES['textDocument/codeAction']; + expect(response).toHaveLength(2); + + const quickfix = response[0]; + expect(quickfix.title).toContain('import'); + expect(quickfix.kind).toBe('quickfix'); + expect(quickfix.isPreferred).toBe(true); + expect(quickfix.edit).toHaveProperty('changes'); + + const organizeImports = response[1]; + expect(organizeImports.kind).toBe('source.organizeImports'); + }); + }); + + describe('Diagnostic Normalization', () => { + it('should normalize severity levels correctly', () => { + const severityMap: Record = { + 1: 'error', + 2: 'warning', + 3: 'information', + 4: 'hint', + }; + + for (const [num, label] of Object.entries(severityMap)) { + expect(severityMap[Number(num)]).toBe(label); + } + }); + + it('should normalize diagnostic tags correctly', () => { + const tagMap: Record = { + 1: 'unnecessary', + 2: 'deprecated', + }; + + expect(tagMap[1]).toBe('unnecessary'); + expect(tagMap[2]).toBe('deprecated'); + }); + }); + + describe('Code Action Context', () => { + it('should support filtering by code action kind', () => { + const kinds = ['quickfix', 'refactor', 'source.organizeImports']; + const filteredActions = MOCK_LSP_RESPONSES['textDocument/codeAction'].filter( + (action) => kinds.includes(action.kind), + ); + expect(filteredActions).toHaveLength(2); + }); + + it('should support quick fix actions with diagnostics', () => { + const quickfix = MOCK_LSP_RESPONSES['textDocument/codeAction'][0]; + expect(quickfix.diagnostics).toBeDefined(); + expect(quickfix.diagnostics).toHaveLength(1); + expect(quickfix.edit).toBeDefined(); + }); + }); + + describe('Workspace Edit Application', () => { + it('should structure workspace edits correctly', () => { + const codeAction = MOCK_LSP_RESPONSES['textDocument/codeAction'][0]; + const edit = codeAction.edit; + + expect(edit).toHaveProperty('changes'); + expect(edit?.changes).toBeDefined(); + + const uri = Object.keys(edit?.changes ?? {})[0]; + expect(uri).toContain('app.tsx'); + + const edits = edit?.changes?.[uri]; + expect(edits).toHaveLength(1); + expect(edits?.[0]).toHaveProperty('range'); + expect(edits?.[0]).toHaveProperty('newText'); + }); + }); + + describe('Error Handling', () => { + it('should handle missing workspace gracefully', async () => { + const emptyWorkspace = new MockWorkspaceContext(); + emptyWorkspace.getDirectories = () => []; + + const service = new NativeLspService( + mockConfig as unknown as CoreConfig, + emptyWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + ); + + await expect(service.discoverAndPrepare()).resolves.not.toThrow(); + }); + + it('should return empty results when no server is ready', async () => { + // Before starting any servers, operations should return empty + const results = await lspService.workspaceSymbols('test'); + expect(results).toEqual([]); + }); + + it('should return empty diagnostics when no server is ready', async () => { + const uri = 'file:///test/workspace/src/app.ts'; + const results = await lspService.diagnostics(uri); + expect(results).toEqual([]); + }); + + it('should return empty code actions when no server is ready', async () => { + const uri = 'file:///test/workspace/src/app.ts'; + const range = { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }; + const context = { + diagnostics: [], + only: undefined, + triggerKind: 'invoked' as const, + }; + + const results = await lspService.codeActions(uri, range, context); + expect(results).toEqual([]); + }); + }); + + describe('Security Controls', () => { + it('should respect trust requirements', async () => { + mockConfig.setTrusted(false); + + const strictService = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + { + requireTrustedWorkspace: true, + }, + ); + + await strictService.discoverAndPrepare(); + const status = strictService.getStatus(); + + // No servers should be discovered in untrusted workspace + expect(status.size).toBe(0); + }); + + it('should allow operations in trusted workspace', async () => { + mockConfig.setTrusted(true); + + await lspService.discoverAndPrepare(); + // Service should be ready to accept operations (even if no real server) + expect(lspService).toBeDefined(); + }); + }); +}); + +describe('LSP Response Type Validation', () => { + describe('LspDiagnostic', () => { + it('should have correct structure', () => { + const diagnostic: LspDiagnostic = { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + severity: 'error', + code: 'TS2304', + source: 'typescript', + message: 'Cannot find name.', + }; + + expect(diagnostic.range).toBeDefined(); + expect(diagnostic.severity).toBe('error'); + expect(diagnostic.code).toBe('TS2304'); + expect(diagnostic.source).toBe('typescript'); + expect(diagnostic.message).toBeDefined(); + }); + + it('should support optional fields', () => { + const minimalDiagnostic: LspDiagnostic = { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + message: 'Error message', + }; + + expect(minimalDiagnostic.severity).toBeUndefined(); + expect(minimalDiagnostic.code).toBeUndefined(); + expect(minimalDiagnostic.source).toBeUndefined(); + }); + }); + + describe('LspLocation', () => { + it('should have correct structure', () => { + const location: LspLocation = { + uri: 'file:///test/file.ts', + range: { + start: { line: 10, character: 5 }, + end: { line: 10, character: 15 }, + }, + }; + + expect(location.uri).toBe('file:///test/file.ts'); + expect(location.range.start.line).toBe(10); + expect(location.range.start.character).toBe(5); + expect(location.range.end.line).toBe(10); + expect(location.range.end.character).toBe(15); + }); + }); +}); From 01a906d6eab81b48af4b02e213a2a3ab3d1c6814 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 21 Jan 2026 13:59:31 +0800 Subject: [PATCH 08/15] feat(cli): add experimental LSP support with --experimental-lsp flag Co-authored-by: Qwen-Coder --- docs/users/configuration/settings.md | 6 +++++- packages/cli/src/config/config.ts | 11 +++++++++-- packages/cli/src/config/lspSettingsSchema.ts | 5 +++-- packages/cli/src/config/settingsSchema.ts | 4 ++-- .../lsp/NativeLspService.integration.test.ts | 12 ++++++------ packages/cli/src/services/lsp/NativeLspService.ts | 12 ++++++++---- packages/core/src/tools/lsp.test.ts | 9 ++++++--- 7 files changed, 39 insertions(+), 20 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3885f7ee3..3ce527bdc 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -289,11 +289,14 @@ If you are experiencing performance issues with file searching (e.g., with `@` c #### lsp +> [!warning] +> **Experimental Feature**: LSP support is currently experimental and disabled by default. Enable it using the `--experimental-lsp` command line flag. + Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details. | Setting | Type | Description | Default | | ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ----------- | -| `lsp.enabled` | boolean | Enable/disable LSP support. | `true` | +| `lsp.enabled` | boolean | Enable/disable LSP support. Has no effect unless `--experimental-lsp` is provided. | `false` | | `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` | | `lsp.serverTimeout`| number | LSP server startup timeout in milliseconds. | `10000` | | `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` | @@ -504,6 +507,7 @@ Arguments passed directly when running the CLI can override other configurations | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | | `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | +| `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | | `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7a461ecb8..95ade13cb 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -121,6 +121,7 @@ export interface CliArgs { acp: boolean | undefined; experimentalAcp: boolean | undefined; experimentalSkills: boolean | undefined; + experimentalLsp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -480,6 +481,12 @@ export async function parseArguments(settings: Settings): Promise { return settings.experimental?.skills ?? legacySkills ?? false; })(), }) + .option('experimental-lsp', { + type: 'boolean', + description: + 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', + default: false, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], @@ -902,8 +909,8 @@ export async function loadCliConfig( let mcpServers = mergeMcpServers(settings, activeExtensions); - // LSP configuration derived from settings; defaults to disabled for safety. - const lspEnabled = settings.lsp?.enabled ?? false; + // LSP configuration: enabled only via --experimental-lsp flag + const lspEnabled = argv.experimentalLsp === true; const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed; const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded; const lspLanguageServers = settings.lsp?.languageServers; diff --git a/packages/cli/src/config/lspSettingsSchema.ts b/packages/cli/src/config/lspSettingsSchema.ts index c8d3f1b33..2a77a2398 100644 --- a/packages/cli/src/config/lspSettingsSchema.ts +++ b/packages/cli/src/config/lspSettingsSchema.ts @@ -5,8 +5,9 @@ export const lspSettingsSchema: JSONSchema7 = { properties: { 'lsp.enabled': { type: 'boolean', - default: true, - description: '启用 LSP 语言服务器协议支持' + default: false, + description: + '启用 LSP 语言服务器协议支持(实验性功能)。必须通过 --experimental-lsp 命令行参数显式开启。' }, 'lsp.allowed': { type: 'array', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 57aff9888..72f521373 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1039,7 +1039,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {}, description: - 'Settings for the native Language Server Protocol integration.', + 'Settings for the native Language Server Protocol integration. Enable with --experimental-lsp flag.', showInDialog: false, properties: { enabled: { @@ -1049,7 +1049,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: false, description: - 'Enable the native LSP client to connect to language servers discovered in the workspace.', + 'Enable the native LSP client. Prefer using --experimental-lsp command line flag instead.', showInDialog: false, }, allowed: { diff --git a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts index bb0a30b64..54c00aa25 100644 --- a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts @@ -495,12 +495,12 @@ describe('NativeLspService Integration Tests', () => { await restrictedService.discoverAndPrepare(); const status = restrictedService.getStatus(); - // Only allowed servers should be present - for (const [name] of status) { - expect( - name === 'typescript-language-server' || - status.get(name) === 'FAILED' - ).toBe(true); + // Only allowed servers should be READY + const readyServers = Array.from(status.entries()) + .filter(([, state]) => state === 'READY') + .map(([name]) => name); + for (const name of readyServers) { + expect(['typescript-language-server']).toContain(name); } }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index da670cb79..18ecaa276 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -24,7 +24,7 @@ import type { import type { EventEmitter } from 'events'; import { LspConnectionFactory } from './LspConnectionFactory.js'; import * as path from 'path'; -import { pathToFileURL } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { spawn, type ChildProcess } from 'node:child_process'; import * as fs from 'node:fs'; import { globSync } from 'glob'; @@ -957,9 +957,13 @@ export class NativeLspService { uri: string, edits: LspTextEdit[], ): Promise { - const filePath = uri.startsWith('file://') - ? uri.replace(/^file:\/\//, '') - : uri; + let filePath = uri.startsWith('file://') ? fileURLToPath(uri) : uri; + if (!path.isAbsolute(filePath)) { + filePath = path.resolve(this.workspaceRoot, filePath); + } + if (!this.workspaceContext.isPathWithinWorkspace(filePath)) { + throw new Error(`Refusing to apply edits outside workspace: ${filePath}`); + } // Read the current file content let content: string; diff --git a/packages/core/src/tools/lsp.test.ts b/packages/core/src/tools/lsp.test.ts index ca2a2fc0c..03a8747ab 100644 --- a/packages/core/src/tools/lsp.test.ts +++ b/packages/core/src/tools/lsp.test.ts @@ -1160,12 +1160,15 @@ describe('LspTool', () => { definitions?: Record; }; const definitionNames = Object.keys(schema.definitions ?? {}); - // Should have exactly these definitions - expect(definitionNames.sort()).toEqual([ + // Should include at least these definitions + expect(definitionNames).toEqual( + expect.arrayContaining([ 'LspCallHierarchyItem', + 'LspDiagnostic', 'LspPosition', 'LspRange', - ]); + ]), + ); }); }); }); From 8420386d146566af66f60edb545cb6030d33e792 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 25 Jan 2026 20:59:44 +0800 Subject: [PATCH 09/15] feat(lsp): Removes built-in LSP configuration options and improves configuration loading mechanism - remove configuration options such as lsp.enabled, lsp.allowed, lsp.excluded, etc. from settings.json schema - Delete lspSettingsSchema.ts files and associated JSON schema definitions - Removed VS Code settings loading function, no longer merge. vscode/settings.json configuration - Updated LSP documentation to reflect new configurations and experimental flags -remove allow/exclude parameters in NativeLspService constructor - Create new LspConfigLoader classes to handle LSP configuration loading and merging - Updated debug guide to match the new configuration mechanism - Simplify loadCliConfig functions, remove startLsp options - Reconstruct the configuration loading process to remove duplicate configuration merge logic - Add LspConfigLoader classes to implement configuration parsing and merging functions --- .vscode/settings.json | 7 +- cclsp-integration-plan.md | 147 - docs/users/configuration/settings.md | 15 +- docs/users/features/lsp.md | 205 +- package-lock.json | 8 - package.json | 1 - packages/cli/LSP_DEBUGGING_GUIDE.md | 34 +- packages/cli/src/config/config.test.ts | 27 +- packages/cli/src/config/config.ts | 20 +- packages/cli/src/config/lspSettingsSchema.ts | 39 - packages/cli/src/config/settings.ts | 57 +- packages/cli/src/config/settingsSchema.ts | 53 - packages/cli/src/gemini.tsx | 1 - .../cli/src/services/lsp/LspConfigLoader.ts | 458 ++++ .../src/services/lsp/LspConnectionFactory.ts | 23 +- .../src/services/lsp/LspLanguageDetector.ts | 222 ++ .../src/services/lsp/LspResponseNormalizer.ts | 911 ++++++ .../cli/src/services/lsp/LspServerManager.ts | 713 +++++ packages/cli/src/services/lsp/LspTypes.ts | 205 ++ .../lsp/NativeLspService.integration.test.ts | 48 +- .../src/services/lsp/NativeLspService.test.ts | 6 + .../cli/src/services/lsp/NativeLspService.ts | 2442 ++--------------- packages/cli/src/services/lsp/constants.ts | 210 ++ packages/core/src/config/config.ts | 23 +- packages/core/src/index.ts | 7 +- packages/core/src/lsp/types.ts | 13 +- .../core/src/tools/lsp-find-references.ts | 308 --- .../core/src/tools/lsp-go-to-definition.ts | 308 --- .../core/src/tools/lsp-workspace-symbol.ts | 180 -- packages/core/src/tools/lsp.test.ts | 12 +- packages/core/src/tools/lsp.ts | 2 +- packages/core/src/tools/tool-names.ts | 11 +- .../LSP_REFACTORING_PLAN.md | 255 -- 33 files changed, 3064 insertions(+), 3907 deletions(-) delete mode 100644 cclsp-integration-plan.md delete mode 100644 packages/cli/src/config/lspSettingsSchema.ts create mode 100644 packages/cli/src/services/lsp/LspConfigLoader.ts create mode 100644 packages/cli/src/services/lsp/LspLanguageDetector.ts create mode 100644 packages/cli/src/services/lsp/LspResponseNormalizer.ts create mode 100644 packages/cli/src/services/lsp/LspServerManager.ts create mode 100644 packages/cli/src/services/lsp/LspTypes.ts create mode 100644 packages/cli/src/services/lsp/constants.ts delete mode 100644 packages/core/src/tools/lsp-find-references.ts delete mode 100644 packages/core/src/tools/lsp-go-to-definition.ts delete mode 100644 packages/core/src/tools/lsp-workspace-symbol.ts delete mode 100644 packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 8331c3876..ea2735760 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,10 +13,5 @@ "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "vitest.disableWorkspaceWarning": true, - "lsp": { - "enabled": true, - "allowed": ["typescript-language-server"], - "excluded": ["gopls"] - } + "vitest.disableWorkspaceWarning": true } diff --git a/cclsp-integration-plan.md b/cclsp-integration-plan.md deleted file mode 100644 index 7105653a7..000000000 --- a/cclsp-integration-plan.md +++ /dev/null @@ -1,147 +0,0 @@ -# Qwen Code CLI LSP 集成实现方案分析 - -## 1. 项目概述 - -本方案旨在将 LSP(Language Server Protocol)能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。 - -## 2. 技术方案对比 - -### 2.1 Piebald-AI/claude-code-lsps 方案 -- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由 -- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装 -- **安全**: LSP 子进程以用户权限运行,无内置信任门控 -- **功能覆盖**: 可以暴露完整的 LSP 表面(hover、诊断、代码操作、重命名等) - -### 2.2 原生 LSP 客户端方案(推荐方案) -- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接 -- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置 -- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示) -- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等) - -### 2.3 cclsp + MCP 方案(备选) -- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接 -- **用户配置**: 需要 MCP 配置 -- **安全**: 通过 MCP 安全控制 -- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具 - -## 3. 原生 LSP 集成详细计划 - -### 3.1 方案选择 -- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验 -- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接 -- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略 - -### 3.2 实现步骤 - -#### 3.2.1 创建原生 LSP 服务 -在 `packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理: -- 工作区语言检测 -- 自动发现和启动语言服务器 -- 与现有文档/编辑模型同步 -- LSP 能力直接暴露给代理 - -#### 3.2.2 配置支持 -- 支持内置预设配置(常见语言服务器) -- 支持用户自定义 `.lsp.json` 配置文件 -- 与 MCP 配置共存,共享信任控制 - -#### 3.2.3 集成启动流程 -- 在 `packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成 -- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制 -- 处理沙箱预检和主运行的重复调用问题 - -#### 3.2.4 功能标志配置 -- 在 `packages/cli/src/config/settingsSchema.ts` 中添加新的设置项 -- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能 -- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置 - -#### 3.2.5 安全控制 -- 与 MCP 共享相同的安全控制机制 -- 在信任工作区中自动启用,在非信任工作区中提示用户 -- 实现路径允许列表和进程启动确认 - -#### 3.2.6 错误处理与用户通知 -- 检测缺失的语言服务器并提供安装命令 -- 通过现有 MCP 状态 UI 显示错误信息 -- 实现重试/退避机制,检测沙箱环境并抑制自动启动 - -### 3.3 需要确认的不确定项 - -1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调 - -2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP - -3. **功能开关设计**:开关应该是全局级别的,LSP 和 MCP 可独立启用/禁用 - -4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑 - -5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步 - -6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项 - -7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制 - -### 3.4 安全考虑 - -- 与 MCP 共享相同的安全控制模型 -- 仅在受信任工作区中启用自动 LSP 功能 -- 提供用户确认机制用于启动新的 LSP 服务器 -- 防止路径劫持,使用安全的路径解析 - -### 3.5 高级 LSP 功能支持 - -- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等 -- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置 -- **性能优化**: 优化 LSP 服务器启动时间和内存使用 - -### 3.6 用户体验 - -- 提供安装提示而非自动安装 -- 在统一的状态界面显示 LSP 和 MCP 服务器状态 -- 提供独立开关让用户控制 LSP 和 MCP 功能 -- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息 - -## 4. 实施总结 - -### 4.1 已完成的工作 -1. **NativeLspService 类**:创建了核心服务类,包含语言检测、配置合并、LSP 连接管理等功能 -2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理 -3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测 -4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并 -5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证 -6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点 - -### 4.2 关键组件 - -#### 4.2.1 LspConnectionFactory -- 使用 `vscode-jsonrpc` 和 `vscode-languageserver-protocol` 实现 LSP 连接 -- 支持 stdio 传输方式,可以扩展支持 TCP 传输 -- 提供连接创建、初始化和关闭的完整生命周期管理 - -#### 4.2.2 NativeLspService -- **语言检测**:扫描项目文件和配置文件来识别编程语言 -- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置 -- **LSP 服务器管理**:启动、停止和状态管理 -- **安全控制**:与 MCP 共享的信任和确认机制 - -#### 4.2.3 配置架构 -- **内置预设**:为常见语言提供默认 LSP 服务器配置 -- **用户配置**:支持 `.lsp.json` 文件格式 -- **Claude 兼容**:可导入 Claude Code 的 LSP 配置 - -### 4.3 依赖管理 -- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信 -- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递 -- 使用 `vscode-languageserver-textdocument` 管理文档版本 - -### 4.4 安全特性 -- 工作区信任检查 -- 用户确认机制(对于非信任工作区) -- 命令存在性验证 -- 路径安全性检查 - -## 5. 总结 - -原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略,但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。 - -该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。 \ No newline at end of file diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 877deeb95..9369a9890 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -288,20 +288,9 @@ If you are experiencing performance issues with file searching (e.g., with `@` c > [!warning] > **Experimental Feature**: LSP support is currently experimental and disabled by default. Enable it using the `--experimental-lsp` command line flag. -Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details. +Language Server Protocol (LSP) provides code intelligence features like go-to-definition, find references, and diagnostics. -| Setting | Type | Description | Default | -| --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `lsp.enabled` | boolean | Enable/disable LSP support. Has no effect unless `--experimental-lsp` is provided. | `false` | -| `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` | -| `lsp.serverTimeout` | number | LSP server startup timeout in milliseconds. | `10000` | -| `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` | -| `lsp.excluded` | array of strings | A denylist of LSP servers to exclude. A server listed in both is excluded. | `[]` | -| `lsp.languageServers` | object | Custom language server configurations. See the [LSP documentation](../features/lsp#custom-language-servers) for configuration format. | `{}` | - -> [!note] -> -> **Security Note for LSP servers:** LSP servers run with your user permissions and can execute code. They are only started in trusted workspaces by default. You can configure per-server trust requirements in the `.lsp.json` configuration file. +LSP server configuration is done through `.lsp.json` files in your project root directory, not through `settings.json`. See the [LSP documentation](../features/lsp) for configuration details and examples. #### security diff --git a/docs/users/features/lsp.md b/docs/users/features/lsp.md index 61e063223..bf6266fbc 100644 --- a/docs/users/features/lsp.md +++ b/docs/users/features/lsp.md @@ -15,55 +15,61 @@ LSP support in Qwen Code works by connecting to language servers that understand ## Quick Start -LSP is enabled by default in Qwen Code. For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system. +LSP is an experimental feature in Qwen Code. To enable it, use the `--experimental-lsp` command line flag: + +```bash +qwen --experimental-lsp +``` + +For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system. ### Prerequisites You need to have the language server for your programming language installed: -| Language | Language Server | Install Command | -|----------|----------------|-----------------| -| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` | -| Python | pylsp | `pip install python-lsp-server` | -| Go | gopls | `go install golang.org/x/tools/gopls@latest` | -| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) | +| Language | Language Server | Install Command | +| --------------------- | -------------------------- | ------------------------------------------------------------------------------ | +| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` | +| Python | pylsp | `pip install python-lsp-server` | +| Go | gopls | `go install golang.org/x/tools/gopls@latest` | +| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) | ## Configuration -### Settings +### .lsp.json File -You can configure LSP behavior in your `settings.json`: +You can configure language servers using a `.lsp.json` file in your project root. This follows the [Claude Code plugin LSP configuration format](https://code.claude.com/docs/en/plugins-reference#lsp-servers). + +**Basic format:** ```json { - "lsp": { - "enabled": true, - "autoDetect": true, - "serverTimeout": 10000, - "allowed": [], - "excluded": [] + "typescript": { + "command": "typescript-language-server", + "args": ["--stdio"], + "extensionToLanguage": { + ".ts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", + ".jsx": "javascriptreact" + } } } ``` -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `lsp.enabled` | boolean | `true` | Enable/disable LSP support | -| `lsp.autoDetect` | boolean | `true` | Automatically detect and start language servers | -| `lsp.serverTimeout` | number | `10000` | Server startup timeout in milliseconds | -| `lsp.allowed` | string[] | `[]` | Allow only these servers (empty = allow all) | -| `lsp.excluded` | string[] | `[]` | Exclude these servers from starting | - -### Custom Language Servers - -You can configure custom language servers using a `.lsp.json` file in your project root: +**Extended format with `languageServers` wrapper:** ```json { "languageServers": { - "my-custom-lsp": { - "languages": ["mylang"], - "command": "my-lsp-server", + "typescript-language-server": { + "languages": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ], + "command": "typescript-language-server", "args": ["--stdio"], "transport": "stdio", "initializationOptions": {}, @@ -73,40 +79,45 @@ You can configure custom language servers using a `.lsp.json` file in your proje } ``` -#### Configuration Options +### Configuration Options -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `languages` | string[] | Yes | Languages this server handles | -| `command` | string | Yes* | Command to start the server | -| `args` | string[] | No | Command line arguments | -| `transport` | string | No | Transport type: `stdio` (default), `tcp`, or `socket` | -| `env` | object | No | Environment variables | -| `initializationOptions` | object | No | LSP initialization options | -| `settings` | object | No | Server settings | -| `workspaceFolder` | string | No | Override workspace folder | -| `startupTimeout` | number | No | Startup timeout in ms | -| `shutdownTimeout` | number | No | Shutdown timeout in ms | -| `restartOnCrash` | boolean | No | Auto-restart on crash | -| `maxRestarts` | number | No | Maximum restart attempts | -| `trustRequired` | boolean | No | Require trusted workspace | +#### Required Fields -*Required for `stdio` transport +| Option | Type | Description | +| --------------------- | ------ | ------------------------------------------------- | +| `command` | string | Command to start the LSP server (must be in PATH) | +| `extensionToLanguage` | object | Maps file extensions to language identifiers | -#### TCP/Socket Transport +#### Optional Fields + +| Option | Type | Default | Description | +| ----------------------- | -------- | --------- | ------------------------------------------------------ | +| `args` | string[] | `[]` | Command line arguments | +| `transport` | string | `"stdio"` | Transport type: `stdio` or `socket` | +| `env` | object | - | Environment variables | +| `initializationOptions` | object | - | LSP initialization options | +| `settings` | object | - | Server settings via `workspace/didChangeConfiguration` | +| `workspaceFolder` | string | - | Override workspace folder | +| `startupTimeout` | number | `10000` | Startup timeout in milliseconds | +| `shutdownTimeout` | number | `5000` | Shutdown timeout in milliseconds | +| `restartOnCrash` | boolean | `false` | Auto-restart on crash | +| `maxRestarts` | number | `3` | Maximum restart attempts | +| `trustRequired` | boolean | `true` | Require trusted workspace | + +### TCP/Socket Transport For servers that use TCP or Unix socket transport: ```json { - "languageServers": { - "remote-lsp": { - "languages": ["custom"], - "transport": "tcp", - "socket": { - "host": "127.0.0.1", - "port": 9999 - } + "remote-lsp": { + "transport": "tcp", + "socket": { + "host": "127.0.0.1", + "port": 9999 + }, + "extensionToLanguage": { + ".custom": "custom" } } } @@ -119,6 +130,7 @@ Qwen Code exposes LSP functionality through the unified `lsp` tool. Here are the ### Code Navigation #### Go to Definition + Find where a symbol is defined. ``` @@ -130,6 +142,7 @@ Parameters: ``` #### Find References + Find all references to a symbol. ``` @@ -142,6 +155,7 @@ Parameters: ``` #### Go to Implementation + Find implementations of an interface or abstract method. ``` @@ -155,6 +169,7 @@ Parameters: ### Symbol Information #### Hover + Get documentation and type information for a symbol. ``` @@ -166,6 +181,7 @@ Parameters: ``` #### Document Symbols + Get all symbols in a document. ``` @@ -175,6 +191,7 @@ Parameters: ``` #### Workspace Symbol Search + Search for symbols across the workspace. ``` @@ -187,6 +204,7 @@ Parameters: ### Call Hierarchy #### Prepare Call Hierarchy + Get the call hierarchy item at a position. ``` @@ -198,6 +216,7 @@ Parameters: ``` #### Incoming Calls + Find all functions that call the given function. ``` @@ -207,6 +226,7 @@ Parameters: ``` #### Outgoing Calls + Find all functions called by the given function. ``` @@ -218,6 +238,7 @@ Parameters: ### Diagnostics #### File Diagnostics + Get diagnostic messages (errors, warnings) for a file. ``` @@ -227,6 +248,7 @@ Parameters: ``` #### Workspace Diagnostics + Get all diagnostic messages across the workspace. ``` @@ -238,6 +260,7 @@ Parameters: ### Code Actions #### Get Code Actions + Get available code actions (quick fixes, refactorings) at a location. ``` @@ -253,6 +276,7 @@ Parameters: ``` Code action kinds: + - `quickfix` - Quick fixes for errors/warnings - `refactor` - Refactoring operations - `refactor.extract` - Extract to function/variable @@ -268,19 +292,23 @@ LSP servers are only started in trusted workspaces by default. This is because l ### Trust Controls - **Trusted Workspace**: LSP servers start automatically -- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` +- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` is set in the server configuration To mark a workspace as trusted, use the `/trust` command or configure trusted folders in settings. -### Server Allowlists +### Per-Server Trust Override -You can restrict which servers are allowed to run: +You can override trust requirements for specific servers in their configuration: ```json { - "lsp": { - "allowed": ["typescript-language-server", "gopls"], - "excluded": ["untrusted-server"] + "safe-server": { + "command": "safe-language-server", + "args": ["--stdio"], + "trustRequired": false, + "extensionToLanguage": { + ".safe": "safe" + } } } ``` @@ -293,12 +321,12 @@ You can restrict which servers are allowed to run: 2. **Check the PATH**: Ensure the server binary is in your system PATH 3. **Check workspace trust**: The workspace must be trusted for LSP 4. **Check logs**: Look for error messages in the console output +5. **Verify --experimental-lsp flag**: Make sure you're using the flag when starting Qwen Code ### Slow Performance 1. **Large projects**: Consider excluding `node_modules` and other large directories -2. **Server timeout**: Increase `lsp.serverTimeout` for slow servers -3. **Multiple servers**: Exclude unused language servers +2. **Server timeout**: Increase `startupTimeout` in server configuration for slow servers ### No Results @@ -311,39 +339,40 @@ You can restrict which servers are allowed to run: Enable debug logging to see LSP communication: ```bash -DEBUG=lsp* qwen +DEBUG=lsp* qwen --experimental-lsp ``` Or check the LSP debugging guide at `packages/cli/LSP_DEBUGGING_GUIDE.md`. ## Claude Code Compatibility -Qwen Code supports Claude Code-style `.lsp.json` configuration files. If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes. +Qwen Code supports Claude Code-style `.lsp.json` configuration files as defined in the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference#lsp-servers). If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes. -### Legacy Format +### Configuration Format -The legacy format (used by earlier versions) is still supported but deprecated: +The recommended format follows Claude Code's specification: ```json { - "typescript": { - "command": "typescript-language-server", - "args": ["--stdio"], - "transport": "stdio" + "go": { + "command": "gopls", + "args": ["serve"], + "extensionToLanguage": { + ".go": "go" + } } } ``` -We recommend migrating to the new `languageServers` format: +The `languageServers` wrapper format is also supported: ```json { "languageServers": { - "typescript-language-server": { - "languages": ["typescript", "javascript"], - "command": "typescript-language-server", - "args": ["--stdio"], - "transport": "stdio" + "gopls": { + "languages": ["go"], + "command": "gopls", + "args": ["serve"] } } } @@ -352,12 +381,20 @@ We recommend migrating to the new `languageServers` format: ## Best Practices 1. **Install language servers globally**: This ensures they're available in all projects -2. **Use project-specific settings**: Configure server options per project when needed +2. **Use project-specific settings**: Configure server options per project when needed via `.lsp.json` 3. **Keep servers updated**: Update your language servers regularly for best results 4. **Trust wisely**: Only trust workspaces from trusted sources ## FAQ +### Q: How do I enable LSP? + +Use the `--experimental-lsp` flag when starting Qwen Code: + +```bash +qwen --experimental-lsp +``` + ### Q: How do I know which language servers are running? Use the `/lsp status` command to see all configured and running language servers. @@ -369,15 +406,3 @@ Yes, but only one will be used for each operation. The first server that returns ### Q: Does LSP work in sandbox mode? LSP servers run outside the sandbox to access your code. They're subject to workspace trust controls. - -### Q: How do I disable LSP for a specific project? - -Add to your project's `.qwen/settings.json`: - -```json -{ - "lsp": { - "enabled": false - } -} -``` diff --git a/package-lock.json b/package-lock.json index 5641b0bde..2a0726478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", - "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", @@ -10816,13 +10815,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index 2ce6e8146..a9ab15472 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", - "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md index 75c018ecf..d837adb4d 100644 --- a/packages/cli/LSP_DEBUGGING_GUIDE.md +++ b/packages/cli/LSP_DEBUGGING_GUIDE.md @@ -17,25 +17,41 @@ DEBUG_MODE=true qwen [你的命令] ## 2. LSP 配置选项 -LSP 功能通过设置系统配置,包含以下选项: +LSP 功能通过 `--experimental-lsp` 命令行参数启用。服务器配置通过以下方式定义: -- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`) -- `lsp.allowed`: 允许的 LSP 服务器名称白名单 -- `lsp.excluded`: 排除的 LSP 服务器名称黑名单 +- `.lsp.json` 文件:在项目根目录创建配置文件 +- `lsp.languageServers`:在 `settings.json` 中内联配置 -在 settings.json 中的示例配置: +### 在 settings.json 中的示例配置 ```json { "lsp": { - "enabled": true, - "allowed": ["typescript-language-server", "pylsp"], - "excluded": ["gopls"] + "languageServers": { + "typescript-language-server": { + "languages": ["typescript", "javascript"], + "command": "typescript-language-server", + "args": ["--stdio"] + } + } } } ``` -也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。 +### 在 .lsp.json 中的示例配置 + +```json +{ + "typescript": { + "command": "typescript-language-server", + "args": ["--stdio"], + "extensionToLanguage": { + ".ts": "typescript", + ".tsx": "typescriptreact" + } + } +} +``` ## 3. NativeLspService 调试功能 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4ddd3e3ef..8c71b8d9d 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -600,42 +600,17 @@ describe('loadCliConfig', () => { it('should initialize native LSP service when enabled', async () => { process.argv = ['node', 'script.js', '--experimental-lsp']; const argv = await parseArguments({} as Settings); - const settings: Settings = { - lsp: { - allowed: ['typescript-language-server'], - excluded: ['pylsp'], - }, - }; + const settings: Settings = {}; const config = await loadCliConfig(settings, argv); // LSP is enabled via --experimental-lsp flag expect(config.isLspEnabled()).toBe(true); - expect(config.getLspAllowed()).toEqual(['typescript-language-server']); - expect(config.getLspExcluded()).toEqual(['pylsp']); expect(nativeLspServiceMock).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']); - expect(options?.excludedServers).toEqual(['pylsp']); - }); - - it('should skip native LSP startup when startLsp option is false', async () => { - process.argv = ['node', 'script.js', '--experimental-lsp']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; - - const config = await loadCliConfig(settings, argv, undefined, undefined, { - startLsp: false, - }); - - expect(config.isLspEnabled()).toBe(true); - expect(nativeLspServiceMock).not.toHaveBeenCalled(); - expect(getLastLspInstance()).toBeUndefined(); }); describe('Proxy configuration', () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2ca7d5950..f04486894 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -151,14 +151,6 @@ export interface CliArgs { channel: string | undefined; } -export interface LoadCliConfigOptions { - /** - * Whether to start the native LSP service during config load. - * Disable when doing preflight runs (e.g., sandbox preparation). - */ - startLsp?: boolean; -} - class NativeLspClient implements LspClient { constructor(private readonly service: NativeLspService) {} @@ -819,7 +811,6 @@ export async function loadCliConfig( argv: CliArgs, cwd: string = process.cwd(), overrideExtensions?: string[], - options: LoadCliConfigOptions = {}, ): Promise { const debugMode = isDebugMode(argv); @@ -877,9 +868,6 @@ export async function loadCliConfig( // LSP configuration: enabled only via --experimental-lsp flag const lspEnabled = argv.experimentalLsp === true; - 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 = @@ -1186,13 +1174,10 @@ export async function loadCliConfig( argv.chatRecording ?? settings.general?.chatRecording ?? true, lsp: { enabled: lspEnabled, - allowed: lspAllowed, - excluded: lspExcluded, }, }); - const shouldStartLsp = options.startLsp ?? true; - if (shouldStartLsp && lspEnabled) { + if (lspEnabled) { try { const lspService = new NativeLspService( config, @@ -1201,10 +1186,7 @@ export async function loadCliConfig( fileService, ideContextStore, { - allowedServers: lspAllowed, - excludedServers: lspExcluded, requireTrustedWorkspace: folderTrust, - inlineServerConfigs: lspLanguageServers, }, ); diff --git a/packages/cli/src/config/lspSettingsSchema.ts b/packages/cli/src/config/lspSettingsSchema.ts deleted file mode 100644 index 2a77a2398..000000000 --- a/packages/cli/src/config/lspSettingsSchema.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { JSONSchema7 } from 'json-schema'; - -export const lspSettingsSchema: JSONSchema7 = { - type: 'object', - properties: { - 'lsp.enabled': { - type: 'boolean', - default: false, - description: - '启用 LSP 语言服务器协议支持(实验性功能)。必须通过 --experimental-lsp 命令行参数显式开启。' - }, - 'lsp.allowed': { - type: 'array', - items: { - type: 'string' - }, - default: [], - description: '允许运行的 LSP 服务器列表' - }, - 'lsp.excluded': { - type: 'array', - items: { - type: 'string' - }, - default: [], - description: '禁止运行的 LSP 服务器列表' - }, - 'lsp.autoDetect': { - type: 'boolean', - default: true, - description: '自动检测项目语言并启动相应 LSP 服务器' - }, - 'lsp.serverTimeout': { - type: 'number', - default: 10000, - description: 'LSP 服务器启动超时时间(毫秒)' - } - } -}; \ No newline at end of file diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 1e28ceb8d..0f213acf3 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -150,39 +150,6 @@ export function getSystemDefaultsPath(): string { ); } -function getVsCodeSettingsPath(workspaceDir: string): string { - return path.join(workspaceDir, '.vscode', 'settings.json'); -} - -function loadVsCodeSettings(workspaceDir: string): Settings { - const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir); - try { - if (fs.existsSync(vscodeSettingsPath)) { - const content = fs.readFileSync(vscodeSettingsPath, 'utf-8'); - const rawSettings: unknown = JSON.parse(stripJsonComments(content)); - - if ( - typeof rawSettings !== 'object' || - rawSettings === null || - Array.isArray(rawSettings) - ) { - console.error( - `VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`, - ); - return {}; - } - - return rawSettings as Settings; - } - } catch (error: unknown) { - console.error( - `Error loading VS Code settings from ${vscodeSettingsPath}:`, - getErrorMessage(error), - ); - } - return {}; -} - export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { @@ -746,9 +713,6 @@ export function loadSettings( workspaceDir, ).getWorkspaceSettingsPath(); - // Load VS Code settings as an additional source of configuration - const vscodeSettings = loadVsCodeSettings(workspaceDir); - const loadAndMigrate = ( filePath: string, scope: SettingScope, @@ -853,14 +817,6 @@ export function loadSettings( userSettings = resolveEnvVarsInObject(userResult.settings); workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); - // Merge VS Code settings into workspace settings (VS Code settings take precedence) - workspaceSettings = customDeepMerge( - getMergeStrategyForPath, - {}, - workspaceSettings, - vscodeSettings, - ) as Settings; - // Support legacy theme names if (userSettings.ui?.theme === 'VS') { userSettings.ui.theme = DefaultLight.name; @@ -874,13 +830,11 @@ export function loadSettings( } // For the initial trust check, we can only use user and system settings. - // We also include VS Code settings as they may contain trust-related settings const initialTrustCheckSettings = customDeepMerge( getMergeStrategyForPath, {}, systemSettings, userSettings, - vscodeSettings, // Include VS Code settings ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; @@ -894,18 +848,9 @@ export function loadSettings( isTrusted, ); - // Add VS Code settings to the temp merged settings for environment loading - // Since loadEnvironment depends on settings, we need to consider VS Code settings as well - const tempMergedSettingsWithVsCode = customDeepMerge( - getMergeStrategyForPath, - {}, - tempMergedSettings, - vscodeSettings, - ) as Settings; - // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle - loadEnvironment(tempMergedSettingsWithVsCode); + loadEnvironment(tempMergedSettings); // Create LoadedSettings first diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 6390643d0..f5669cd87 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -967,59 +967,6 @@ const SETTINGS_SCHEMA = { }, }, }, - lsp: { - type: 'object', - label: 'LSP', - category: 'LSP', - requiresRestart: true, - default: {}, - description: - 'Settings for the native Language Server Protocol integration. Enable with --experimental-lsp flag.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable LSP', - category: 'LSP', - requiresRestart: true, - default: false, - description: - 'Enable the native LSP client. Prefer using --experimental-lsp command line flag instead.', - showInDialog: false, - }, - allowed: { - type: 'array', - label: 'Allow LSP Servers', - category: 'LSP', - requiresRestart: true, - default: undefined as string[] | undefined, - description: - 'Optional allowlist of LSP server names. If set, only matching servers will start.', - showInDialog: false, - }, - excluded: { - type: 'array', - label: 'Exclude LSP Servers', - category: 'LSP', - requiresRestart: true, - default: undefined as string[] | undefined, - description: - '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: { type: 'boolean', label: 'Use Smart Edit', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8ab99413b..ea2dee43b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -247,7 +247,6 @@ export async function main() { argv, undefined, [], - { startLsp: false }, ); if (!settings.merged.security?.auth?.useExternal) { diff --git a/packages/cli/src/services/lsp/LspConfigLoader.ts b/packages/cli/src/services/lsp/LspConfigLoader.ts new file mode 100644 index 000000000..89f56ee64 --- /dev/null +++ b/packages/cli/src/services/lsp/LspConfigLoader.ts @@ -0,0 +1,458 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import type { + LspInitializationOptions, + LspServerConfig, + LspSocketOptions, +} from './LspTypes.js'; + +export class LspConfigLoader { + private warnedLegacyConfig = false; + + constructor(private readonly workspaceRoot: string) {} + + /** + * Load user .lsp.json configuration + */ + async loadUserConfigs(): Promise { + const configs: LspServerConfig[] = []; + const sources: Array<{ origin: string; data: unknown }> = []; + + const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); + if (fs.existsSync(lspConfigPath)) { + try { + const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); + sources.push({ + origin: lspConfigPath, + data: JSON.parse(configContent), + }); + } catch (error) { + console.warn('Failed to load user .lsp.json config:', error); + } + } + + 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; + } + + /** + * Merge configs: built-in presets + user configs + compatibility layer + */ + mergeConfigs( + detectedLanguages: string[], + userConfigs: LspServerConfig[], + ): LspServerConfig[] { + // Built-in preset configurations + const presets = this.getBuiltInPresets(detectedLanguages); + + // Merge configs, user configs take priority + const mergedConfigs = [...presets]; + + for (const userConfig of userConfigs) { + // Find if there's a preset with the same name, if so replace it + const existingIndex = mergedConfigs.findIndex( + (c) => c.name === userConfig.name, + ); + if (existingIndex !== -1) { + mergedConfigs[existingIndex] = userConfig; + } else { + mergedConfigs.push(userConfig); + } + } + + return mergedConfigs; + } + + 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; + } + + /** + * Get built-in preset configurations + */ + private getBuiltInPresets(detectedLanguages: string[]): LspServerConfig[] { + const presets: LspServerConfig[] = []; + + // Convert directory path to file URI format + const rootUri = pathToFileURL(this.workspaceRoot).toString(); + + // Generate corresponding LSP server config based on detected languages + if ( + detectedLanguages.includes('typescript') || + detectedLanguages.includes('javascript') + ) { + presets.push({ + name: 'typescript-language-server', + languages: [ + 'typescript', + 'javascript', + 'typescriptreact', + 'javascriptreact', + ], + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + initializationOptions: {}, + rootUri, + workspaceFolder: this.workspaceRoot, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('python')) { + presets.push({ + name: 'pylsp', + languages: ['python'], + command: 'pylsp', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri, + workspaceFolder: this.workspaceRoot, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('go')) { + presets.push({ + name: 'gopls', + languages: ['go'], + command: 'gopls', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri, + workspaceFolder: this.workspaceRoot, + trustRequired: true, + }); + } + + // Additional language presets can be added as needed + + return presets; + } + + 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; + } +} diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index 1a1acc059..84b23878d 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -1,5 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import * as cp from 'node:child_process'; import * as net from 'node:net'; +import { DEFAULT_LSP_REQUEST_TIMEOUT_MS } from './constants.js'; +import type { JsonRpcMessage } from './LspTypes.js'; interface PendingRequest { resolve: (value: unknown) => void; @@ -88,7 +96,7 @@ class JsonRpcConnection { const timer = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`LSP request timeout: ${method}`)); - }, 15000); + }, DEFAULT_LSP_REQUEST_TIMEOUT_MS); this.pendingRequests.set(id, { resolve, reject, timer }); }); @@ -234,19 +242,6 @@ interface SocketConnectionOptions { path?: string; } -interface JsonRpcMessage { - jsonrpc: string; - id?: number | string; - method?: string; - params?: unknown; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; -} - export class LspConnectionFactory { /** * 创建基于 stdio 的 LSP 连接 diff --git a/packages/cli/src/services/lsp/LspLanguageDetector.ts b/packages/cli/src/services/lsp/LspLanguageDetector.ts new file mode 100644 index 000000000..694cf14f1 --- /dev/null +++ b/packages/cli/src/services/lsp/LspLanguageDetector.ts @@ -0,0 +1,222 @@ +/** + * LSP Language Detector + * + * Detects programming languages in a workspace by analyzing file extensions + * and root marker files (e.g., package.json, tsconfig.json). + */ + +import * as fs from 'node:fs'; +import * as path from 'path'; +import { globSync } from 'glob'; +import type { + WorkspaceContext, + FileDiscoveryService, +} from '@qwen-code/qwen-code-core'; + +/** + * Extension to language ID mapping + */ +const DEFAULT_EXTENSION_TO_LANGUAGE: Record = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + php: 'php', + rb: 'ruby', + cs: 'csharp', + vue: 'vue', + svelte: 'svelte', + html: 'html', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', +}; + +/** + * Root marker file to language ID mapping + */ +const MARKER_TO_LANGUAGE: Record = { + 'package.json': 'javascript', + 'tsconfig.json': 'typescript', + 'pyproject.toml': 'python', + 'go.mod': 'go', + 'Cargo.toml': 'rust', + 'pom.xml': 'java', + 'build.gradle': 'java', + 'composer.json': 'php', + Gemfile: 'ruby', + '*.sln': 'csharp', + 'mix.exs': 'elixir', + 'deno.json': 'deno', +}; + +/** + * Common root marker files to look for + */ +const COMMON_MARKERS = [ + 'package.json', + 'tsconfig.json', + 'pyproject.toml', + 'go.mod', + 'Cargo.toml', + 'pom.xml', + 'build.gradle', + 'composer.json', + 'Gemfile', + 'mix.exs', + 'deno.json', +]; + +/** + * Default exclude patterns for file search + */ +const DEFAULT_EXCLUDE_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', +]; + +/** + * Detects programming languages in a workspace. + */ +export class LspLanguageDetector { + constructor( + private readonly workspaceContext: WorkspaceContext, + private readonly fileDiscoveryService: FileDiscoveryService, + ) {} + + /** + * Detect programming languages in workspace by analyzing files and markers. + * Returns languages sorted by frequency (most common first). + * + * @param extensionOverrides - Custom extension to language mappings + * @returns Array of detected language IDs + */ + async detectLanguages( + extensionOverrides: Record = {}, + ): Promise { + const extensionMap = this.getExtensionToLanguageMap(extensionOverrides); + const extensions = Object.keys(extensionMap); + const patterns = + extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*']; + + const files = new Set(); + const searchRoots = this.workspaceContext.getDirectories(); + + for (const root of searchRoots) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: DEFAULT_EXCLUDE_PATTERNS, + absolute: true, + nodir: true, + }); + + for (const match of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(match)) { + continue; + } + files.add(match); + } + } catch { + // Ignore glob errors for missing/invalid directories + } + } + } + + // Count files per language + const languageCounts = new Map(); + for (const file of Array.from(files)) { + const ext = path.extname(file).slice(1).toLowerCase(); + if (ext) { + const lang = this.mapExtensionToLanguage(ext, extensionMap); + if (lang) { + languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); + } + } + } + + // Also detect languages via root marker files + const rootMarkers = await this.detectRootMarkers(); + for (const marker of rootMarkers) { + const lang = this.mapMarkerToLanguage(marker); + if (lang) { + // Give higher weight to config files + const currentCount = languageCounts.get(lang) || 0; + languageCounts.set(lang, currentCount + 100); + } + } + + // Return languages sorted by count (descending) + return Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([lang]) => lang); + } + + /** + * Detect root marker files in workspace directories + */ + private async detectRootMarkers(): Promise { + const markers = new Set(); + + for (const root of this.workspaceContext.getDirectories()) { + for (const marker of COMMON_MARKERS) { + try { + const fullPath = path.join(root, marker); + if (fs.existsSync(fullPath)) { + markers.add(marker); + } + } catch { + // ignore missing files + } + } + } + + return Array.from(markers); + } + + /** + * Map file extension to programming language ID + */ + private mapExtensionToLanguage( + ext: string, + extensionMap: Record, + ): string | null { + return extensionMap[ext] || null; + } + + /** + * Get extension to language mapping with overrides applied + */ + private getExtensionToLanguageMap( + extensionOverrides: Record = {}, + ): Record { + const extToLang = { ...DEFAULT_EXTENSION_TO_LANGUAGE }; + + 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; + } + + /** + * Map root marker file to programming language ID + */ + private mapMarkerToLanguage(marker: string): string | null { + return MARKER_TO_LANGUAGE[marker] || null; + } +} diff --git a/packages/cli/src/services/lsp/LspResponseNormalizer.ts b/packages/cli/src/services/lsp/LspResponseNormalizer.ts new file mode 100644 index 000000000..ee789bc73 --- /dev/null +++ b/packages/cli/src/services/lsp/LspResponseNormalizer.ts @@ -0,0 +1,911 @@ +/** + * LSP Response Normalizer + * + * Converts raw LSP protocol responses to normalized internal types. + * Handles various response formats from different language servers. + */ + +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspCodeAction, + LspCodeActionKind, + LspDiagnostic, + LspDiagnosticSeverity, + LspFileDiagnostics, + LspHoverResult, + LspLocation, + LspRange, + LspReference, + LspSymbolInformation, + LspTextEdit, + LspWorkspaceEdit, +} from '@qwen-code/qwen-code-core'; +import { + CODE_ACTION_KIND_LABELS, + DIAGNOSTIC_SEVERITY_LABELS, + SYMBOL_KIND_LABELS, +} from './constants.js'; + +/** + * Normalizes LSP protocol responses to internal types. + */ +export class LspResponseNormalizer { + // ============================================================================ + // Diagnostic Normalization + // ============================================================================ + + /** + * Normalize diagnostic result from LSP response + */ + normalizeDiagnostic(item: unknown, serverName: string): LspDiagnostic | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const range = this.normalizeRange(itemObj['range']); + if (!range) { + return null; + } + + const message = + typeof itemObj['message'] === 'string' + ? (itemObj['message'] as string) + : ''; + if (!message) { + return null; + } + + const severityNum = + typeof itemObj['severity'] === 'number' + ? (itemObj['severity'] as number) + : undefined; + const severity = severityNum + ? DIAGNOSTIC_SEVERITY_LABELS[severityNum] + : undefined; + + const code = itemObj['code']; + const codeValue = + typeof code === 'string' || typeof code === 'number' ? code : undefined; + + const source = + typeof itemObj['source'] === 'string' + ? (itemObj['source'] as string) + : undefined; + + const tags = this.normalizeDiagnosticTags(itemObj['tags']); + const relatedInfo = this.normalizeDiagnosticRelatedInfo( + itemObj['relatedInformation'], + ); + + return { + range, + severity, + code: codeValue, + source, + message, + tags: tags.length > 0 ? tags : undefined, + relatedInformation: relatedInfo.length > 0 ? relatedInfo : undefined, + serverName, + }; + } + + /** + * Convert diagnostic back to LSP format for requests + */ + denormalizeDiagnostic(diagnostic: LspDiagnostic): Record { + const severityMap: Record = { + error: 1, + warning: 2, + information: 3, + hint: 4, + }; + + return { + range: diagnostic.range, + message: diagnostic.message, + severity: diagnostic.severity + ? severityMap[diagnostic.severity] + : undefined, + code: diagnostic.code, + source: diagnostic.source, + }; + } + + /** + * Normalize diagnostic tags + */ + normalizeDiagnosticTags(tags: unknown): Array<'unnecessary' | 'deprecated'> { + if (!Array.isArray(tags)) { + return []; + } + + const result: Array<'unnecessary' | 'deprecated'> = []; + for (const tag of tags) { + if (tag === 1) { + result.push('unnecessary'); + } else if (tag === 2) { + result.push('deprecated'); + } + } + return result; + } + + /** + * Normalize diagnostic related information + */ + normalizeDiagnosticRelatedInfo( + info: unknown, + ): Array<{ location: LspLocation; message: string }> { + if (!Array.isArray(info)) { + return []; + } + + const result: Array<{ location: LspLocation; message: string }> = []; + for (const item of info) { + if (!item || typeof item !== 'object') { + continue; + } + const itemObj = item as Record; + const location = itemObj['location']; + if (!location || typeof location !== 'object') { + continue; + } + const locObj = location as Record; + const uri = locObj['uri']; + const range = this.normalizeRange(locObj['range']); + const message = itemObj['message']; + + if (typeof uri === 'string' && range && typeof message === 'string') { + result.push({ + location: { uri, range }, + message, + }); + } + } + return result; + } + + /** + * Normalize file diagnostics result + */ + normalizeFileDiagnostics( + item: unknown, + serverName: string, + ): LspFileDiagnostics | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const uri = + typeof itemObj['uri'] === 'string' ? (itemObj['uri'] as string) : ''; + if (!uri) { + return null; + } + + const items = itemObj['items']; + if (!Array.isArray(items)) { + return null; + } + + const diagnostics: LspDiagnostic[] = []; + for (const diagItem of items) { + const normalized = this.normalizeDiagnostic(diagItem, serverName); + if (normalized) { + diagnostics.push(normalized); + } + } + + return { + uri, + diagnostics, + serverName, + }; + } + + // ============================================================================ + // Code Action Normalization + // ============================================================================ + + /** + * Normalize code action result + */ + normalizeCodeAction(item: unknown, serverName: string): LspCodeAction | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + + // Check if this is a Command instead of CodeAction + if ( + itemObj['command'] && + typeof itemObj['title'] === 'string' && + !itemObj['kind'] + ) { + // This is a raw Command, wrap it + return { + title: itemObj['title'] as string, + command: { + title: itemObj['title'] as string, + command: (itemObj['command'] as string) ?? '', + arguments: itemObj['arguments'] as unknown[] | undefined, + }, + serverName, + }; + } + + const title = + typeof itemObj['title'] === 'string' ? (itemObj['title'] as string) : ''; + if (!title) { + return null; + } + + const kind = + typeof itemObj['kind'] === 'string' + ? (CODE_ACTION_KIND_LABELS[itemObj['kind'] as string] ?? + (itemObj['kind'] as LspCodeActionKind)) + : undefined; + + const isPreferred = + typeof itemObj['isPreferred'] === 'boolean' + ? (itemObj['isPreferred'] as boolean) + : undefined; + + const edit = this.normalizeWorkspaceEdit(itemObj['edit']); + const command = this.normalizeCommand(itemObj['command']); + + const diagnostics: LspDiagnostic[] = []; + if (Array.isArray(itemObj['diagnostics'])) { + for (const diag of itemObj['diagnostics']) { + const normalized = this.normalizeDiagnostic(diag, serverName); + if (normalized) { + diagnostics.push(normalized); + } + } + } + + return { + title, + kind, + diagnostics: diagnostics.length > 0 ? diagnostics : undefined, + isPreferred, + edit: edit ?? undefined, + command: command ?? undefined, + data: itemObj['data'], + serverName, + }; + } + + // ============================================================================ + // Workspace Edit Normalization + // ============================================================================ + + /** + * Normalize workspace edit + */ + normalizeWorkspaceEdit(edit: unknown): LspWorkspaceEdit | null { + if (!edit || typeof edit !== 'object') { + return null; + } + + const editObj = edit as Record; + const result: LspWorkspaceEdit = {}; + + // Handle changes (map of URI to TextEdit[]) + if (editObj['changes'] && typeof editObj['changes'] === 'object') { + const changes = editObj['changes'] as Record; + result.changes = {}; + for (const [uri, edits] of Object.entries(changes)) { + if (Array.isArray(edits)) { + const normalizedEdits: LspTextEdit[] = []; + for (const e of edits) { + const normalized = this.normalizeTextEdit(e); + if (normalized) { + normalizedEdits.push(normalized); + } + } + if (normalizedEdits.length > 0) { + result.changes[uri] = normalizedEdits; + } + } + } + } + + // Handle documentChanges + if (Array.isArray(editObj['documentChanges'])) { + result.documentChanges = []; + for (const docChange of editObj['documentChanges']) { + const normalized = this.normalizeTextDocumentEdit(docChange); + if (normalized) { + result.documentChanges.push(normalized); + } + } + } + + if ( + (!result.changes || Object.keys(result.changes).length === 0) && + (!result.documentChanges || result.documentChanges.length === 0) + ) { + return null; + } + + return result; + } + + /** + * Normalize text edit + */ + normalizeTextEdit(edit: unknown): LspTextEdit | null { + if (!edit || typeof edit !== 'object') { + return null; + } + + const editObj = edit as Record; + const range = this.normalizeRange(editObj['range']); + if (!range) { + return null; + } + + const newText = + typeof editObj['newText'] === 'string' + ? (editObj['newText'] as string) + : ''; + + return { range, newText }; + } + + /** + * Normalize text document edit + */ + normalizeTextDocumentEdit(docEdit: unknown): { + textDocument: { uri: string; version?: number | null }; + edits: LspTextEdit[]; + } | null { + if (!docEdit || typeof docEdit !== 'object') { + return null; + } + + const docEditObj = docEdit as Record; + const textDocument = docEditObj['textDocument']; + if (!textDocument || typeof textDocument !== 'object') { + return null; + } + + const textDocObj = textDocument as Record; + const uri = + typeof textDocObj['uri'] === 'string' + ? (textDocObj['uri'] as string) + : ''; + if (!uri) { + return null; + } + + const version = + typeof textDocObj['version'] === 'number' + ? (textDocObj['version'] as number) + : null; + + const edits = docEditObj['edits']; + if (!Array.isArray(edits)) { + return null; + } + + const normalizedEdits: LspTextEdit[] = []; + for (const e of edits) { + const normalized = this.normalizeTextEdit(e); + if (normalized) { + normalizedEdits.push(normalized); + } + } + + if (normalizedEdits.length === 0) { + return null; + } + + return { + textDocument: { uri, version }, + edits: normalizedEdits, + }; + } + + /** + * Normalize command + */ + normalizeCommand( + cmd: unknown, + ): { title: string; command: string; arguments?: unknown[] } | null { + if (!cmd || typeof cmd !== 'object') { + return null; + } + + const cmdObj = cmd as Record; + const title = + typeof cmdObj['title'] === 'string' ? (cmdObj['title'] as string) : ''; + const command = + typeof cmdObj['command'] === 'string' + ? (cmdObj['command'] as string) + : ''; + + if (!command) { + return null; + } + + const args = Array.isArray(cmdObj['arguments']) + ? (cmdObj['arguments'] as unknown[]) + : undefined; + + return { title, command, arguments: args }; + } + + // ============================================================================ + // Location and Symbol Normalization + // ============================================================================ + + /** + * Normalize location result (definitions, references, implementations) + */ + normalizeLocationResult( + item: unknown, + serverName: string, + ): LspReference | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const uri = (itemObj['uri'] ?? + itemObj['targetUri'] ?? + (itemObj['target'] as Record)?.['uri']) as + | string + | undefined; + + const range = (itemObj['range'] ?? + itemObj['targetSelectionRange'] ?? + itemObj['targetRange'] ?? + (itemObj['target'] as Record)?.['range']) as + | { start?: unknown; end?: unknown } + | undefined; + + if (!uri || !range?.start || !range?.end) { + return null; + } + + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; + + return { + uri, + range: { + start: { + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), + }, + end: { + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), + }, + }, + serverName, + }; + } + + /** + * Normalize symbol result (workspace symbols, document symbols) + */ + normalizeSymbolResult( + item: unknown, + serverName: string, + ): LspSymbolInformation | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const location = itemObj['location'] ?? itemObj['target'] ?? item; + if (!location || typeof location !== 'object') { + return null; + } + + const locationObj = location as Record; + const range = (locationObj['range'] ?? + locationObj['targetRange'] ?? + itemObj['range'] ?? + undefined) as { start?: unknown; end?: unknown } | undefined; + + if (!locationObj['uri'] || !range?.start || !range?.end) { + return null; + } + + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; + + return { + name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, + kind: this.normalizeSymbolKind(itemObj['kind']), + containerName: (itemObj['containerName'] ?? itemObj['container']) as + | string + | undefined, + location: { + uri: locationObj['uri'] as string, + range: { + start: { + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), + }, + end: { + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), + }, + }, + }, + serverName, + }; + } + + // ============================================================================ + // Range Normalization + // ============================================================================ + + /** + * Normalize a single range + */ + 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), + }, + }; + } + + /** + * Normalize an array of ranges + */ + 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; + } + + /** + * Normalize symbol kind from number to string label + */ + 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; + } + + // ============================================================================ + // Hover Normalization + // ============================================================================ + + /** + * Normalize hover contents to string + */ + 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 ''; + } + + /** + * Normalize hover result + */ + 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, + }; + } + + // ============================================================================ + // Call Hierarchy Normalization + // ============================================================================ + + /** + * Normalize call hierarchy item + */ + 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 + 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, + }; + } + + /** + * Normalize incoming call + */ + 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']), + }; + } + + /** + * Normalize outgoing call + */ + 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']), + }; + } + + /** + * Convert call hierarchy item back to LSP params format + */ + toCallHierarchyItemParams( + item: LspCallHierarchyItem, + ): Record { + // Use rawKind (numeric) for server communication + 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, + }; + } + + // ============================================================================ + // Document Symbol Helpers + // ============================================================================ + + /** + * Check if item is a DocumentSymbol (has range and selectionRange) + */ + isDocumentSymbol(item: Record): boolean { + const range = item['range']; + const selectionRange = item['selectionRange']; + return ( + typeof range === 'object' && + range !== null && + typeof selectionRange === 'object' && + selectionRange !== null + ); + } + + /** + * Recursively collect document symbols from a tree structure + */ + 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, + ); + } + } + } + } +} diff --git a/packages/cli/src/services/lsp/LspServerManager.ts b/packages/cli/src/services/lsp/LspServerManager.ts new file mode 100644 index 000000000..af2e9a4f6 --- /dev/null +++ b/packages/cli/src/services/lsp/LspServerManager.ts @@ -0,0 +1,713 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, +} from '@qwen-code/qwen-code-core'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { globSync } from 'glob'; +import { LspConnectionFactory } from './LspConnectionFactory.js'; +import { + DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS, + DEFAULT_LSP_MAX_RESTARTS, + DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS, + DEFAULT_LSP_SOCKET_RETRY_DELAY_MS, + DEFAULT_LSP_STARTUP_TIMEOUT_MS, + DEFAULT_LSP_WARMUP_DELAY_MS, +} from './constants.js'; +import type { + LspConnectionResult, + LspServerConfig, + LspServerHandle, + LspServerStatus, + LspSocketOptions, +} from './LspTypes.js'; + +export interface LspServerManagerOptions { + requireTrustedWorkspace: boolean; + workspaceRoot: string; +} + +export class LspServerManager { + private serverHandles: Map = new Map(); + private requireTrustedWorkspace: boolean; + private workspaceRoot: string; + + constructor( + private readonly config: CoreConfig, + private readonly workspaceContext: WorkspaceContext, + private readonly fileDiscoveryService: FileDiscoveryService, + options: LspServerManagerOptions, + ) { + this.requireTrustedWorkspace = options.requireTrustedWorkspace; + this.workspaceRoot = options.workspaceRoot; + } + + setServerConfigs(configs: LspServerConfig[]): void { + this.serverHandles.clear(); + for (const config of configs) { + this.serverHandles.set(config.name, { + config, + status: 'NOT_STARTED', + }); + } + } + + clearServerHandles(): void { + this.serverHandles.clear(); + } + + getHandles(): ReadonlyMap { + return this.serverHandles; + } + + getStatus(): Map { + const statusMap = new Map(); + for (const [name, handle] of Array.from(this.serverHandles)) { + statusMap.set(name, handle.status); + } + return statusMap; + } + + async startAll(): Promise { + for (const [name, handle] of Array.from(this.serverHandles)) { + await this.startServer(name, handle); + } + } + + async stopAll(): Promise { + for (const [name, handle] of Array.from(this.serverHandles)) { + await this.stopServer(name, handle); + } + this.serverHandles.clear(); + } + + /** + * Ensure tsserver has at least one file open so navto/navtree requests succeed. + * Sets warmedUp flag only after successful warm-up to allow retry on failure. + */ + async warmupTypescriptServer( + handle: LspServerHandle, + force = false, + ): Promise { + if (!handle.connection || !this.isTypescriptServer(handle)) { + return; + } + if (handle.warmedUp && !force) { + return; + } + const tsFile = this.findFirstTypescriptFile(); + if (!tsFile) { + return; + } + + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : tsFile.endsWith('.jsx') + ? 'javascriptreact' + : tsFile.endsWith('.js') + ? 'javascript' + : 'typescript'; + try { + const text = fs.readFileSync(tsFile, 'utf-8'); + handle.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + // Give tsserver a moment to build the project. + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_LSP_WARMUP_DELAY_MS), + ); + // Only mark as warmed up after successful completion + handle.warmedUp = true; + } catch (error) { + // Do not set warmedUp to true on failure, allowing retry + console.warn('TypeScript server warm-up failed:', error); + } + } + + private isTypescriptServer(handle: LspServerHandle): boolean { + return ( + handle.config.name.includes('typescript') || + (handle.config.command?.includes('typescript') ?? false) + ); + } + + /** + * Start individual LSP server with lock to prevent concurrent startup attempts. + * + * @param name - The name of the LSP server + * @param handle - The LSP server handle + */ + private async startServer( + name: string, + handle: LspServerHandle, + ): Promise { + // If already starting, wait for the existing promise + if (handle.startingPromise) { + return handle.startingPromise; + } + + if (handle.status === 'IN_PROGRESS' || handle.status === 'READY') { + return; + } + handle.stopRequested = false; + + // Create a promise to lock concurrent calls + handle.startingPromise = this.doStartServer(name, handle).finally(() => { + handle.startingPromise = undefined; + }); + + return handle.startingPromise; + } + + /** + * Internal method that performs the actual server startup. + * + * @param name - The name of the LSP server + * @param handle - The LSP server handle + */ + private async doStartServer( + name: string, + handle: LspServerHandle, + ): Promise { + const workspaceTrusted = this.config.isTrustedFolder(); + if ( + (this.requireTrustedWorkspace || handle.config.trustRequired) && + !workspaceTrusted + ) { + console.log( + `LSP server ${name} requires trusted workspace, skipping startup`, + ); + handle.status = 'FAILED'; + return; + } + + // Request user confirmation + const consent = await this.requestUserConsent( + name, + handle.config, + workspaceTrusted, + ); + if (!consent) { + console.log(`User declined to start LSP server ${name}`); + handle.status = 'FAILED'; + return; + } + + // Check if command exists + 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 server ${name} command not found: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } + + // Check path safety + if ( + !this.isPathSafe(handle.config.command, this.workspaceRoot, commandCwd) + ) { + console.warn( + `LSP server ${name} command path is unsafe: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } + } + + try { + handle.error = undefined; + handle.warmedUp = false; + handle.status = 'IN_PROGRESS'; + + // Create LSP connection + const connection = await this.createLspConnection(handle.config); + handle.connection = connection.connection; + handle.process = connection.process; + + // Initialize LSP server + await this.initializeLspServer(connection, handle.config); + + handle.status = 'READY'; + this.attachRestartHandler(name, handle); + console.log(`LSP server ${name} started successfully`); + } catch (error) { + handle.status = 'FAILED'; + handle.error = error as Error; + console.error(`LSP server ${name} failed to start:`, error); + } + } + + /** + * Stop individual LSP server + */ + private async stopServer( + name: string, + handle: LspServerHandle, + ): Promise { + handle.stopRequested = true; + + if (handle.connection) { + try { + await this.shutdownConnection(handle); + } catch (error) { + console.error(`Error closing LSP server ${name}:`, error); + } + } 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 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 server ${name} reached max restart attempts (${maxRestarts}), stopping restarts`, + ); + handle.status = 'FAILED'; + return; + } + handle.restartAttempts = attempts + 1; + console.warn( + `LSP server ${name} exited (code ${code ?? 'unknown'}), restarting (${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( + DEFAULT_LSP_SOCKET_RETRY_DELAY_MS * attempt, + DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + /** + * Create LSP connection + */ + private async createLspConnection( + config: LspServerConfig, + ): 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'); + } + + // Fix: use cwd as cwd instead of rootUri + const lspConnection = await LspConnectionFactory.createStdioConnection( + config.command, + config.args ?? [], + { cwd: workspaceFolder, env }, + startupTimeout, + ); + + return { + connection: lspConnection.connection, + process: lspConnection.process as ChildProcess, + shutdown: async () => { + await lspConnection.connection.shutdown(); + }, + exit: () => { + if (lspConnection.process && !lspConnection.process.killed) { + (lspConnection.process as ChildProcess).kill(); + } + lspConnection.connection.end(); + }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), + }; + } 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, + 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}`); + } + } + + /** + * Initialize LSP server + */ + private async initializeLspServer( + connection: LspConnectionResult, + config: LspServerConfig, + ): Promise { + const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot; + const workspaceFolder = { + name: path.basename(workspaceFolderPath) || workspaceFolderPath, + uri: config.rootUri, + }; + + const initializeParams = { + processId: process.pid, + rootUri: config.rootUri, + rootPath: workspaceFolderPath, + workspaceFolders: [workspaceFolder], + capabilities: { + textDocument: { + completion: { dynamicRegistration: true }, + hover: { dynamicRegistration: true }, + definition: { dynamicRegistration: true }, + references: { dynamicRegistration: true }, + documentSymbol: { dynamicRegistration: true }, + codeAction: { dynamicRegistration: true }, + }, + workspace: { + workspaceFolders: { supported: true }, + }, + }, + initializationOptions: config.initializationOptions, + }; + + await connection.initialize(initializeParams); + + // Send initialized notification and workspace folders change to help servers (e.g. tsserver) + // create projects in the correct workspace. + connection.connection.send({ + jsonrpc: '2.0', + method: 'initialized', + params: {}, + }); + connection.connection.send({ + jsonrpc: '2.0', + method: 'workspace/didChangeWorkspaceFolders', + params: { + event: { + added: [workspaceFolder], + removed: [], + }, + }, + }); + + 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') || + (config.command?.includes('typescript') ?? false) + ) { + try { + const tsFile = this.findFirstTypescriptFile(); + if (tsFile) { + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : 'typescript'; + const text = fs.readFileSync(tsFile, 'utf-8'); + connection.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + } + } catch (error) { + console.warn('TypeScript LSP warm-up failed:', error); + } + } + } + + /** + * Check if command exists + */ + 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: cwd ?? this.workspaceRoot, + env: this.buildProcessEnv(env), + }); + + child.on('error', () => { + settled = true; + resolve(false); + }); + + child.on('exit', (code) => { + if (settled) { + return; + } + // If command exists, it typically returns 0 or other non-error codes + // Some commands with --version may return non-0, but won't throw error + resolve(code !== 127); // 127 typically indicates command not found + }); + + // Set timeout to avoid long waits + setTimeout(() => { + settled = true; + child.kill(); + resolve(false); + }, DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS); + }); + } + + /** + * Check path safety + */ + private isPathSafe( + command: string, + workspacePath: string, + cwd?: string, + ): boolean { + // Allow commands without path separators (global PATH commands like 'typescript-language-server') + // These are resolved by the shell from PATH and are generally safe + if (!command.includes(path.sep) && !command.includes('/')) { + return true; + } + + // For explicit paths (absolute or relative), verify they're within workspace + 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 + ); + } + + /** + * 请求用户确认启动 LSP 服务器 + */ + private async requestUserConsent( + serverName: string, + serverConfig: LspServerConfig, + workspaceTrusted: boolean, + ): Promise { + if (workspaceTrusted) { + return true; // Auto-allow in trusted workspace + } + + if (this.requireTrustedWorkspace || serverConfig.trustRequired) { + console.log( + `Workspace not trusted, skipping LSP server ${serverName} (${serverConfig.command ?? serverConfig.transport})`, + ); + return false; + } + + console.log( + `Untrusted workspace, but LSP server ${serverName} has trustRequired=false, attempting cautious startup`, + ); + return true; + } + + /** + * Find a representative TypeScript/JavaScript file to warm up tsserver. + */ + private findFirstTypescriptFile(): string | undefined { + const patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx']; + const excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + ]; + + for (const root of this.workspaceContext.getDirectories()) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: excludePatterns, + absolute: true, + nodir: true, + }); + for (const file of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(file)) { + continue; + } + return file; + } + } catch (_error) { + // ignore glob errors + } + } + } + + return undefined; + } +} diff --git a/packages/cli/src/services/lsp/LspTypes.ts b/packages/cli/src/services/lsp/LspTypes.ts new file mode 100644 index 000000000..55b89cbef --- /dev/null +++ b/packages/cli/src/services/lsp/LspTypes.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * LSP Service Type Definitions + * + * Centralized type definitions for the LSP service modules. + */ + +import type { ChildProcess } from 'node:child_process'; + +// ============================================================================ +// LSP Initialization Options +// ============================================================================ + +/** + * LSP server initialization options passed during the initialize request. + */ +export interface LspInitializationOptions { + [key: string]: unknown; +} + +// ============================================================================ +// LSP Socket Options +// ============================================================================ + +/** + * Socket connection options for TCP or Unix socket transport. + */ +export interface LspSocketOptions { + /** Host address for TCP connections */ + host?: string; + /** Port number for TCP connections */ + port?: number; + /** Path for Unix socket connections */ + path?: string; +} + +// ============================================================================ +// LSP Server Configuration +// ============================================================================ + +/** + * Configuration for an LSP server instance. + */ +export interface LspServerConfig { + /** Unique name identifier for the server */ + name: string; + /** List of languages this server handles */ + languages: string[]; + /** Command to start the server (required for stdio transport) */ + command?: string; + /** Command line arguments */ + args?: string[]; + /** Transport type: stdio, tcp, or socket */ + transport: 'stdio' | 'tcp' | 'socket'; + /** Environment variables for the server process */ + env?: Record; + /** LSP initialization options */ + initializationOptions?: LspInitializationOptions; + /** Server-specific settings */ + settings?: Record; + /** Custom file extension to language mappings */ + extensionToLanguage?: Record; + /** Root URI for the workspace */ + rootUri: string; + /** Workspace folder path */ + workspaceFolder?: string; + /** Startup timeout in milliseconds */ + startupTimeout?: number; + /** Shutdown timeout in milliseconds */ + shutdownTimeout?: number; + /** Whether to restart on crash */ + restartOnCrash?: boolean; + /** Maximum number of restart attempts */ + maxRestarts?: number; + /** Whether trusted workspace is required */ + trustRequired?: boolean; + /** Socket connection options */ + socket?: LspSocketOptions; +} + +// ============================================================================ +// LSP JSON-RPC Message +// ============================================================================ + +/** + * JSON-RPC message format for LSP communication. + */ +export interface JsonRpcMessage { + jsonrpc: string; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +// ============================================================================ +// LSP Connection Interface +// ============================================================================ + +/** + * Interface for LSP JSON-RPC connection. + */ +export interface LspConnectionInterface { + /** Start listening on a readable stream */ + listen: (readable: NodeJS.ReadableStream) => void; + /** Send a message to the server */ + send: (message: JsonRpcMessage) => void; + /** Register a notification handler */ + onNotification: (handler: (notification: JsonRpcMessage) => void) => void; + /** Register a request handler */ + onRequest: (handler: (request: JsonRpcMessage) => Promise) => void; + /** Send a request and wait for response */ + request: (method: string, params: unknown) => Promise; + /** Send initialize request */ + initialize: (params: unknown) => Promise; + /** Send shutdown request */ + shutdown: () => Promise; + /** End the connection */ + end: () => void; +} + +// ============================================================================ +// LSP Server Status +// ============================================================================ + +/** + * Status of an LSP server instance. + */ +export type LspServerStatus = + | 'NOT_STARTED' + | 'IN_PROGRESS' + | 'READY' + | 'FAILED'; + +// ============================================================================ +// LSP Server Handle +// ============================================================================ + +/** + * Handle for managing an LSP server instance. + */ +export interface LspServerHandle { + /** Server configuration */ + config: LspServerConfig; + /** Current status */ + status: LspServerStatus; + /** Active connection to the server */ + connection?: LspConnectionInterface; + /** Server process (for stdio transport) */ + process?: ChildProcess; + /** Error that caused failure */ + error?: Error; + /** Whether TypeScript server has been warmed up */ + warmedUp?: boolean; + /** Whether stop was explicitly requested */ + stopRequested?: boolean; + /** Number of restart attempts */ + restartAttempts?: number; + /** Lock to prevent concurrent startup attempts */ + startingPromise?: Promise; +} + +// ============================================================================ +// LSP Service Options +// ============================================================================ + +/** + * Options for NativeLspService constructor. + */ +export interface NativeLspServiceOptions { + /** Whether to require trusted workspace */ + requireTrustedWorkspace?: boolean; + /** Override workspace root path */ + workspaceRoot?: string; +} + +// ============================================================================ +// LSP Connection Result +// ============================================================================ + +/** + * Result from creating an LSP connection. + */ +export interface LspConnectionResult { + /** The JSON-RPC connection */ + connection: LspConnectionInterface; + /** Server process (for stdio transport) */ + process?: ChildProcess; + /** Shutdown the connection gracefully */ + shutdown: () => Promise; + /** Force exit the connection */ + exit: () => void; + /** Send initialize request */ + initialize: (params: unknown) => Promise; +} diff --git a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts index 0f65e70cb..f9fc6b106 100644 --- a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.integration.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -477,52 +477,6 @@ describe('NativeLspService Integration Tests', () => { // The exact server name depends on built-in presets expect(status.size).toBeGreaterThanOrEqual(0); }); - - it('should respect allowed servers list', async () => { - const restrictedService = new NativeLspService( - mockConfig as unknown as CoreConfig, - mockWorkspace as unknown as WorkspaceContext, - eventEmitter, - mockFileDiscovery as unknown as FileDiscoveryService, - mockIdeStore as unknown as IdeContextStore, - { - workspaceRoot: mockWorkspace.rootPath, - allowedServers: ['typescript-language-server'], - }, - ); - - await restrictedService.discoverAndPrepare(); - const status = restrictedService.getStatus(); - - // Only allowed servers should be READY - const readyServers = Array.from(status.entries()) - .filter(([, state]) => state === 'READY') - .map(([name]) => name); - for (const name of readyServers) { - expect(['typescript-language-server']).toContain(name); - } - }); - - it('should respect excluded servers list', async () => { - const restrictedService = new NativeLspService( - mockConfig as unknown as CoreConfig, - mockWorkspace as unknown as WorkspaceContext, - eventEmitter, - mockFileDiscovery as unknown as FileDiscoveryService, - mockIdeStore as unknown as IdeContextStore, - { - workspaceRoot: mockWorkspace.rootPath, - excludedServers: ['pylsp'], - }, - ); - - await restrictedService.discoverAndPrepare(); - const status = restrictedService.getStatus(); - - // pylsp should not be present or should be FAILED - const pylspStatus = status.get('pylsp'); - expect(pylspStatus !== 'READY').toBe(true); - }); }); describe('LSP Operations - Mock Responses', () => { diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index 5ee4eff29..553581d29 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import { NativeLspService } from './NativeLspService.js'; import { EventEmitter } from 'events'; import type { diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index a19fe49af..306e706a7 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type { Config as CoreConfig, WorkspaceContext, @@ -8,10 +14,8 @@ import type { LspCallHierarchyOutgoingCall, LspCodeAction, LspCodeActionContext, - LspCodeActionKind, LspDefinition, LspDiagnostic, - LspDiagnosticSeverity, LspFileDiagnostics, LspHoverResult, LspLocation, @@ -22,233 +26,109 @@ import type { LspWorkspaceEdit, } from '@qwen-code/qwen-code-core'; import type { EventEmitter } from 'events'; -import { LspConnectionFactory } from './LspConnectionFactory.js'; +import { LspConfigLoader } from './LspConfigLoader.js'; +import { LspLanguageDetector } from './LspLanguageDetector.js'; +import { LspResponseNormalizer } from './LspResponseNormalizer.js'; +import { LspServerManager } from './LspServerManager.js'; +import type { + LspServerHandle, + LspServerStatus, + NativeLspServiceOptions, +} from './LspTypes.js'; import * as path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; -import { spawn, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'url'; import * as fs from 'node:fs'; -import { globSync } from 'glob'; - -// 定义 LSP 初始化选项的类型 -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' | '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 连接接口 -interface LspConnectionInterface { - listen: (readable: NodeJS.ReadableStream) => void; - send: (message: unknown) => void; - onNotification: (handler: (notification: unknown) => void) => void; - onRequest: (handler: (request: unknown) => Promise) => void; - request: (method: string, params: unknown) => Promise; - initialize: (params: unknown) => Promise; - shutdown: () => Promise; - end: () => void; -} - -// 定义 LSP 服务器状态 -type LspServerStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; - -// 定义 LSP 服务器句柄 -interface LspServerHandle { - config: LspServerConfig; - status: LspServerStatus; - connection?: LspConnectionInterface; - 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', -}; - -/** - * Diagnostic severity labels for converting numeric LSP DiagnosticSeverity to readable strings. - * Based on the LSP specification. - */ -const DIAGNOSTIC_SEVERITY_LABELS: Record = { - 1: 'error', - 2: 'warning', - 3: 'information', - 4: 'hint', -}; - -/** - * Code action kind labels from LSP specification. - */ -const CODE_ACTION_KIND_LABELS: Record = { - '': 'quickfix', - quickfix: 'quickfix', - refactor: 'refactor', - 'refactor.extract': 'refactor.extract', - 'refactor.inline': 'refactor.inline', - 'refactor.rewrite': 'refactor.rewrite', - source: 'source', - 'source.organizeImports': 'source.organizeImports', - 'source.fixAll': 'source.fixAll', -}; - -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 { - private serverHandles: Map = new Map(); private config: CoreConfig; private workspaceContext: WorkspaceContext; private fileDiscoveryService: FileDiscoveryService; - private allowedServers?: string[]; - private excludedServers?: string[]; private requireTrustedWorkspace: boolean; private workspaceRoot: string; - private inlineServerConfigs?: Record; - private warnedLegacyConfig = false; + private configLoader: LspConfigLoader; + private serverManager: LspServerManager; + private languageDetector: LspLanguageDetector; + private normalizer: LspResponseNormalizer; constructor( config: CoreConfig, workspaceContext: WorkspaceContext, - _eventEmitter: EventEmitter, // 未使用,用下划线前缀 + _eventEmitter: EventEmitter, fileDiscoveryService: FileDiscoveryService, - _ideContextStore: IdeContextStore, // 未使用,用下划线前缀 + _ideContextStore: IdeContextStore, options: NativeLspServiceOptions = {}, ) { this.config = config; this.workspaceContext = workspaceContext; this.fileDiscoveryService = fileDiscoveryService; - this.allowedServers = options.allowedServers?.filter(Boolean); - this.excludedServers = options.excludedServers?.filter(Boolean); this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; this.workspaceRoot = options.workspaceRoot ?? (config as { getProjectRoot: () => string }).getProjectRoot(); - this.inlineServerConfigs = options.inlineServerConfigs; + this.configLoader = new LspConfigLoader(this.workspaceRoot); + this.languageDetector = new LspLanguageDetector( + this.workspaceContext, + this.fileDiscoveryService, + ); + this.normalizer = new LspResponseNormalizer(); + this.serverManager = new LspServerManager( + this.config, + this.workspaceContext, + this.fileDiscoveryService, + { + requireTrustedWorkspace: this.requireTrustedWorkspace, + workspaceRoot: this.workspaceRoot, + }, + ); } /** - * 发现并准备 LSP 服务器 + * Discover and prepare LSP servers */ async discoverAndPrepare(): Promise { const workspaceTrusted = this.config.isTrustedFolder(); - this.serverHandles.clear(); + this.serverManager.clearServerHandles(); - // 检查工作区是否受信任 + // Check if workspace is trusted if (this.requireTrustedWorkspace && !workspaceTrusted) { - console.log('工作区不受信任,跳过 LSP 服务器发现'); + console.log('Workspace is not trusted, skipping LSP server discovery'); return; } - // 检测工作区中的语言 - const userConfigs = await this.loadUserConfigs(); + // Detect languages in workspace + const userConfigs = await this.configLoader.loadUserConfigs(); const extensionOverrides = - this.collectExtensionToLanguageOverrides(userConfigs); - const detectedLanguages = await this.detectLanguages(extensionOverrides); + this.configLoader.collectExtensionToLanguageOverrides(userConfigs); + const detectedLanguages = + await this.languageDetector.detectLanguages(extensionOverrides); - // 合并配置:内置预设 + 用户 .lsp.json + 可选 cclsp 兼容转换 - const serverConfigs = this.mergeConfigs(detectedLanguages, userConfigs); - - // 创建服务器句柄 - for (const config of serverConfigs) { - this.serverHandles.set(config.name, { - config, - status: 'NOT_STARTED' as LspServerStatus, - }); - } + // Merge configs: built-in presets + user .lsp.json + optional cclsp compatibility + const serverConfigs = this.configLoader.mergeConfigs( + detectedLanguages, + userConfigs, + ); + this.serverManager.setServerConfigs(serverConfigs); } /** - * 启动所有 LSP 服务器 + * Start all LSP servers */ async start(): Promise { - for (const [name, handle] of Array.from(this.serverHandles)) { - await this.startServer(name, handle); - } + await this.serverManager.startAll(); } /** - * 停止所有 LSP 服务器 + * Stop all LSP servers */ async stop(): Promise { - for (const [name, handle] of Array.from(this.serverHandles)) { - await this.stopServer(name, handle); - } - this.serverHandles.clear(); + await this.serverManager.stopAll(); } /** - * 获取 LSP 服务器状态 + * Get LSP server status */ getStatus(): Map { - const statusMap = new Map(); - for (const [name, handle] of Array.from(this.serverHandles)) { - statusMap.set(name, handle.status); - } - return statusMap; + return this.serverManager.getStatus(); } /** @@ -260,12 +140,14 @@ export class NativeLspService { ): Promise { const results: LspSymbolInformation[] = []; - for (const [serverName, handle] of Array.from(this.serverHandles)) { + for (const [serverName, handle] of Array.from( + this.serverManager.getHandles(), + )) { if (handle.status !== 'READY' || !handle.connection) { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); let response = await handle.connection.request('workspace/symbol', { query, }); @@ -273,7 +155,7 @@ export class NativeLspService { this.isTypescriptServer(handle) && this.isNoProjectErrorResponse(response) ) { - await this.warmupTypescriptServer(handle, true); + await this.serverManager.warmupTypescriptServer(handle, true); response = await handle.connection.request('workspace/symbol', { query, }); @@ -282,7 +164,10 @@ export class NativeLspService { continue; } for (const item of response) { - const symbol = this.normalizeSymbolResult(item, serverName); + const symbol = this.normalizer.normalizeSymbolResult( + item, + serverName, + ); if (symbol) { results.push(symbol); } @@ -299,14 +184,16 @@ export class NativeLspService { } /** - * 跳转到定义 + * Go to definition */ async definitions( location: LspLocation, serverName?: string, limit = 50, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -318,8 +205,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'textDocument/definition', { @@ -334,7 +220,7 @@ export class NativeLspService { : []; const definitions: LspDefinition[] = []; for (const def of candidates) { - const normalized = this.normalizeLocationResult(def, name); + const normalized = this.normalizer.normalizeLocationResult(def, name); if (normalized) { definitions.push(normalized); if (definitions.length >= limit) { @@ -354,7 +240,7 @@ export class NativeLspService { } /** - * 查找引用 + * Find references */ async references( location: LspLocation, @@ -362,7 +248,9 @@ export class NativeLspService { includeDeclaration = false, limit = 200, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -374,8 +262,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'textDocument/references', { @@ -389,7 +276,7 @@ export class NativeLspService { } const refs: LspReference[] = []; for (const ref of response) { - const normalized = this.normalizeLocationResult(ref, name); + const normalized = this.normalizer.normalizeLocationResult(ref, name); if (normalized) { refs.push(normalized); } @@ -409,13 +296,15 @@ export class NativeLspService { } /** - * 获取悬停信息 + * Get hover information */ async hover( location: LspLocation, serverName?: string, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -427,12 +316,12 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request('textDocument/hover', { textDocument: { uri: location.uri }, position: location.range.start, }); - const normalized = this.normalizeHoverResult(response, name); + const normalized = this.normalizer.normalizeHoverResult(response, name); if (normalized) { return normalized; } @@ -445,14 +334,16 @@ export class NativeLspService { } /** - * 获取文档符号 + * Get document symbols */ async documentSymbols( uri: string, serverName?: string, limit = 200, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -464,7 +355,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'textDocument/documentSymbol', { @@ -480,10 +371,19 @@ export class NativeLspService { continue; } const itemObj = item as Record; - if (this.isDocumentSymbol(itemObj)) { - this.collectDocumentSymbol(itemObj, uri, name, symbols, limit); + if (this.normalizer.isDocumentSymbol(itemObj)) { + this.normalizer.collectDocumentSymbol( + itemObj, + uri, + name, + symbols, + limit, + ); } else { - const normalized = this.normalizeSymbolResult(itemObj, name); + const normalized = this.normalizer.normalizeSymbolResult( + itemObj, + name, + ); if (normalized) { symbols.push(normalized); } @@ -507,14 +407,16 @@ export class NativeLspService { } /** - * 查找实现 + * Find implementations */ async implementations( location: LspLocation, serverName?: string, limit = 50, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -526,7 +428,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'textDocument/implementation', { @@ -541,7 +443,10 @@ export class NativeLspService { : []; const implementations: LspDefinition[] = []; for (const item of candidates) { - const normalized = this.normalizeLocationResult(item, name); + const normalized = this.normalizer.normalizeLocationResult( + item, + name, + ); if (normalized) { implementations.push(normalized); if (implementations.length >= limit) { @@ -564,14 +469,16 @@ export class NativeLspService { } /** - * 准备调用层级 + * Prepare call hierarchy */ async prepareCallHierarchy( location: LspLocation, serverName?: string, limit = 50, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -583,7 +490,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'textDocument/prepareCallHierarchy', { @@ -598,7 +505,10 @@ export class NativeLspService { : []; const items: LspCallHierarchyItem[] = []; for (const item of candidates) { - const normalized = this.normalizeCallHierarchyItem(item, name); + const normalized = this.normalizer.normalizeCallHierarchyItem( + item, + name, + ); if (normalized) { items.push(normalized); if (items.length >= limit) { @@ -621,7 +531,7 @@ export class NativeLspService { } /** - * 查找调用当前函数的调用者 + * Find callers of the current function */ async incomingCalls( item: LspCallHierarchyItem, @@ -629,7 +539,9 @@ export class NativeLspService { limit = 50, ): Promise { const targetServer = serverName ?? item.serverName; - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -641,11 +553,11 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'callHierarchy/incomingCalls', { - item: this.toCallHierarchyItemParams(item), + item: this.normalizer.toCallHierarchyItemParams(item), }, ); if (!Array.isArray(response)) { @@ -653,7 +565,7 @@ export class NativeLspService { } const calls: LspCallHierarchyIncomingCall[] = []; for (const call of response) { - const normalized = this.normalizeIncomingCall(call, name); + const normalized = this.normalizer.normalizeIncomingCall(call, name); if (normalized) { calls.push(normalized); if (calls.length >= limit) { @@ -676,7 +588,7 @@ export class NativeLspService { } /** - * 查找当前函数调用的目标 + * Find functions called by the current function */ async outgoingCalls( item: LspCallHierarchyItem, @@ -684,7 +596,9 @@ export class NativeLspService { limit = 50, ): Promise { const targetServer = serverName ?? item.serverName; - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -696,11 +610,11 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( 'callHierarchy/outgoingCalls', { - item: this.toCallHierarchyItemParams(item), + item: this.normalizer.toCallHierarchyItemParams(item), }, ); if (!Array.isArray(response)) { @@ -708,7 +622,7 @@ export class NativeLspService { } const calls: LspCallHierarchyOutgoingCall[] = []; for (const call of response) { - const normalized = this.normalizeOutgoingCall(call, name); + const normalized = this.normalizer.normalizeOutgoingCall(call, name); if (normalized) { calls.push(normalized); if (calls.length >= limit) { @@ -731,13 +645,15 @@ export class NativeLspService { } /** - * 获取文档的诊断信息 + * Get diagnostics for a document */ async diagnostics( uri: string, serverName?: string, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -751,7 +667,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); // Request pull diagnostics if the server supports it const response = await handle.connection.request( @@ -766,7 +682,10 @@ export class NativeLspService { const items = responseObj['items']; if (Array.isArray(items)) { for (const item of items) { - const normalized = this.normalizeDiagnostic(item, name); + const normalized = this.normalizer.normalizeDiagnostic( + item, + name, + ); if (normalized) { allDiagnostics.push(normalized); } @@ -784,13 +703,15 @@ export class NativeLspService { } /** - * 获取工作区所有文档的诊断信息 + * Get diagnostics for all documents in the workspace */ async workspaceDiagnostics( serverName?: string, limit = 100, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -804,7 +725,7 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); // Request workspace diagnostics if supported const response = await handle.connection.request( @@ -822,7 +743,10 @@ export class NativeLspService { if (results.length >= limit) { break; } - const normalized = this.normalizeFileDiagnostics(item, name); + const normalized = this.normalizer.normalizeFileDiagnostics( + item, + name, + ); if (normalized && normalized.diagnostics.length > 0) { results.push(normalized); } @@ -842,7 +766,7 @@ export class NativeLspService { } /** - * 获取指定位置的代码操作 + * Get code actions at the specified position */ async codeActions( uri: string, @@ -851,7 +775,9 @@ export class NativeLspService { serverName?: string, limit = 20, ): Promise { - const handles = Array.from(this.serverHandles.entries()).filter( + const handles = Array.from( + this.serverManager.getHandles().entries(), + ).filter( ([name, handle]) => handle.status === 'READY' && handle.connection && @@ -863,11 +789,11 @@ export class NativeLspService { continue; } try { - await this.warmupTypescriptServer(handle); + await this.serverManager.warmupTypescriptServer(handle); // Convert context diagnostics to LSP format const lspDiagnostics = context.diagnostics.map((d: LspDiagnostic) => - this.denormalizeDiagnostic(d), + this.normalizer.denormalizeDiagnostic(d), ); const response = await handle.connection.request( @@ -892,7 +818,7 @@ export class NativeLspService { const actions: LspCodeAction[] = []; for (const item of response) { - const normalized = this.normalizeCodeAction(item, name); + const normalized = this.normalizer.normalizeCodeAction(item, name); if (normalized) { actions.push(normalized); if (actions.length >= limit) { @@ -913,7 +839,7 @@ export class NativeLspService { } /** - * 应用工作区编辑 + * Apply workspace edit */ async applyWorkspaceEdit( edit: LspWorkspaceEdit, @@ -945,7 +871,7 @@ export class NativeLspService { } /** - * 应用文本编辑到文件 + * Apply text edits to a file */ private async applyTextEdits( uri: string, @@ -1004,2005 +930,6 @@ export class NativeLspService { fs.writeFileSync(filePath, lines.join('\n'), 'utf-8'); } - /** - * 规范化诊断结果 - */ - private normalizeDiagnostic( - item: unknown, - serverName: string, - ): LspDiagnostic | null { - if (!item || typeof item !== 'object') { - return null; - } - - const itemObj = item as Record; - const range = this.normalizeRange(itemObj['range']); - if (!range) { - return null; - } - - const message = - typeof itemObj['message'] === 'string' - ? (itemObj['message'] as string) - : ''; - if (!message) { - return null; - } - - const severityNum = - typeof itemObj['severity'] === 'number' - ? (itemObj['severity'] as number) - : undefined; - const severity = severityNum - ? DIAGNOSTIC_SEVERITY_LABELS[severityNum] - : undefined; - - const code = itemObj['code']; - const codeValue = - typeof code === 'string' || typeof code === 'number' ? code : undefined; - - const source = - typeof itemObj['source'] === 'string' - ? (itemObj['source'] as string) - : undefined; - - const tags = this.normalizeDiagnosticTags(itemObj['tags']); - const relatedInfo = this.normalizeDiagnosticRelatedInfo( - itemObj['relatedInformation'], - ); - - return { - range, - severity, - code: codeValue, - source, - message, - tags: tags.length > 0 ? tags : undefined, - relatedInformation: relatedInfo.length > 0 ? relatedInfo : undefined, - serverName, - }; - } - - /** - * 将诊断转换回 LSP 格式 - */ - private denormalizeDiagnostic( - diagnostic: LspDiagnostic, - ): Record { - const severityMap: Record = { - error: 1, - warning: 2, - information: 3, - hint: 4, - }; - - return { - range: diagnostic.range, - message: diagnostic.message, - severity: diagnostic.severity - ? severityMap[diagnostic.severity] - : undefined, - code: diagnostic.code, - source: diagnostic.source, - }; - } - - /** - * 规范化诊断标签 - */ - private normalizeDiagnosticTags( - tags: unknown, - ): Array<'unnecessary' | 'deprecated'> { - if (!Array.isArray(tags)) { - return []; - } - - const result: Array<'unnecessary' | 'deprecated'> = []; - for (const tag of tags) { - if (tag === 1) { - result.push('unnecessary'); - } else if (tag === 2) { - result.push('deprecated'); - } - } - return result; - } - - /** - * 规范化诊断相关信息 - */ - private normalizeDiagnosticRelatedInfo( - info: unknown, - ): Array<{ location: LspLocation; message: string }> { - if (!Array.isArray(info)) { - return []; - } - - const result: Array<{ location: LspLocation; message: string }> = []; - for (const item of info) { - if (!item || typeof item !== 'object') { - continue; - } - const itemObj = item as Record; - const location = itemObj['location']; - if (!location || typeof location !== 'object') { - continue; - } - const locObj = location as Record; - const uri = locObj['uri']; - const range = this.normalizeRange(locObj['range']); - const message = itemObj['message']; - - if (typeof uri === 'string' && range && typeof message === 'string') { - result.push({ - location: { uri, range }, - message, - }); - } - } - return result; - } - - /** - * 规范化文件诊断结果 - */ - private normalizeFileDiagnostics( - item: unknown, - serverName: string, - ): LspFileDiagnostics | null { - if (!item || typeof item !== 'object') { - return null; - } - - const itemObj = item as Record; - const uri = - typeof itemObj['uri'] === 'string' ? (itemObj['uri'] as string) : ''; - if (!uri) { - return null; - } - - const items = itemObj['items']; - if (!Array.isArray(items)) { - return null; - } - - const diagnostics: LspDiagnostic[] = []; - for (const diagItem of items) { - const normalized = this.normalizeDiagnostic(diagItem, serverName); - if (normalized) { - diagnostics.push(normalized); - } - } - - return { - uri, - diagnostics, - serverName, - }; - } - - /** - * 规范化代码操作结果 - */ - private normalizeCodeAction( - item: unknown, - serverName: string, - ): LspCodeAction | null { - if (!item || typeof item !== 'object') { - return null; - } - - const itemObj = item as Record; - - // Check if this is a Command instead of CodeAction - if ( - itemObj['command'] && - typeof itemObj['title'] === 'string' && - !itemObj['kind'] - ) { - // This is a raw Command, wrap it - return { - title: itemObj['title'] as string, - command: { - title: itemObj['title'] as string, - command: (itemObj['command'] as string) ?? '', - arguments: itemObj['arguments'] as unknown[] | undefined, - }, - serverName, - }; - } - - const title = - typeof itemObj['title'] === 'string' ? (itemObj['title'] as string) : ''; - if (!title) { - return null; - } - - const kind = - typeof itemObj['kind'] === 'string' - ? (CODE_ACTION_KIND_LABELS[itemObj['kind'] as string] ?? - (itemObj['kind'] as LspCodeActionKind)) - : undefined; - - const isPreferred = - typeof itemObj['isPreferred'] === 'boolean' - ? (itemObj['isPreferred'] as boolean) - : undefined; - - const edit = this.normalizeWorkspaceEdit(itemObj['edit']); - const command = this.normalizeCommand(itemObj['command']); - - const diagnostics: LspDiagnostic[] = []; - if (Array.isArray(itemObj['diagnostics'])) { - for (const diag of itemObj['diagnostics']) { - const normalized = this.normalizeDiagnostic(diag, serverName); - if (normalized) { - diagnostics.push(normalized); - } - } - } - - return { - title, - kind, - diagnostics: diagnostics.length > 0 ? diagnostics : undefined, - isPreferred, - edit: edit ?? undefined, - command: command ?? undefined, - data: itemObj['data'], - serverName, - }; - } - - /** - * 规范化工作区编辑 - */ - private normalizeWorkspaceEdit(edit: unknown): LspWorkspaceEdit | null { - if (!edit || typeof edit !== 'object') { - return null; - } - - const editObj = edit as Record; - const result: LspWorkspaceEdit = {}; - - // Handle changes (map of URI to TextEdit[]) - if (editObj['changes'] && typeof editObj['changes'] === 'object') { - const changes = editObj['changes'] as Record; - result.changes = {}; - for (const [uri, edits] of Object.entries(changes)) { - if (Array.isArray(edits)) { - const normalizedEdits: LspTextEdit[] = []; - for (const e of edits) { - const normalized = this.normalizeTextEdit(e); - if (normalized) { - normalizedEdits.push(normalized); - } - } - if (normalizedEdits.length > 0) { - result.changes[uri] = normalizedEdits; - } - } - } - } - - // Handle documentChanges - if (Array.isArray(editObj['documentChanges'])) { - result.documentChanges = []; - for (const docChange of editObj['documentChanges']) { - const normalized = this.normalizeTextDocumentEdit(docChange); - if (normalized) { - result.documentChanges.push(normalized); - } - } - } - - if ( - (!result.changes || Object.keys(result.changes).length === 0) && - (!result.documentChanges || result.documentChanges.length === 0) - ) { - return null; - } - - return result; - } - - /** - * 规范化文本编辑 - */ - private normalizeTextEdit(edit: unknown): LspTextEdit | null { - if (!edit || typeof edit !== 'object') { - return null; - } - - const editObj = edit as Record; - const range = this.normalizeRange(editObj['range']); - if (!range) { - return null; - } - - const newText = - typeof editObj['newText'] === 'string' - ? (editObj['newText'] as string) - : ''; - - return { range, newText }; - } - - /** - * 规范化文本文档编辑 - */ - private normalizeTextDocumentEdit(docEdit: unknown): { - textDocument: { uri: string; version?: number | null }; - edits: LspTextEdit[]; - } | null { - if (!docEdit || typeof docEdit !== 'object') { - return null; - } - - const docEditObj = docEdit as Record; - const textDocument = docEditObj['textDocument']; - if (!textDocument || typeof textDocument !== 'object') { - return null; - } - - const textDocObj = textDocument as Record; - const uri = - typeof textDocObj['uri'] === 'string' - ? (textDocObj['uri'] as string) - : ''; - if (!uri) { - return null; - } - - const version = - typeof textDocObj['version'] === 'number' - ? (textDocObj['version'] as number) - : null; - - const edits = docEditObj['edits']; - if (!Array.isArray(edits)) { - return null; - } - - const normalizedEdits: LspTextEdit[] = []; - for (const e of edits) { - const normalized = this.normalizeTextEdit(e); - if (normalized) { - normalizedEdits.push(normalized); - } - } - - if (normalizedEdits.length === 0) { - return null; - } - - return { - textDocument: { uri, version }, - edits: normalizedEdits, - }; - } - - /** - * 规范化命令 - */ - private normalizeCommand( - cmd: unknown, - ): { title: string; command: string; arguments?: unknown[] } | null { - if (!cmd || typeof cmd !== 'object') { - return null; - } - - const cmdObj = cmd as Record; - const title = - typeof cmdObj['title'] === 'string' ? (cmdObj['title'] as string) : ''; - const command = - typeof cmdObj['command'] === 'string' - ? (cmdObj['command'] as string) - : ''; - - if (!command) { - return null; - } - - const args = Array.isArray(cmdObj['arguments']) - ? (cmdObj['arguments'] as unknown[]) - : undefined; - - return { title, command, arguments: args }; - } - - /** - * 检测工作区中的编程语言 - */ - 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/**', - '**/dist/**', - '**/build/**', - ]; - - const files = new Set(); - const searchRoots = this.workspaceContext.getDirectories(); - - for (const root of searchRoots) { - for (const pattern of patterns) { - try { - const matches = globSync(pattern, { - cwd: root, - ignore: excludePatterns, - absolute: true, - nodir: true, - }); - - for (const match of matches) { - if (this.fileDiscoveryService.shouldIgnoreFile(match)) { - continue; - } - files.add(match); - } - } catch (_error) { - // Ignore glob errors for missing/invalid directories - } - } - } - - // 统计不同语言的文件数量 - const languageCounts = new Map(); - for (const file of Array.from(files)) { - const ext = path.extname(file).slice(1).toLowerCase(); - if (ext) { - const lang = this.mapExtensionToLanguage(ext, extensionMap); - if (lang) { - languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); - } - } - } - - // 也可以通过特定的配置文件来检测语言 - const rootMarkers = await this.detectRootMarkers(); - for (const marker of rootMarkers) { - const lang = this.mapMarkerToLanguage(marker); - if (lang) { - // 使用安全的数字操作避免 NaN - const currentCount = languageCounts.get(lang) || 0; - languageCounts.set(lang, currentCount + 100); // 给配置文件更高的权重 - } - } - - // 返回检测到的语言,按数量排序 - return Array.from(languageCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([lang]) => lang); - } - - /** - * 检测根目录标记文件 - */ - private async detectRootMarkers(): Promise { - const markers = new Set(); - const commonMarkers = [ - 'package.json', - 'tsconfig.json', - 'pyproject.toml', - 'go.mod', - 'Cargo.toml', - 'pom.xml', - 'build.gradle', - 'composer.json', - 'Gemfile', - 'mix.exs', - 'deno.json', - ]; - - for (const root of this.workspaceContext.getDirectories()) { - for (const marker of commonMarkers) { - try { - const fullPath = path.join(root, marker); - if (fs.existsSync(fullPath)) { - markers.add(marker); - } - } catch (_error) { - // ignore missing files - } - } - } - - return Array.from(markers); - } - - /** - * 将文件扩展名映射到编程语言 - */ - 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', - tsx: 'typescriptreact', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - cpp: 'cpp', - c: 'c', - php: 'php', - rb: 'ruby', - cs: 'csharp', - vue: 'vue', - svelte: 'svelte', - html: 'html', - css: 'css', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - }; - - 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; - } - - /** - * 将根目录标记映射到编程语言 - */ - private mapMarkerToLanguage(marker: string): string | null { - const markerToLang: { [key: string]: string } = { - 'package.json': 'javascript', - 'tsconfig.json': 'typescript', - 'pyproject.toml': 'python', - 'go.mod': 'go', - 'Cargo.toml': 'rust', - 'pom.xml': 'java', - 'build.gradle': 'java', - 'composer.json': 'php', - Gemfile: 'ruby', - '*.sln': 'csharp', - 'mix.exs': 'elixir', - 'deno.json': 'deno', - }; - - return markerToLang[marker] || null; - } - - private normalizeLocationResult( - item: unknown, - serverName: string, - ): LspReference | null { - if (!item || typeof item !== 'object') { - return null; - } - - const itemObj = item as Record; - const uri = (itemObj['uri'] ?? - itemObj['targetUri'] ?? - (itemObj['target'] as Record)?.['uri']) as - | string - | undefined; - - const range = (itemObj['range'] ?? - itemObj['targetSelectionRange'] ?? - itemObj['targetRange'] ?? - (itemObj['target'] as Record)?.['range']) as - | { start?: unknown; end?: unknown } - | undefined; - - if (!uri || !range?.start || !range?.end) { - return null; - } - - const start = range.start as { line?: number; character?: number }; - const end = range.end as { line?: number; character?: number }; - - return { - uri, - range: { - start: { - line: Number(start?.line ?? 0), - character: Number(start?.character ?? 0), - }, - end: { - line: Number(end?.line ?? 0), - character: Number(end?.character ?? 0), - }, - }, - serverName, - }; - } - - private normalizeSymbolResult( - item: unknown, - serverName: string, - ): LspSymbolInformation | null { - if (!item || typeof item !== 'object') { - return null; - } - - const itemObj = item as Record; - const location = itemObj['location'] ?? itemObj['target'] ?? item; - if (!location || typeof location !== 'object') { - return null; - } - - const locationObj = location as Record; - const range = (locationObj['range'] ?? - locationObj['targetRange'] ?? - itemObj['range'] ?? - undefined) as { start?: unknown; end?: unknown } | undefined; - - if (!locationObj['uri'] || !range?.start || !range?.end) { - return null; - } - - const start = range.start as { line?: number; character?: number }; - const end = range.end as { line?: number; character?: number }; - - return { - name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, - kind: this.normalizeSymbolKind(itemObj['kind']), - containerName: (itemObj['containerName'] ?? itemObj['container']) as - | string - | undefined, - location: { - uri: locationObj['uri'] as string, - range: { - start: { - line: Number(start?.line ?? 0), - character: Number(start?.character ?? 0), - }, - end: { - line: Number(end?.line ?? 0), - character: Number(end?.character ?? 0), - }, - }, - }, - serverName, - }; - } - - 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 mergeConfigs( - detectedLanguages: string[], - userConfigs: LspServerConfig[], - ): LspServerConfig[] { - // 内置预设配置 - const presets = this.getBuiltInPresets(detectedLanguages); - - // 合并配置,用户配置优先级更高 - const mergedConfigs = [...presets]; - - for (const userConfig of userConfigs) { - // 查找是否有同名的预设配置,如果有则替换 - const existingIndex = mergedConfigs.findIndex( - (c) => c.name === userConfig.name, - ); - if (existingIndex !== -1) { - mergedConfigs[existingIndex] = userConfig; - } else { - mergedConfigs.push(userConfig); - } - } - - return mergedConfigs; - } - - /** - * 获取内置预设配置 - */ - private getBuiltInPresets(detectedLanguages: string[]): LspServerConfig[] { - const presets: LspServerConfig[] = []; - - // 将目录路径转换为文件 URI 格式 - const rootUri = pathToFileURL(this.workspaceRoot).toString(); - - // 根据检测到的语言生成对应的 LSP 服务器配置 - if ( - detectedLanguages.includes('typescript') || - detectedLanguages.includes('javascript') - ) { - presets.push({ - name: 'typescript-language-server', - languages: [ - 'typescript', - 'javascript', - 'typescriptreact', - 'javascriptreact', - ], - command: 'typescript-language-server', - args: ['--stdio'], - transport: 'stdio', - initializationOptions: {}, - rootUri, - workspaceFolder: this.workspaceRoot, - trustRequired: true, - }); - } - - if (detectedLanguages.includes('python')) { - presets.push({ - name: 'pylsp', - languages: ['python'], - command: 'pylsp', - args: [], - transport: 'stdio', - initializationOptions: {}, - rootUri, - workspaceFolder: this.workspaceRoot, - trustRequired: true, - }); - } - - if (detectedLanguages.includes('go')) { - presets.push({ - name: 'gopls', - languages: ['go'], - command: 'gopls', - args: [], - transport: 'stdio', - initializationOptions: {}, - rootUri, - workspaceFolder: this.workspaceRoot, - trustRequired: true, - }); - } - - // 可以根据需要添加更多语言的预设配置 - - return presets; - } - - /** - * 加载用户 .lsp.json 配置 - */ - private async loadUserConfigs(): Promise { - const configs: LspServerConfig[] = []; - const sources: Array<{ origin: string; data: unknown }> = []; - - 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'); - sources.push({ - origin: lspConfigPath, - data: JSON.parse(configContent), - }); - } 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 服务器 - */ - private async startServer( - name: string, - handle: LspServerHandle, - ): Promise { - 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.length > 0 && - !this.isServerInList(this.allowedServers, handle.config) - ) { - console.log(`LSP 服务器 ${name} 不在允许列表中,跳过启动`); - handle.status = 'FAILED'; - return; - } - - const workspaceTrusted = this.config.isTrustedFolder(); - if ( - (this.requireTrustedWorkspace || handle.config.trustRequired) && - !workspaceTrusted - ) { - console.log(`LSP 服务器 ${name} 需要受信任的工作区,跳过启动`); - handle.status = 'FAILED'; - return; - } - - // 请求用户确认 - const consent = await this.requestUserConsent( - name, - handle.config, - workspaceTrusted, - ); - if (!consent) { - console.log(`用户拒绝启动 LSP 服务器 ${name}`); - 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, commandCwd) - ) { - console.warn( - `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, - ); - handle.status = 'FAILED'; - return; - } - } - - try { - handle.error = undefined; - handle.warmedUp = false; - handle.status = 'IN_PROGRESS'; - - // 创建 LSP 连接 - const connection = await this.createLspConnection(handle.config); - handle.connection = connection.connection; - handle.process = connection.process; - - // 初始化 LSP 服务器 - await this.initializeLspServer(connection, handle.config); - - handle.status = 'READY'; - this.attachRestartHandler(name, handle); - console.log(`LSP 服务器 ${name} 启动成功`); - } catch (error) { - handle.status = 'FAILED'; - handle.error = error as Error; - console.error(`LSP 服务器 ${name} 启动失败:`, error); - } - } - - /** - * 停止单个 LSP 服务器 - */ - private async stopServer( - name: string, - handle: LspServerHandle, - ): Promise { - handle.stopRequested = true; - - if (handle.connection) { - try { - await this.shutdownConnection(handle); - } catch (error) { - console.error(`关闭 LSP 服务器 ${name} 时出错:`, error); - } - } 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)); - } - } - } - - /** - * 创建 LSP 连接 - */ - private async createLspConnection(config: LspServerConfig): Promise<{ - connection: LspConnectionInterface; - 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: workspaceFolder, env }, - startupTimeout, - ); - - return { - connection: lspConnection.connection as LspConnectionInterface, - process: lspConnection.process as ChildProcess, - shutdown: async () => { - await lspConnection.connection.shutdown(); - }, - exit: () => { - if (lspConnection.process && !lspConnection.process.killed) { - (lspConnection.process as ChildProcess).kill(); - } - lspConnection.connection.end(); - }, - initialize: async (params: unknown) => - lspConnection.connection.initialize(params), - }; - } 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}`); - } - } - - /** - * 初始化 LSP 服务器 - */ - private async initializeLspServer( - connection: Awaited>, - config: LspServerConfig, - ): Promise { - const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot; - const workspaceFolder = { - name: path.basename(workspaceFolderPath) || workspaceFolderPath, - uri: config.rootUri, - }; - - const initializeParams = { - processId: process.pid, - rootUri: config.rootUri, - rootPath: workspaceFolderPath, - workspaceFolders: [workspaceFolder], - capabilities: { - textDocument: { - completion: { dynamicRegistration: true }, - hover: { dynamicRegistration: true }, - definition: { dynamicRegistration: true }, - references: { dynamicRegistration: true }, - documentSymbol: { dynamicRegistration: true }, - codeAction: { dynamicRegistration: true }, - }, - workspace: { - workspaceFolders: { supported: true }, - }, - }, - initializationOptions: config.initializationOptions, - }; - - await connection.initialize(initializeParams); - - // Send initialized notification and workspace folders change to help servers (e.g. tsserver) - // create projects in the correct workspace. - connection.connection.send({ - jsonrpc: '2.0', - method: 'initialized', - params: {}, - }); - connection.connection.send({ - jsonrpc: '2.0', - method: 'workspace/didChangeWorkspaceFolders', - params: { - event: { - added: [workspaceFolder], - removed: [], - }, - }, - }); - - 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') || - (config.command?.includes('typescript') ?? false) - ) { - try { - const tsFile = this.findFirstTypescriptFile(); - if (tsFile) { - const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') - ? 'typescriptreact' - : 'typescript'; - const text = fs.readFileSync(tsFile, 'utf-8'); - connection.connection.send({ - jsonrpc: '2.0', - method: 'textDocument/didOpen', - params: { - textDocument: { - uri, - languageId, - version: 1, - text, - }, - }, - }); - } - } catch (error) { - console.warn('TypeScript LSP warm-up failed:', error); - } - } - } - - /** - * 检查命令是否存在 - */ - 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: cwd ?? this.workspaceRoot, - env: this.buildProcessEnv(env), - }); - - child.on('error', () => { - settled = true; - resolve(false); - }); - - child.on('exit', (code) => { - if (settled) { - return; - } - // 如果命令存在,通常会返回 0 或其他非错误码 - // 有些命令的 --version 选项可能返回非 0,但不会抛出错误 - resolve(code !== 127); // 127 通常表示命令不存在 - }); - - // 设置超时,避免长时间等待 - setTimeout(() => { - settled = true; - child.kill(); - resolve(false); - }, 2000); - }); - } - - /** - * 检查路径安全性 - */ - private isPathSafe( - command: string, - workspacePath: string, - cwd?: string, - ): boolean { - // 检查命令是否在工作区路径内,或者是否在系统 PATH 中 - // 允许全局安装的命令(如在 PATH 中的命令) - // 只阻止显式指定工作区外绝对路径的情况 - 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 - ); - } - - /** - * 请求用户确认启动 LSP 服务器 - */ - private async requestUserConsent( - serverName: string, - serverConfig: LspServerConfig, - workspaceTrusted: boolean, - ): Promise { - if (workspaceTrusted) { - return true; // 在受信任工作区中自动允许 - } - - if (this.requireTrustedWorkspace || serverConfig.trustRequired) { - console.log( - `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command ?? serverConfig.transport})`, - ); - return false; - } - - console.log( - `未受信任的工作区,LSP 服务器 ${serverName} 标记为 trustRequired=false,将谨慎尝试启动`, - ); - return true; - } - - /** - * Find a representative TypeScript/JavaScript file to warm up tsserver. - */ - private findFirstTypescriptFile(): string | undefined { - const patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx']; - const excludePatterns = [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - ]; - - for (const root of this.workspaceContext.getDirectories()) { - for (const pattern of patterns) { - try { - const matches = globSync(pattern, { - cwd: root, - ignore: excludePatterns, - absolute: true, - nodir: true, - }); - for (const file of matches) { - if (this.fileDiscoveryService.shouldIgnoreFile(file)) { - continue; - } - return file; - } - } catch (_error) { - // ignore glob errors - } - } - } - - return undefined; - } - private isTypescriptServer(handle: LspServerHandle): boolean { return ( handle.config.name.includes('typescript') || @@ -3022,51 +949,4 @@ export class NativeLspService { : ''; return message.includes('No Project'); } - - /** - * Ensure tsserver has at least one file open so navto/navtree requests succeed. - */ - private async warmupTypescriptServer( - handle: LspServerHandle, - force = false, - ): Promise { - if (!handle.connection || !this.isTypescriptServer(handle)) { - return; - } - if (handle.warmedUp && !force) { - return; - } - const tsFile = this.findFirstTypescriptFile(); - if (!tsFile) { - return; - } - handle.warmedUp = true; - const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') - ? 'typescriptreact' - : tsFile.endsWith('.jsx') - ? 'javascriptreact' - : tsFile.endsWith('.js') - ? 'javascript' - : 'typescript'; - try { - const text = fs.readFileSync(tsFile, 'utf-8'); - handle.connection.send({ - jsonrpc: '2.0', - method: 'textDocument/didOpen', - params: { - textDocument: { - uri, - languageId, - version: 1, - text, - }, - }, - }); - // Give tsserver a moment to build the project. - await new Promise((resolve) => setTimeout(resolve, 150)); - } catch (error) { - console.warn('TypeScript server warm-up failed:', error); - } - } } diff --git a/packages/cli/src/services/lsp/constants.ts b/packages/cli/src/services/lsp/constants.ts new file mode 100644 index 000000000..b76c09aa7 --- /dev/null +++ b/packages/cli/src/services/lsp/constants.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + LspCodeActionKind, + LspDiagnosticSeverity, +} from '@qwen-code/qwen-code-core'; + +// ============================================================================ +// Timeout Constants +// ============================================================================ + +/** Default timeout for LSP server startup in milliseconds */ +export const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000; + +/** Default timeout for LSP requests in milliseconds */ +export const DEFAULT_LSP_REQUEST_TIMEOUT_MS = 15000; + +/** Default delay for TypeScript server warm-up in milliseconds */ +export const DEFAULT_LSP_WARMUP_DELAY_MS = 150; + +/** Default timeout for command existence check in milliseconds */ +export const DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS = 2000; + +/** Default timeout for LSP server shutdown in milliseconds */ +export const DEFAULT_LSP_SHUTDOWN_TIMEOUT_MS = 5000; + +// ============================================================================ +// Retry Constants +// ============================================================================ + +/** Default maximum number of server restart attempts */ +export const DEFAULT_LSP_MAX_RESTARTS = 3; + +/** Default initial delay between socket connection retries in milliseconds */ +export const DEFAULT_LSP_SOCKET_RETRY_DELAY_MS = 250; + +/** Default maximum delay between socket connection retries in milliseconds */ +export const DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS = 1000; + +// ============================================================================ +// LSP Protocol Labels +// ============================================================================ + +/** + * 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 + */ +export 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', +}; + +/** + * Diagnostic severity labels for converting numeric LSP DiagnosticSeverity to readable strings. + * Based on the LSP specification. + */ +export const DIAGNOSTIC_SEVERITY_LABELS: Record = + { + 1: 'error', + 2: 'warning', + 3: 'information', + 4: 'hint', + }; + +/** + * Code action kind labels from LSP specification. + */ +export const CODE_ACTION_KIND_LABELS: Record = { + '': 'quickfix', + quickfix: 'quickfix', + refactor: 'refactor', + 'refactor.extract': 'refactor.extract', + 'refactor.inline': 'refactor.inline', + 'refactor.rewrite': 'refactor.rewrite', + source: 'source', + 'source.organizeImports': 'source.organizeImports', + 'source.fixAll': 'source.fixAll', +}; + +// ============================================================================ +// Language Detection +// ============================================================================ + +/** + * Common root marker files that indicate project type/language. + */ +export const COMMON_ROOT_MARKERS = [ + 'package.json', + 'tsconfig.json', + 'pyproject.toml', + 'go.mod', + 'Cargo.toml', + 'pom.xml', + 'build.gradle', + 'composer.json', + 'Gemfile', + 'mix.exs', + 'deno.json', +] as const; + +/** + * Mapping from root marker files to programming languages. + */ +export const MARKER_TO_LANGUAGE: Record = { + 'package.json': 'javascript', + 'tsconfig.json': 'typescript', + 'pyproject.toml': 'python', + 'go.mod': 'go', + 'Cargo.toml': 'rust', + 'pom.xml': 'java', + 'build.gradle': 'java', + 'composer.json': 'php', + Gemfile: 'ruby', + '*.sln': 'csharp', + 'mix.exs': 'elixir', + 'deno.json': 'deno', +}; + +/** + * Default mapping from file extensions to language identifiers. + */ +export const DEFAULT_EXTENSION_TO_LANGUAGE: Record = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + php: 'php', + rb: 'ruby', + cs: 'csharp', + vue: 'vue', + svelte: 'svelte', + html: 'html', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', +}; + +/** + * Glob patterns to exclude when detecting languages. + */ +export const LANGUAGE_DETECTION_EXCLUDE_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', +] as const; + +// ============================================================================ +// Default Limits for LSP Operations +// ============================================================================ + +/** Default limit for workspace symbol search results */ +export const DEFAULT_LSP_WORKSPACE_SYMBOL_LIMIT = 50; + +/** Default limit for definition/implementation results */ +export const DEFAULT_LSP_DEFINITION_LIMIT = 50; + +/** Default limit for reference results */ +export const DEFAULT_LSP_REFERENCE_LIMIT = 200; + +/** Default limit for document symbol results */ +export const DEFAULT_LSP_DOCUMENT_SYMBOL_LIMIT = 200; + +/** Default limit for call hierarchy results */ +export const DEFAULT_LSP_CALL_HIERARCHY_LIMIT = 50; + +/** Default limit for diagnostics results */ +export const DEFAULT_LSP_DIAGNOSTICS_LIMIT = 100; + +/** Default limit for code action results */ +export const DEFAULT_LSP_CODE_ACTION_LIMIT = 20; + +/** Maximum number of files to scan during language detection */ +export const DEFAULT_LSP_LANGUAGE_DETECTION_FILE_LIMIT = 1000; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a303e26f0..0cd207b6b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -61,9 +61,6 @@ import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { WebSearchTool } from '../tools/web-search/index.js'; 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'; @@ -296,8 +293,6 @@ export interface ConfigParameters { mcpServers?: Record; lsp?: { enabled?: boolean; - allowed?: string[]; - excluded?: string[]; }; lspClient?: LspClient; userMemory?: string; @@ -444,8 +439,6 @@ export class Config { private readonly mcpServerCommand: string | undefined; private mcpServers: Record | undefined; private readonly lspEnabled: boolean; - private readonly lspAllowed?: string[]; - private readonly lspExcluded?: string[]; private lspClient?: LspClient; private readonly allowedMcpServers?: string[]; private readonly excludedMcpServers?: string[]; @@ -551,8 +544,6 @@ export class Config { this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; this.lspEnabled = params.lsp?.enabled ?? false; - this.lspAllowed = params.lsp?.allowed?.filter(Boolean); - this.lspExcluded = params.lsp?.excluded?.filter(Boolean); this.lspClient = params.lspClient; this.allowedMcpServers = params.allowedMcpServers; this.excludedMcpServers = params.excludedMcpServers; @@ -1120,14 +1111,6 @@ export class Config { return this.lspEnabled; } - getLspAllowed(): string[] | undefined { - return this.lspAllowed; - } - - getLspExcluded(): string[] | undefined { - return this.lspExcluded; - } - getLspClient(): LspClient | undefined { return this.lspClient; } @@ -1690,12 +1673,8 @@ export class Config { registerCoreTool(WebSearchTool, this); } if (this.isLspEnabled() && this.getLspClient()) { - // Register the unified LSP tool (recommended) + // Register the unified LSP tool registerCoreTool(LspTool, this); - // Keep legacy tools for backward compatibility - registerCoreTool(LspGoToDefinitionTool, this); - registerCoreTool(LspFindReferencesTool, this); - registerCoreTool(LspWorkspaceSymbolTool, this); } await registry.discoverAllTools(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b535a763..42950ffb9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -116,7 +116,6 @@ export * from './extension/index.js'; // Export prompt logic export * from './prompts/mcp-prompts.js'; -export * from './lsp/types.js'; // Export specific tool logic export * from './tools/read-file.js'; @@ -131,8 +130,6 @@ export * from './tools/memoryTool.js'; export * from './tools/shell.js'; export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; -export * from './tools/lsp-go-to-definition.js'; -export * from './tools/lsp-find-references.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; @@ -142,6 +139,10 @@ export * from './tools/skill.js'; export * from './tools/todoWrite.js'; export * from './tools/exitPlanMode.js'; +// Export LSP types and tools +export * from './lsp/types.js'; +export * from './tools/lsp.js'; + // MCP OAuth export { MCPOAuthProvider } from './mcp/oauth-provider.js'; export type { diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 1602b286c..780a45718 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -98,7 +98,11 @@ export interface LspCallHierarchyOutgoingCall { /** * Diagnostic severity levels from LSP specification. */ -export type LspDiagnosticSeverity = 'error' | 'warning' | 'information' | 'hint'; +export type LspDiagnosticSeverity = + | 'error' + | 'warning' + | 'information' + | 'hint'; /** * A diagnostic message from a language server. @@ -326,10 +330,7 @@ export interface LspClient { /** * Get diagnostics for a specific document. */ - diagnostics( - uri: string, - serverName?: string, - ): Promise; + diagnostics(uri: string, serverName?: string): Promise; /** * Get diagnostics for all open documents in the workspace. diff --git a/packages/core/src/tools/lsp-find-references.ts b/packages/core/src/tools/lsp-find-references.ts deleted file mode 100644 index 5f7127dba..000000000 --- a/packages/core/src/tools/lsp-find-references.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * @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 { LspClient, LspLocation, LspReference } from '../lsp/types.js'; - -export interface LspFindReferencesParams { - /** - * Symbol name to resolve if a file/position is not provided. - */ - symbol?: string; - /** - * File path (absolute or workspace-relative). - * Use together with `line` (1-based) and optional `character` (1-based). - */ - file?: string; - /** - * File URI (e.g., file:///path/to/file). - * Use together with `line` (1-based) and optional `character` (1-based). - */ - uri?: 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 results (default: false). - */ - includeDeclaration?: boolean; - /** - * Optional server name override. - */ - serverName?: string; - /** - * Optional maximum number of results. - */ - limit?: number; -} - -type ResolvedTarget = - | { - location: LspLocation; - description: string; - serverName?: string; - fromSymbol: boolean; - } - | { error: string }; - -class LspFindReferencesInvocation extends BaseToolInvocation< - LspFindReferencesParams, - ToolResult -> { - constructor( - private readonly config: Config, - params: LspFindReferencesParams, - ) { - super(params); - } - - getDescription(): string { - if (this.params.symbol) { - return `LSP find-references(查引用) for symbol "${this.params.symbol}"`; - } - if (this.params.file && this.params.line !== undefined) { - return `LSP find-references(查引用) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; - } - if (this.params.uri && this.params.line !== undefined) { - return `LSP find-references(查引用) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; - } - return 'LSP find-references(查引用)'; - } - - async execute(_signal: AbortSignal): Promise { - const client = this.config.getLspClient(); - if (!client || !this.config.isLspEnabled()) { - const message = - 'LSP find-references is unavailable (LSP disabled or not initialized).'; - return { llmContent: message, returnDisplay: message }; - } - - const target = await this.resolveTarget(client); - 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, - target.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.formatLocation(reference, workspaceRoot)}`, - ); - - const heading = `References for ${target.description}:`; - return { - llmContent: [heading, ...lines].join('\n'), - returnDisplay: lines.join('\n'), - }; - } - - private async resolveTarget( - client: Pick, - ): Promise { - const workspaceRoot = this.config.getProjectRoot(); - const lineProvided = typeof this.params.line === 'number'; - const character = this.params.character ?? 1; - - if ((this.params.file || this.params.uri) && lineProvided) { - const uri = this.resolveUri(workspaceRoot); - if (!uri) { - return { - error: - 'A valid file path or URI is required when specifying a line/character.', - }; - } - const position = { - line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), - character: Math.max(0, Math.floor(character - 1)), - }; - const location: LspLocation = { - uri, - range: { start: position, end: position }, - }; - const description = this.formatLocation( - { ...location, serverName: this.params.serverName }, - workspaceRoot, - ); - return { - location, - description, - serverName: this.params.serverName, - fromSymbol: false, - }; - } - - if (this.params.symbol) { - try { - const symbols = await client.workspaceSymbols(this.params.symbol, 5); - if (!symbols.length) { - return { - error: `No symbols found for query "${this.params.symbol}".`, - }; - } - const top = symbols[0]; - return { - location: top.location, - description: `symbol "${this.params.symbol}"`, - serverName: this.params.serverName ?? top.serverName, - fromSymbol: true, - }; - } catch (error) { - return { - error: `Workspace symbol search failed: ${ - (error as Error)?.message || String(error) - }`, - }; - } - } - - return { - error: - 'Provide a symbol name or a file plus line (and optional character) to use find-references.', - }; - } - - private resolveUri(workspaceRoot: string): string | null { - if (this.params.uri) { - if ( - this.params.uri.startsWith('file://') || - this.params.uri.includes('://') - ) { - return this.params.uri; - } - const absoluteUriPath = path.isAbsolute(this.params.uri) - ? this.params.uri - : path.resolve(workspaceRoot, this.params.uri); - return pathToFileURL(absoluteUriPath).toString(); - } - - if (this.params.file) { - const absolutePath = path.isAbsolute(this.params.file) - ? this.params.file - : path.resolve(workspaceRoot, this.params.file); - return pathToFileURL(absolutePath).toString(); - } - - return null; - } - - private formatLocation( - location: LspReference | (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}`; - } -} - -export class LspFindReferencesTool extends BaseDeclarativeTool< - LspFindReferencesParams, - ToolResult -> { - static readonly Name = ToolNames.LSP_FIND_REFERENCES; - - constructor(private readonly config: Config) { - super( - LspFindReferencesTool.Name, - ToolDisplayNames.LSP_FIND_REFERENCES, - 'Use LSP find-references for a symbol or a specific file location(查引用,优先于 grep 搜索)。', - Kind.Other, - { - type: 'object', - properties: { - symbol: { - type: 'string', - description: - 'Symbol name to resolve when a file/position is not provided.', - }, - file: { - type: 'string', - description: - 'File path (absolute or workspace-relative). Requires `line`.', - }, - uri: { - type: 'string', - description: - 'File URI (file:///...). Requires `line` when provided.', - }, - 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.', - }, - serverName: { - type: 'string', - description: 'Optional LSP server name to target.', - }, - limit: { - type: 'number', - description: 'Optional maximum number of results to return.', - }, - }, - }, - false, - false, - ); - } - - protected createInvocation( - params: LspFindReferencesParams, - ): ToolInvocation { - return new LspFindReferencesInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/lsp-go-to-definition.ts b/packages/core/src/tools/lsp-go-to-definition.ts deleted file mode 100644 index 54e093545..000000000 --- a/packages/core/src/tools/lsp-go-to-definition.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * @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 { LspClient, LspDefinition, LspLocation } from '../lsp/types.js'; - -export interface LspGoToDefinitionParams { - /** - * Symbol name to resolve if a file/position is not provided. - */ - symbol?: string; - /** - * File path (absolute or workspace-relative). - * Use together with `line` (1-based) and optional `character` (1-based). - */ - file?: string; - /** - * File URI (e.g., file:///path/to/file). - * Use together with `line` (1-based) and optional `character` (1-based). - */ - uri?: 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; - /** - * Optional server name override. - */ - serverName?: string; - /** - * Optional maximum number of results. - */ - limit?: number; -} - -type ResolvedTarget = - | { - location: LspLocation; - description: string; - serverName?: string; - fromSymbol: boolean; - } - | { error: string }; - -class LspGoToDefinitionInvocation extends BaseToolInvocation< - LspGoToDefinitionParams, - ToolResult -> { - constructor( - private readonly config: Config, - params: LspGoToDefinitionParams, - ) { - super(params); - } - - getDescription(): string { - if (this.params.symbol) { - return `LSP go-to-definition(跳转定义) for symbol "${this.params.symbol}"`; - } - if (this.params.file && this.params.line !== undefined) { - return `LSP go-to-definition(跳转定义) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; - } - if (this.params.uri && this.params.line !== undefined) { - return `LSP go-to-definition(跳转定义) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; - } - return 'LSP go-to-definition(跳转定义)'; - } - - async execute(_signal: AbortSignal): Promise { - const client = this.config.getLspClient(); - if (!client || !this.config.isLspEnabled()) { - const message = - 'LSP go-to-definition is unavailable (LSP disabled or not initialized).'; - return { llmContent: message, returnDisplay: message }; - } - - const target = await this.resolveTarget(client); - 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, - target.serverName, - limit, - ); - } catch (error) { - const message = `LSP go-to-definition failed: ${ - (error as Error)?.message || String(error) - }`; - return { llmContent: message, returnDisplay: message }; - } - - // Fallback to the resolved symbol location if the server does not return definitions. - if (!definitions.length && target.fromSymbol) { - definitions = [ - { - ...target.location, - serverName: target.serverName, - }, - ]; - } - - 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.formatLocation(definition, workspaceRoot)}`, - ); - - const heading = `Definitions for ${target.description}:`; - return { - llmContent: [heading, ...lines].join('\n'), - returnDisplay: lines.join('\n'), - }; - } - - private async resolveTarget( - client: Pick, - ): Promise { - const workspaceRoot = this.config.getProjectRoot(); - const lineProvided = typeof this.params.line === 'number'; - const character = this.params.character ?? 1; - - if ((this.params.file || this.params.uri) && lineProvided) { - const uri = this.resolveUri(workspaceRoot); - if (!uri) { - return { - error: - 'A valid file path or URI is required when specifying a line/character.', - }; - } - const position = { - line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), - character: Math.max(0, Math.floor(character - 1)), - }; - const location: LspLocation = { - uri, - range: { start: position, end: position }, - }; - const description = this.formatLocation( - { ...location, serverName: this.params.serverName }, - workspaceRoot, - ); - return { - location, - description, - serverName: this.params.serverName, - fromSymbol: false, - }; - } - - if (this.params.symbol) { - try { - const symbols = await client.workspaceSymbols(this.params.symbol, 5); - if (!symbols.length) { - return { - error: `No symbols found for query "${this.params.symbol}".`, - }; - } - const top = symbols[0]; - return { - location: top.location, - description: `symbol "${this.params.symbol}"`, - serverName: this.params.serverName ?? top.serverName, - fromSymbol: true, - }; - } catch (error) { - return { - error: `Workspace symbol search failed: ${ - (error as Error)?.message || String(error) - }`, - }; - } - } - - return { - error: - 'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.', - }; - } - - private resolveUri(workspaceRoot: string): string | null { - if (this.params.uri) { - if ( - this.params.uri.startsWith('file://') || - this.params.uri.includes('://') - ) { - return this.params.uri; - } - const absoluteUriPath = path.isAbsolute(this.params.uri) - ? this.params.uri - : path.resolve(workspaceRoot, this.params.uri); - return pathToFileURL(absoluteUriPath).toString(); - } - - if (this.params.file) { - const absolutePath = path.isAbsolute(this.params.file) - ? this.params.file - : path.resolve(workspaceRoot, this.params.file); - return pathToFileURL(absolutePath).toString(); - } - - return null; - } - - private formatLocation( - location: LspDefinition | (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}`; - } -} - -export class LspGoToDefinitionTool extends BaseDeclarativeTool< - LspGoToDefinitionParams, - ToolResult -> { - static readonly Name = ToolNames.LSP_GO_TO_DEFINITION; - - constructor(private readonly config: Config) { - super( - LspGoToDefinitionTool.Name, - ToolDisplayNames.LSP_GO_TO_DEFINITION, - 'Use LSP go-to-definition for a symbol or a specific file location(跳转定义,优先于 grep 搜索)。', - Kind.Other, - { - type: 'object', - properties: { - symbol: { - type: 'string', - description: - 'Symbol name to resolve when a file/position is not provided.', - }, - file: { - type: 'string', - description: - 'File path (absolute or workspace-relative). Requires `line`.', - }, - uri: { - type: 'string', - description: - 'File URI (file:///...). Requires `line` when provided.', - }, - 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.', - }, - serverName: { - type: 'string', - description: 'Optional LSP server name to target.', - }, - limit: { - type: 'number', - description: 'Optional maximum number of results to return.', - }, - }, - }, - false, - false, - ); - } - - protected createInvocation( - params: LspGoToDefinitionParams, - ): ToolInvocation { - return new LspGoToDefinitionInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/lsp-workspace-symbol.ts b/packages/core/src/tools/lsp-workspace-symbol.ts deleted file mode 100644 index be016a02d..000000000 --- a/packages/core/src/tools/lsp-workspace-symbol.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import path from 'node:path'; -import { fileURLToPath } 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 { LspSymbolInformation } from '../lsp/types.js'; - -export interface LspWorkspaceSymbolParams { - /** - * Query string to search symbols (e.g., function or class name). - */ - query: string; - /** - * Maximum number of results to return. - */ - limit?: number; -} - -class LspWorkspaceSymbolInvocation extends BaseToolInvocation< - LspWorkspaceSymbolParams, - ToolResult -> { - constructor( - private readonly config: Config, - params: LspWorkspaceSymbolParams, - ) { - super(params); - } - - getDescription(): string { - return `LSP workspace symbol search(按名称找定义/实现/引用) for "${this.params.query}"`; - } - - async execute(_signal: AbortSignal): Promise { - const client = this.config.getLspClient(); - if (!client || !this.config.isLspEnabled()) { - const message = - 'LSP workspace symbol search is unavailable (LSP disabled or not initialized).'; - return { llmContent: message, returnDisplay: message }; - } - - const limit = this.params.limit ?? 20; - let symbols: LspSymbolInformation[] = []; - try { - symbols = await client.workspaceSymbols(this.params.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 "${this.params.query}".`; - return { llmContent: message, returnDisplay: message }; - } - - const workspaceRoot = this.config.getProjectRoot(); - const lines = symbols.slice(0, limit).map((symbol, index) => { - const location = this.formatLocation(symbol, 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 "${this.params.query}":`; - - 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.formatLocation( - { location: ref, name: '', kind: undefined }, - 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 formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) { - const { uri, range } = symbol.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}`; - } -} - -export class LspWorkspaceSymbolTool extends BaseDeclarativeTool< - LspWorkspaceSymbolParams, - ToolResult -> { - static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL; - - constructor(private readonly config: Config) { - super( - LspWorkspaceSymbolTool.Name, - ToolDisplayNames.LSP_WORKSPACE_SYMBOL, - 'Search workspace symbols via LSP(查找定义/实现/引用,按名称定位符号,优先于 grep)。', - Kind.Other, - { - type: 'object', - properties: { - query: { - type: 'string', - description: - 'Symbol name query, e.g., function/class/variable name to search.', - }, - limit: { - type: 'number', - description: 'Optional maximum number of results to return.', - }, - }, - required: ['query'], - }, - false, - false, - ); - } - - protected createInvocation( - params: LspWorkspaceSymbolParams, - ): ToolInvocation { - return new LspWorkspaceSymbolInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/lsp.test.ts b/packages/core/src/tools/lsp.test.ts index 03a8747ab..74b5c4067 100644 --- a/packages/core/src/tools/lsp.test.ts +++ b/packages/core/src/tools/lsp.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -1163,11 +1163,11 @@ describe('LspTool', () => { // Should include at least these definitions expect(definitionNames).toEqual( expect.arrayContaining([ - 'LspCallHierarchyItem', - 'LspDiagnostic', - 'LspPosition', - 'LspRange', - ]), + 'LspCallHierarchyItem', + 'LspDiagnostic', + 'LspPosition', + 'LspRange', + ]), ); }); }); diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts index 065414d8b..0a8fd0b76 100644 --- a/packages/core/src/tools/lsp.ts +++ b/packages/core/src/tools/lsp.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index d9a5ef772..aa3687aba 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,9 +25,6 @@ export const ToolNames = { WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', LS: 'list_directory', - 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; @@ -53,9 +50,6 @@ export const ToolDisplayNames = { WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', LS: 'ListFiles', - LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol', - LSP_GO_TO_DEFINITION: 'LspGoToDefinition', - LSP_FIND_REFERENCES: 'LspFindReferences', /** Unified LSP tool display name. */ LSP: 'Lsp', } as const; @@ -66,8 +60,9 @@ export const ToolDisplayNames = { export const ToolNamesMigration = { search_file_content: ToolNames.GREP, // Legacy name from grep tool replace: ToolNames.EDIT, // Legacy name from edit tool - go_to_definition: ToolNames.LSP_GO_TO_DEFINITION, - find_references: ToolNames.LSP_FIND_REFERENCES, + // Legacy LSP tools now use unified LSP tool with operation parameter + go_to_definition: ToolNames.LSP, + find_references: ToolNames.LSP, } as const; // Migration from old tool display names to new tool display names diff --git a/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md b/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md deleted file mode 100644 index e3660926e..000000000 --- a/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md +++ /dev/null @@ -1,255 +0,0 @@ -# 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 逻辑,减少首次调用延迟 From 05b56487caa3a6c65a508eb7bdc7a3c65ff7cd37 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 25 Jan 2026 23:05:56 +0800 Subject: [PATCH 10/15] refactor(cli/lsp): extract NativeLspClient and simplify LSP service architecture - Extract NativeLspClient from NativeLspService for better separation of concerns - Simplify LspConfigLoader by removing built-in configuration options - Remove LSP_DEBUGGING_GUIDE.md as it's no longer needed - Clean up unused LSP constants - Remove deprecated LSP configuration from config.ts --- packages/cli/LSP_DEBUGGING_GUIDE.md | 156 ----------- packages/cli/src/config/config.ts | 129 +-------- .../cli/src/services/lsp/LspConfigLoader.ts | 109 +++----- .../src/services/lsp/LspLanguageDetector.ts | 6 + .../src/services/lsp/LspResponseNormalizer.ts | 6 + .../cli/src/services/lsp/LspServerManager.ts | 8 +- .../cli/src/services/lsp/NativeLspClient.ts | 259 ++++++++++++++++++ .../cli/src/services/lsp/NativeLspService.ts | 167 +++-------- packages/cli/src/services/lsp/constants.ts | 106 ------- 9 files changed, 350 insertions(+), 596 deletions(-) delete mode 100644 packages/cli/LSP_DEBUGGING_GUIDE.md create mode 100644 packages/cli/src/services/lsp/NativeLspClient.ts diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md deleted file mode 100644 index d837adb4d..000000000 --- a/packages/cli/LSP_DEBUGGING_GUIDE.md +++ /dev/null @@ -1,156 +0,0 @@ -# LSP 调试指南 - -本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。 - -## 1. 启用调试模式 - -CLI 支持调试模式,可以提供额外的日志信息: - -```bash -# 使用 debug 标志运行 -qwen --debug [你的命令] - -# 或设置环境变量 -DEBUG=true qwen [你的命令] -DEBUG_MODE=true qwen [你的命令] -``` - -## 2. LSP 配置选项 - -LSP 功能通过 `--experimental-lsp` 命令行参数启用。服务器配置通过以下方式定义: - -- `.lsp.json` 文件:在项目根目录创建配置文件 -- `lsp.languageServers`:在 `settings.json` 中内联配置 - -### 在 settings.json 中的示例配置 - -```json -{ - "lsp": { - "languageServers": { - "typescript-language-server": { - "languages": ["typescript", "javascript"], - "command": "typescript-language-server", - "args": ["--stdio"] - } - } - } -} -``` - -### 在 .lsp.json 中的示例配置 - -```json -{ - "typescript": { - "command": "typescript-language-server", - "args": ["--stdio"], - "extensionToLanguage": { - ".ts": "typescript", - ".tsx": "typescriptreact" - } - } -} -``` - -## 3. NativeLspService 调试功能 - -`NativeLspService` 类包含几个调试功能: - -### 3.1 控制台日志 - -服务向控制台输出状态消息: - -- `LSP 服务器 ${name} 启动成功` - 服务器成功启动 -- `LSP 服务器 ${name} 启动失败` - 服务器启动失败 -- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现 - -### 3.2 错误处理 - -服务具有全面的错误处理和详细的错误消息 - -### 3.3 状态跟踪 - -您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态 - -## 4. 调试命令 - -```bash -# 启用调试运行 -qwen --debug --prompt "调试 LSP 功能" - -# 检查在您的项目中检测到哪些 LSP 服务器 -# 系统会自动检测语言和相应的 LSP 服务器 -``` - -## 5. 手动 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 -{ - "python": { - "command": "pylsp", - "args": [], - "transport": "stdio", - "trustRequired": true - } -} -``` - -## 6. LSP 问题排查 - -### 6.1 检查 LSP 服务器是否已安装 - -- 对于 TypeScript/JavaScript: `typescript-language-server` -- 对于 Python: `pylsp` -- 对于 Go: `gopls` - -### 6.2 验证工作区信任 - -- LSP 服务器可能需要受信任的工作区才能启动 -- 检查 `security.folderTrust.enabled` 设置 - -### 6.3 查看日志 - -- 查找以 `LSP 服务器` 开头的控制台消息 -- 检查命令存在性和路径安全性问题 - -## 7. LSP 服务启动流程 - -LSP 服务的启动遵循以下流程: - -1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言 -2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄 -3. **启动服务器**: `start()` 方法启动所有服务器句柄 -4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换 - -## 8. 调试技巧 - -- 使用 `--debug` 标志查看详细的启动过程 -- 检查工作区是否受信任(影响 LSP 服务器启动) -- 确认 LSP 服务器命令在系统 PATH 中可用 -- 使用 `getStatus()` 方法监控服务器运行状态 diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f04486894..165ec8a43 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -45,6 +45,7 @@ import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +import { NativeLspClient } from '../services/lsp/NativeLspClient.js'; import { NativeLspService } from '../services/lsp/NativeLspService.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -151,134 +152,6 @@ export interface CliArgs { channel: string | undefined; } -class NativeLspClient implements LspClient { - constructor(private readonly service: NativeLspService) {} - - workspaceSymbols(query: string, limit?: number) { - return this.service.workspaceSymbols(query, limit); - } - - definitions( - location: Parameters[0], - serverName?: string, - limit?: number, - ) { - return this.service.definitions(location, serverName, limit); - } - - references( - location: Parameters[0], - serverName?: string, - includeDeclaration?: boolean, - limit?: number, - ) { - return this.service.references( - location, - serverName, - includeDeclaration, - 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); - } - - /** - * Get diagnostics for a specific document. - */ - diagnostics(uri: string, serverName?: string) { - return this.service.diagnostics(uri, serverName); - } - - /** - * Get diagnostics for all open documents in the workspace. - */ - workspaceDiagnostics(serverName?: string, limit?: number) { - return this.service.workspaceDiagnostics(serverName, limit); - } - - /** - * Get code actions available at a specific location. - */ - codeActions( - uri: string, - range: Parameters[1], - context: Parameters[2], - serverName?: string, - limit?: number, - ) { - return this.service.codeActions(uri, range, context, serverName, limit); - } - - /** - * Apply a workspace edit (from code action or other sources). - */ - applyWorkspaceEdit( - edit: Parameters[0], - serverName?: string, - ) { - return this.service.applyWorkspaceEdit(edit, serverName); - } -} - function normalizeOutputFormat( format: string | OutputFormat | undefined, ): OutputFormat | undefined { diff --git a/packages/cli/src/services/lsp/LspConfigLoader.ts b/packages/cli/src/services/lsp/LspConfigLoader.ts index 89f56ee64..a0c5cd08d 100644 --- a/packages/cli/src/services/lsp/LspConfigLoader.ts +++ b/packages/cli/src/services/lsp/LspConfigLoader.ts @@ -14,39 +14,28 @@ import type { } from './LspTypes.js'; export class LspConfigLoader { - private warnedLegacyConfig = false; - constructor(private readonly workspaceRoot: string) {} /** - * Load user .lsp.json configuration + * Load user .lsp.json configuration. + * Supports two official formats: + * 1. Basic format: { "language": { "command": "...", "extensionToLanguage": {...} } } + * 2. LanguageServers format: { "languageServers": { "server-name": { "languages": [...], ... } } } */ async loadUserConfigs(): Promise { - const configs: LspServerConfig[] = []; - const sources: Array<{ origin: string; data: unknown }> = []; - const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); - if (fs.existsSync(lspConfigPath)) { - try { - const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); - sources.push({ - origin: lspConfigPath, - data: JSON.parse(configContent), - }); - } catch (error) { - console.warn('Failed to load user .lsp.json config:', error); - } + if (!fs.existsSync(lspConfigPath)) { + return []; } - 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); + try { + const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); + const data = JSON.parse(configContent); + return this.parseConfigSource(data, lspConfigPath); + } catch (error) { + console.warn('Failed to load user .lsp.json config:', error); + return []; } - - return configs; } /** @@ -164,40 +153,43 @@ export class LspConfigLoader { return presets; } + /** + * Parse configuration source and extract server configs. + * Detects format based on presence of 'languageServers' key. + */ private parseConfigSource( source: unknown, origin: string, - ): { configs: LspServerConfig[]; usedLegacyFormat: boolean } { + ): LspServerConfig[] { if (!this.isRecord(source)) { - return { configs: [], usedLegacyFormat: false }; + return []; } 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; - } + // Determine format: languageServers wrapper vs basic format + const hasLanguageServersWrapper = this.isRecord(source['languageServers']); + const serverMap = hasLanguageServersWrapper + ? (source['languageServers'] as Record) + : source; 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] : [])); + // In basic format: key is language name, server name comes from command + // In languageServers format: key is server name, languages come from 'languages' array + const isBasicFormat = !hasLanguageServersWrapper && !spec['languages']; - const name = usedLegacyFormat + const languages = isBasicFormat + ? [key] + : (this.normalizeStringArray(spec['languages']) ?? + (typeof spec['languages'] === 'string' ? [spec['languages']] : [])); + + const name = isBasicFormat ? typeof spec['command'] === 'string' - ? (spec['command'] as string) + ? spec['command'] : key : key; @@ -207,7 +199,7 @@ export class LspConfigLoader { } } - return { configs, usedLegacyFormat }; + return configs; } private buildServerConfig( @@ -282,37 +274,6 @@ export class LspConfigLoader { }; } - 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); } diff --git a/packages/cli/src/services/lsp/LspLanguageDetector.ts b/packages/cli/src/services/lsp/LspLanguageDetector.ts index 694cf14f1..863332867 100644 --- a/packages/cli/src/services/lsp/LspLanguageDetector.ts +++ b/packages/cli/src/services/lsp/LspLanguageDetector.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + /** * LSP Language Detector * diff --git a/packages/cli/src/services/lsp/LspResponseNormalizer.ts b/packages/cli/src/services/lsp/LspResponseNormalizer.ts index ee789bc73..a9720a8a4 100644 --- a/packages/cli/src/services/lsp/LspResponseNormalizer.ts +++ b/packages/cli/src/services/lsp/LspResponseNormalizer.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + /** * LSP Response Normalizer * diff --git a/packages/cli/src/services/lsp/LspServerManager.ts b/packages/cli/src/services/lsp/LspServerManager.ts index af2e9a4f6..0bb129529 100644 --- a/packages/cli/src/services/lsp/LspServerManager.ts +++ b/packages/cli/src/services/lsp/LspServerManager.ts @@ -143,7 +143,13 @@ export class LspServerManager { } } - private isTypescriptServer(handle: LspServerHandle): boolean { + /** + * Check if the given handle is a TypeScript language server. + * + * @param handle - The LSP server handle + * @returns true if it's a TypeScript server + */ + isTypescriptServer(handle: LspServerHandle): boolean { return ( handle.config.name.includes('typescript') || (handle.config.command?.includes('typescript') ?? false) diff --git a/packages/cli/src/services/lsp/NativeLspClient.ts b/packages/cli/src/services/lsp/NativeLspClient.ts new file mode 100644 index 000000000..890ed0755 --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspClient.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * NativeLspClient is an adapter that implements the LspClient interface + * by delegating all calls to NativeLspService. + * + * This class bridges the gap between the generic LspClient interface (defined in core) + * and the CLI-specific NativeLspService implementation. + */ + +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspClient, + LspCodeAction, + LspCodeActionContext, + LspDefinition, + LspDiagnostic, + LspFileDiagnostics, + LspHoverResult, + LspLocation, + LspRange, + LspReference, + LspSymbolInformation, + LspWorkspaceEdit, +} from '@qwen-code/qwen-code-core'; + +import type { NativeLspService } from './NativeLspService.js'; + +/** + * Adapter class that implements LspClient by delegating to NativeLspService. + * + * @example + * ```typescript + * const lspService = new NativeLspService(config, workspaceContext, ...); + * await lspService.start(); + * const lspClient = new NativeLspClient(lspService); + * config.setLspClient(lspClient); + * ``` + */ +export class NativeLspClient implements LspClient { + /** + * Creates a new NativeLspClient instance. + * + * @param service - The NativeLspService instance to delegate calls to + */ + constructor(private readonly service: NativeLspService) {} + + /** + * Search for symbols across the workspace. + * + * @param query - The search query string + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of symbol information + */ + workspaceSymbols( + query: string, + limit?: number, + ): Promise { + return this.service.workspaceSymbols(query, limit); + } + + /** + * Find where a symbol is defined. + * + * @param location - The source location to find definitions for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of definition locations + */ + definitions( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise { + return this.service.definitions(location, serverName, limit); + } + + /** + * Find all references to a symbol. + * + * @param location - The source location to find references for + * @param serverName - Optional specific LSP server to query + * @param includeDeclaration - Whether to include the declaration in results + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of reference locations + */ + references( + location: LspLocation, + serverName?: string, + includeDeclaration?: boolean, + limit?: number, + ): Promise { + return this.service.references( + location, + serverName, + includeDeclaration, + limit, + ); + } + + /** + * Get hover information (documentation, type info) for a symbol. + * + * @param location - The source location to get hover info for + * @param serverName - Optional specific LSP server to query + * @returns Promise resolving to hover result or null if not available + */ + hover( + location: LspLocation, + serverName?: string, + ): Promise { + return this.service.hover(location, serverName); + } + + /** + * Get all symbols in a document. + * + * @param uri - The document URI to get symbols for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of symbol information + */ + documentSymbols( + uri: string, + serverName?: string, + limit?: number, + ): Promise { + return this.service.documentSymbols(uri, serverName, limit); + } + + /** + * Find implementations of an interface or abstract method. + * + * @param location - The source location to find implementations for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of implementation locations + */ + implementations( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise { + return this.service.implementations(location, serverName, limit); + } + + /** + * Prepare call hierarchy item at a position (functions/methods). + * + * @param location - The source location to prepare call hierarchy for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of call hierarchy items + */ + prepareCallHierarchy( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise { + return this.service.prepareCallHierarchy(location, serverName, limit); + } + + /** + * Find all functions/methods that call the given function. + * + * @param item - The call hierarchy item to find callers for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of incoming calls + */ + incomingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise { + return this.service.incomingCalls(item, serverName, limit); + } + + /** + * Find all functions/methods called by the given function. + * + * @param item - The call hierarchy item to find callees for + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of results to return + * @returns Promise resolving to array of outgoing calls + */ + outgoingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise { + return this.service.outgoingCalls(item, serverName, limit); + } + + /** + * Get diagnostics for a specific document. + * + * @param uri - The document URI to get diagnostics for + * @param serverName - Optional specific LSP server to query + * @returns Promise resolving to array of diagnostics + */ + diagnostics(uri: string, serverName?: string): Promise { + return this.service.diagnostics(uri, serverName); + } + + /** + * Get diagnostics for all open documents in the workspace. + * + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of file diagnostics to return + * @returns Promise resolving to array of file diagnostics + */ + workspaceDiagnostics( + serverName?: string, + limit?: number, + ): Promise { + return this.service.workspaceDiagnostics(serverName, limit); + } + + /** + * Get code actions available at a specific location. + * + * @param uri - The document URI + * @param range - The range to get code actions for + * @param context - The code action context including diagnostics + * @param serverName - Optional specific LSP server to query + * @param limit - Maximum number of code actions to return + * @returns Promise resolving to array of code actions + */ + codeActions( + uri: string, + range: LspRange, + context: LspCodeActionContext, + serverName?: string, + limit?: number, + ): Promise { + return this.service.codeActions(uri, range, context, serverName, limit); + } + + /** + * Apply a workspace edit (from code action or other sources). + * + * @param edit - The workspace edit to apply + * @param serverName - Optional specific LSP server context + * @returns Promise resolving to true if edit was applied successfully + */ + applyWorkspaceEdit( + edit: LspWorkspaceEdit, + serverName?: string, + ): Promise { + return this.service.applyWorkspaceEdit(edit, serverName); + } +} diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index 306e706a7..a7e12cfcf 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -34,6 +34,7 @@ import type { LspServerHandle, LspServerStatus, NativeLspServiceOptions, + LspConnectionInterface, } from './LspTypes.js'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -131,6 +132,29 @@ export class NativeLspService { return this.serverManager.getStatus(); } + /** + * Get ready server handles filtered by optional server name. + * Each handle is guaranteed to have a valid connection. + * + * @param serverName - Optional server name to filter by + * @returns Array of [serverName, handle] tuples with active connections + */ + private getReadyHandles( + serverName?: string, + ): Array<[string, LspServerHandle & { connection: LspConnectionInterface }]> { + return Array.from(this.serverManager.getHandles().entries()).filter( + ( + entry, + ): entry is [ + string, + LspServerHandle & { connection: LspConnectionInterface }, + ] => + entry[1].status === 'READY' && + entry[1].connection !== undefined && + (!serverName || entry[0] === serverName), + ); + } + /** * Workspace symbol search across all ready LSP servers. */ @@ -152,7 +176,7 @@ export class NativeLspService { query, }); if ( - this.isTypescriptServer(handle) && + this.serverManager.isTypescriptServer(handle) && this.isNoProjectErrorResponse(response) ) { await this.serverManager.warmupTypescriptServer(handle, true); @@ -191,19 +215,9 @@ export class NativeLspService { serverName?: string, limit = 50, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -248,19 +262,9 @@ export class NativeLspService { includeDeclaration = false, limit = 200, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -302,19 +306,9 @@ export class NativeLspService { location: LspLocation, serverName?: string, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request('textDocument/hover', { @@ -341,19 +335,9 @@ export class NativeLspService { serverName?: string, limit = 200, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -414,19 +398,9 @@ export class NativeLspService { serverName?: string, limit = 50, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -476,19 +450,9 @@ export class NativeLspService { serverName?: string, limit = 50, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -539,19 +503,9 @@ export class NativeLspService { limit = 50, ): Promise { const targetServer = serverName ?? item.serverName; - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!targetServer || name === targetServer), - ); + const handles = this.getReadyHandles(targetServer); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -596,19 +550,9 @@ export class NativeLspService { limit = 50, ): Promise { const targetServer = serverName ?? item.serverName; - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!targetServer || name === targetServer), - ); + const handles = this.getReadyHandles(targetServer); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); const response = await handle.connection.request( @@ -651,21 +595,10 @@ export class NativeLspService { uri: string, serverName?: string, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); - + const handles = this.getReadyHandles(serverName); const allDiagnostics: LspDiagnostic[] = []; for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); @@ -709,21 +642,10 @@ export class NativeLspService { serverName?: string, limit = 100, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); - + const handles = this.getReadyHandles(serverName); const results: LspFileDiagnostics[] = []; for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); @@ -775,19 +697,9 @@ export class NativeLspService { serverName?: string, limit = 20, ): Promise { - const handles = Array.from( - this.serverManager.getHandles().entries(), - ).filter( - ([name, handle]) => - handle.status === 'READY' && - handle.connection && - (!serverName || name === serverName), - ); + const handles = this.getReadyHandles(serverName); for (const [name, handle] of handles) { - if (!handle.connection) { - continue; - } try { await this.serverManager.warmupTypescriptServer(handle); @@ -930,13 +842,6 @@ export class NativeLspService { fs.writeFileSync(filePath, lines.join('\n'), 'utf-8'); } - private isTypescriptServer(handle: LspServerHandle): boolean { - return ( - handle.config.name.includes('typescript') || - (handle.config.command?.includes('typescript') ?? false) - ); - } - private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; diff --git a/packages/cli/src/services/lsp/constants.ts b/packages/cli/src/services/lsp/constants.ts index b76c09aa7..e5874d9fc 100644 --- a/packages/cli/src/services/lsp/constants.ts +++ b/packages/cli/src/services/lsp/constants.ts @@ -25,9 +25,6 @@ export const DEFAULT_LSP_WARMUP_DELAY_MS = 150; /** Default timeout for command existence check in milliseconds */ export const DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS = 2000; -/** Default timeout for LSP server shutdown in milliseconds */ -export const DEFAULT_LSP_SHUTDOWN_TIMEOUT_MS = 5000; - // ============================================================================ // Retry Constants // ============================================================================ @@ -105,106 +102,3 @@ export const CODE_ACTION_KIND_LABELS: Record = { 'source.organizeImports': 'source.organizeImports', 'source.fixAll': 'source.fixAll', }; - -// ============================================================================ -// Language Detection -// ============================================================================ - -/** - * Common root marker files that indicate project type/language. - */ -export const COMMON_ROOT_MARKERS = [ - 'package.json', - 'tsconfig.json', - 'pyproject.toml', - 'go.mod', - 'Cargo.toml', - 'pom.xml', - 'build.gradle', - 'composer.json', - 'Gemfile', - 'mix.exs', - 'deno.json', -] as const; - -/** - * Mapping from root marker files to programming languages. - */ -export const MARKER_TO_LANGUAGE: Record = { - 'package.json': 'javascript', - 'tsconfig.json': 'typescript', - 'pyproject.toml': 'python', - 'go.mod': 'go', - 'Cargo.toml': 'rust', - 'pom.xml': 'java', - 'build.gradle': 'java', - 'composer.json': 'php', - Gemfile: 'ruby', - '*.sln': 'csharp', - 'mix.exs': 'elixir', - 'deno.json': 'deno', -}; - -/** - * Default mapping from file extensions to language identifiers. - */ -export const DEFAULT_EXTENSION_TO_LANGUAGE: Record = { - js: 'javascript', - ts: 'typescript', - jsx: 'javascriptreact', - tsx: 'typescriptreact', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - cpp: 'cpp', - c: 'c', - php: 'php', - rb: 'ruby', - cs: 'csharp', - vue: 'vue', - svelte: 'svelte', - html: 'html', - css: 'css', - json: 'json', - yaml: 'yaml', - yml: 'yaml', -}; - -/** - * Glob patterns to exclude when detecting languages. - */ -export const LANGUAGE_DETECTION_EXCLUDE_PATTERNS = [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', -] as const; - -// ============================================================================ -// Default Limits for LSP Operations -// ============================================================================ - -/** Default limit for workspace symbol search results */ -export const DEFAULT_LSP_WORKSPACE_SYMBOL_LIMIT = 50; - -/** Default limit for definition/implementation results */ -export const DEFAULT_LSP_DEFINITION_LIMIT = 50; - -/** Default limit for reference results */ -export const DEFAULT_LSP_REFERENCE_LIMIT = 200; - -/** Default limit for document symbol results */ -export const DEFAULT_LSP_DOCUMENT_SYMBOL_LIMIT = 200; - -/** Default limit for call hierarchy results */ -export const DEFAULT_LSP_CALL_HIERARCHY_LIMIT = 50; - -/** Default limit for diagnostics results */ -export const DEFAULT_LSP_DIAGNOSTICS_LIMIT = 100; - -/** Default limit for code action results */ -export const DEFAULT_LSP_CODE_ACTION_LIMIT = 20; - -/** Maximum number of files to scan during language detection */ -export const DEFAULT_LSP_LANGUAGE_DETECTION_FILE_LIMIT = 1000; From a21aeecd0f087bf29b5973f3086b55e1c9567149 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 25 Jan 2026 23:06:18 +0800 Subject: [PATCH 11/15] feat(core/prompts): add LSP tool usage instructions to system prompt - Add comprehensive LSP tool guidance in the Tool Usage section - Document all LSP operations with their required parameters - Clarify that workspaceSymbol only requires query, not filePath/line/character - Emphasize using LSP directly instead of grep for code intelligence queries --- packages/core/src/core/prompts.ts | 13 +++++++++++++ packages/core/src/tools/tool-names.ts | 5 ----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 8d3ff4683..94d54b911 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -258,6 +258,19 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t - **Subagent Delegation:** When doing file search, prefer to use the '${ToolNames.TASK}' tool in order to reduce context usage. You should proactively use the '${ToolNames.TASK}' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the '${ToolNames.MEMORY}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the '${ToolNames.LSP}' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use '${ToolNames.GREP}' or '${ToolNames.GLOB}' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index aa3687aba..7976ba461 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,7 +25,6 @@ export const ToolNames = { WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', LS: 'list_directory', - /** Unified LSP tool supporting all LSP operations. */ LSP: 'lsp', } as const; @@ -50,7 +49,6 @@ export const ToolDisplayNames = { WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', LS: 'ListFiles', - /** Unified LSP tool display name. */ LSP: 'Lsp', } as const; @@ -60,9 +58,6 @@ export const ToolDisplayNames = { export const ToolNamesMigration = { search_file_content: ToolNames.GREP, // Legacy name from grep tool replace: ToolNames.EDIT, // Legacy name from edit tool - // Legacy LSP tools now use unified LSP tool with operation parameter - go_to_definition: ToolNames.LSP, - find_references: ToolNames.LSP, } as const; // Migration from old tool display names to new tool display names From e3e2f52a12ab7a2fd8c99be8b565746bd146a959 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 25 Jan 2026 23:49:23 +0800 Subject: [PATCH 12/15] feat(core): fix ci:test --- .../core/__snapshots__/prompts.test.ts.snap | 195 ++++++++++++++++++ packages/core/src/tools/lsp.test.ts | 12 +- 2 files changed, 206 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 0c0b6c6ad..6d1eff9fc 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -124,6 +124,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -343,6 +356,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -572,6 +598,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -786,6 +825,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1000,6 +1052,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1214,6 +1279,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1428,6 +1506,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1642,6 +1733,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1856,6 +1960,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2070,6 +2187,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2307,6 +2437,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2604,6 +2747,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2841,6 +2997,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -3134,6 +3303,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -3348,6 +3530,19 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. +- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: + - goToDefinition: Find where a symbol is defined (requires filePath, line, character) + - findReferences: Find all references to a symbol (requires filePath, line, character) + - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) + - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) + - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) + - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) + - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) + - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) + - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) + - diagnostics: Get errors/warnings for a file (requires filePath only) + - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) + IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/core/src/tools/lsp.test.ts b/packages/core/src/tools/lsp.test.ts index 74b5c4067..a74f5453c 100644 --- a/packages/core/src/tools/lsp.test.ts +++ b/packages/core/src/tools/lsp.test.ts @@ -951,6 +951,9 @@ describe('LspTool', () => { 'prepareCallHierarchy', 'incomingCalls', 'outgoingCalls', + 'diagnostics', + 'workspaceDiagnostics', + 'codeActions', ]; expect(schema.properties?.operation?.enum).toEqual(expectedOperations); }); @@ -986,7 +989,14 @@ describe('LspTool', () => { const properties = Object.keys(schema.properties ?? {}); // Our extensions beyond Claude Code - const extensionProperties = ['serverName', 'limit']; + const extensionProperties = [ + 'serverName', + 'limit', + 'endLine', + 'endCharacter', + 'diagnostics', + 'codeActionKinds', + ]; // All properties should be either core or documented extensions const knownProperties = [ From 7ec79e6806fb85de833bb273a6e2e980fe962a39 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 27 Jan 2026 11:21:29 +0800 Subject: [PATCH 13/15] feat(lsp): support for loading lspServers configurations from extensions --- docs/users/features/lsp.md | 40 +---- package-lock.json | 168 ++++-------------- .../src/services/lsp/LspConfigLoader.test.ts | 78 ++++++++ .../cli/src/services/lsp/LspConfigLoader.ts | 152 ++++++++++++---- .../cli/src/services/lsp/NativeLspService.ts | 21 ++- .../src/extension/claude-converter.test.ts | 20 +++ .../core/src/extension/claude-converter.ts | 9 +- .../core/src/extension/extensionManager.ts | 1 + 8 files changed, 278 insertions(+), 211 deletions(-) create mode 100644 packages/cli/src/services/lsp/LspConfigLoader.test.ts diff --git a/docs/users/features/lsp.md b/docs/users/features/lsp.md index bf6266fbc..c0ed7da9a 100644 --- a/docs/users/features/lsp.md +++ b/docs/users/features/lsp.md @@ -38,7 +38,7 @@ You need to have the language server for your programming language installed: ### .lsp.json File -You can configure language servers using a `.lsp.json` file in your project root. This follows the [Claude Code plugin LSP configuration format](https://code.claude.com/docs/en/plugins-reference#lsp-servers). +You can configure language servers using a `.lsp.json` file in your project root. This uses the language-keyed format described in the [Claude Code plugin LSP configuration reference](https://code.claude.com/docs/en/plugins-reference#lsp-servers). **Basic format:** @@ -57,28 +57,6 @@ You can configure language servers using a `.lsp.json` file in your project root } ``` -**Extended format with `languageServers` wrapper:** - -```json -{ - "languageServers": { - "typescript-language-server": { - "languages": [ - "typescript", - "javascript", - "typescriptreact", - "javascriptreact" - ], - "command": "typescript-language-server", - "args": ["--stdio"], - "transport": "stdio", - "initializationOptions": {}, - "settings": {} - } - } -} -``` - ### Configuration Options #### Required Fields @@ -346,7 +324,7 @@ Or check the LSP debugging guide at `packages/cli/LSP_DEBUGGING_GUIDE.md`. ## Claude Code Compatibility -Qwen Code supports Claude Code-style `.lsp.json` configuration files as defined in the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference#lsp-servers). If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes. +Qwen Code supports Claude Code-style `.lsp.json` configuration files in the language-keyed format defined in the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference#lsp-servers). If you're migrating from Claude Code, use the language-as-key layout in your configuration. ### Configuration Format @@ -364,19 +342,7 @@ The recommended format follows Claude Code's specification: } ``` -The `languageServers` wrapper format is also supported: - -```json -{ - "languageServers": { - "gopls": { - "languages": ["go"], - "command": "gopls", - "args": ["serve"] - } - } -} -``` +Claude Code LSP plugins can also supply `lspServers` in `plugin.json` (or a referenced `.lsp.json`). Qwen Code loads those configs when the extension is enabled, and they must use the same language-keyed format. ## Best Practices diff --git a/package-lock.json b/package-lock.json index e3e7405e1..17963ad94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -596,6 +596,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -619,6 +620,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1933,6 +1935,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2447,7 +2450,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -2490,7 +2492,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2512,7 +2513,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2534,7 +2534,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2556,7 +2555,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2578,7 +2576,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2600,7 +2597,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2622,7 +2618,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2644,7 +2639,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2666,7 +2660,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2688,7 +2681,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2710,7 +2702,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2732,7 +2723,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2754,7 +2744,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2770,7 +2759,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -2784,8 +2772,7 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -3428,6 +3415,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3878,19 +3866,13 @@ "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -3918,6 +3900,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3928,6 +3911,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4133,6 +4117,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4907,6 +4892,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5314,8 +5300,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -5893,6 +5878,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6645,7 +6631,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7770,6 +7755,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8304,7 +8290,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8366,7 +8351,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8376,7 +8360,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8386,7 +8369,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -8577,7 +8559,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8596,7 +8577,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8605,15 +8585,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9602,8 +9580,7 @@ "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/import-fresh": { "version": "3.3.1", @@ -9700,6 +9677,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10695,6 +10673,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11679,7 +11658,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12980,8 +12958,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -13141,6 +13118,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13654,6 +13632,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13664,6 +13643,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13697,6 +13677,7 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -14338,29 +14319,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sass": { - "version": "1.94.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", - "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -15759,6 +15717,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15938,7 +15897,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15946,6 +15906,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16140,6 +16101,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16448,7 +16410,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16504,6 +16465,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16617,6 +16579,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16630,6 +16593,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17137,6 +17101,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -17317,6 +17282,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17329,7 +17295,6 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", - "@types/prompts": "^2.4.9", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -17373,6 +17338,7 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", @@ -17416,6 +17382,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -18062,6 +18029,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -18473,6 +18441,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19235,6 +19204,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -19732,6 +19702,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20858,6 +20829,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21521,27 +21493,6 @@ "zod": "^3.25 || ^4" } }, - "packages/vscode-ide-companion/node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "packages/vscode-ide-companion/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "packages/vscode-ide-companion/node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -21584,13 +21535,6 @@ "node": ">= 0.6" } }, - "packages/vscode-ide-companion/node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -21671,40 +21615,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "packages/vscode-ide-companion/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "packages/vscode-ide-companion/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "packages/vscode-ide-companion/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "packages/vscode-ide-companion/node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/packages/cli/src/services/lsp/LspConfigLoader.test.ts b/packages/cli/src/services/lsp/LspConfigLoader.test.ts new file mode 100644 index 000000000..2207aa5ea --- /dev/null +++ b/packages/cli/src/services/lsp/LspConfigLoader.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import mock from 'mock-fs'; +import { LspConfigLoader } from './LspConfigLoader.js'; +import type { Extension } from '@qwen-code/qwen-code-core'; + +describe('LspConfigLoader extension configs', () => { + const workspaceRoot = '/workspace'; + const extensionPath = '/extensions/ts-plugin'; + + afterEach(() => { + mock.restore(); + }); + + it('loads inline lspServers config from extension', async () => { + const loader = new LspConfigLoader(workspaceRoot); + const extension = { + name: 'ts-plugin', + path: extensionPath, + config: { + lspServers: { + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + extensionToLanguage: { + '.ts': 'typescript', + }, + }, + }, + }, + } as Extension; + + const configs = await loader.loadExtensionConfigs([extension]); + + expect(configs).toHaveLength(1); + expect(configs[0]?.languages).toEqual(['typescript']); + expect(configs[0]?.command).toBe('typescript-language-server'); + expect(configs[0]?.args).toEqual(['--stdio']); + }); + + it('loads lspServers config from referenced file and hydrates variables', async () => { + mock({ + [extensionPath]: { + '.lsp.json': JSON.stringify({ + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + env: { + EXT_ROOT: '${CLAUDE_PLUGIN_ROOT}', + }, + extensionToLanguage: { + '.ts': 'typescript', + }, + }, + }), + }, + }); + + const loader = new LspConfigLoader(workspaceRoot); + const extension = { + name: 'ts-plugin', + path: extensionPath, + config: { + lspServers: './.lsp.json', + }, + } as Extension; + + const configs = await loader.loadExtensionConfigs([extension]); + + expect(configs).toHaveLength(1); + expect(configs[0]?.env?.EXT_ROOT).toBe(extensionPath); + }); +}); diff --git a/packages/cli/src/services/lsp/LspConfigLoader.ts b/packages/cli/src/services/lsp/LspConfigLoader.ts index a0c5cd08d..c82f5323a 100644 --- a/packages/cli/src/services/lsp/LspConfigLoader.ts +++ b/packages/cli/src/services/lsp/LspConfigLoader.ts @@ -7,6 +7,11 @@ import * as fs from 'node:fs'; import * as path from 'path'; import { pathToFileURL } from 'url'; +import { + recursivelyHydrateStrings, + type Extension, + type JsonValue, +} from '@qwen-code/qwen-code-core'; import type { LspInitializationOptions, LspServerConfig, @@ -18,9 +23,7 @@ export class LspConfigLoader { /** * Load user .lsp.json configuration. - * Supports two official formats: - * 1. Basic format: { "language": { "command": "...", "extensionToLanguage": {...} } } - * 2. LanguageServers format: { "languageServers": { "server-name": { "languages": [...], ... } } } + * Supports basic format: { "language": { "command": "...", "extensionToLanguage": {...} } } */ async loadUserConfigs(): Promise { const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); @@ -39,10 +42,76 @@ export class LspConfigLoader { } /** - * Merge configs: built-in presets + user configs + compatibility layer + * Load LSP configurations declared by extensions (Claude plugins). + */ + async loadExtensionConfigs(extensions: Extension[]): Promise { + const configs: LspServerConfig[] = []; + + for (const extension of extensions) { + const lspServers = extension.config?.lspServers; + if (!lspServers) { + continue; + } + + const originBase = `extension ${extension.name}`; + if (typeof lspServers === 'string') { + const configPath = this.resolveExtensionConfigPath( + extension.path, + lspServers, + ); + if (!fs.existsSync(configPath)) { + console.warn( + `LSP config not found for ${originBase}: ${configPath}`, + ); + continue; + } + + try { + const configContent = fs.readFileSync(configPath, 'utf-8'); + const data = JSON.parse(configContent) as JsonValue; + const hydrated = this.hydrateExtensionLspConfig( + data, + extension.path, + ); + configs.push( + ...this.parseConfigSource( + hydrated, + `${originBase} (${configPath})`, + ), + ); + } catch (error) { + console.warn( + `Failed to load extension LSP config from ${configPath}:`, + error, + ); + } + } else if (this.isRecord(lspServers)) { + const hydrated = this.hydrateExtensionLspConfig( + lspServers as JsonValue, + extension.path, + ); + configs.push( + ...this.parseConfigSource( + hydrated, + `${originBase} (lspServers)`, + ), + ); + } else { + console.warn( + `LSP config for ${originBase} must be an object or a JSON file path.`, + ); + } + } + + return configs; + } + + /** + * Merge configs: built-in presets + extension configs + user configs */ mergeConfigs( detectedLanguages: string[], + extensionConfigs: LspServerConfig[], userConfigs: LspServerConfig[], ): LspServerConfig[] { // Built-in preset configurations @@ -51,17 +120,22 @@ export class LspConfigLoader { // Merge configs, user configs take priority const mergedConfigs = [...presets]; - for (const userConfig of userConfigs) { - // Find if there's a preset with the same name, if so replace it - const existingIndex = mergedConfigs.findIndex( - (c) => c.name === userConfig.name, - ); - if (existingIndex !== -1) { - mergedConfigs[existingIndex] = userConfig; - } else { - mergedConfigs.push(userConfig); + const applyConfigs = (configs: LspServerConfig[]) => { + for (const config of configs) { + // Find if there's a preset with the same name, if so replace it + const existingIndex = mergedConfigs.findIndex( + (c) => c.name === config.name, + ); + if (existingIndex !== -1) { + mergedConfigs[existingIndex] = config; + } else { + mergedConfigs.push(config); + } } - } + }; + + applyConfigs(extensionConfigs); + applyConfigs(userConfigs); return mergedConfigs; } @@ -155,7 +229,7 @@ export class LspConfigLoader { /** * Parse configuration source and extract server configs. - * Detects format based on presence of 'languageServers' key. + * Expects basic format keyed by language identifier. */ private parseConfigSource( source: unknown, @@ -167,31 +241,15 @@ export class LspConfigLoader { const configs: LspServerConfig[] = []; - // Determine format: languageServers wrapper vs basic format - const hasLanguageServersWrapper = this.isRecord(source['languageServers']); - const serverMap = hasLanguageServersWrapper - ? (source['languageServers'] as Record) - : source; - - for (const [key, spec] of Object.entries(serverMap)) { + for (const [key, spec] of Object.entries(source)) { if (!this.isRecord(spec)) { continue; } - // In basic format: key is language name, server name comes from command - // In languageServers format: key is server name, languages come from 'languages' array - const isBasicFormat = !hasLanguageServersWrapper && !spec['languages']; - - const languages = isBasicFormat - ? [key] - : (this.normalizeStringArray(spec['languages']) ?? - (typeof spec['languages'] === 'string' ? [spec['languages']] : [])); - - const name = isBasicFormat - ? typeof spec['command'] === 'string' - ? spec['command'] - : key - : key; + // In basic format: key is language name, server name comes from command. + const languages = [key]; + const name = + typeof spec['command'] === 'string' ? (spec['command'] as string) : key; const config = this.buildServerConfig(name, languages, spec, origin); if (config) { @@ -202,6 +260,28 @@ export class LspConfigLoader { return configs; } + private resolveExtensionConfigPath( + extensionPath: string, + configPath: string, + ): string { + return path.isAbsolute(configPath) + ? path.resolve(configPath) + : path.resolve(extensionPath, configPath); + } + + private hydrateExtensionLspConfig( + source: JsonValue, + extensionPath: string, + ): JsonValue { + return recursivelyHydrateStrings(source, { + extensionPath, + CLAUDE_PLUGIN_ROOT: extensionPath, + workspacePath: this.workspaceRoot, + '/': path.sep, + pathSeparator: path.sep, + }); + } + private buildServerConfig( name: string, languages: string[], diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index a7e12cfcf..a57ad3483 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -24,6 +24,7 @@ import type { LspSymbolInformation, LspTextEdit, LspWorkspaceEdit, + Extension, } from '@qwen-code/qwen-code-core'; import type { EventEmitter } from 'events'; import { LspConfigLoader } from './LspConfigLoader.js'; @@ -98,19 +99,35 @@ export class NativeLspService { // Detect languages in workspace const userConfigs = await this.configLoader.loadUserConfigs(); + const extensionConfigs = await this.configLoader.loadExtensionConfigs( + this.getActiveExtensions(), + ); const extensionOverrides = - this.configLoader.collectExtensionToLanguageOverrides(userConfigs); + this.configLoader.collectExtensionToLanguageOverrides([ + ...extensionConfigs, + ...userConfigs, + ]); const detectedLanguages = await this.languageDetector.detectLanguages(extensionOverrides); - // Merge configs: built-in presets + user .lsp.json + optional cclsp compatibility + // Merge configs: built-in presets + extension LSP configs + user .lsp.json const serverConfigs = this.configLoader.mergeConfigs( detectedLanguages, + extensionConfigs, userConfigs, ); this.serverManager.setServerConfigs(serverConfigs); } + private getActiveExtensions(): Extension[] { + const configWithExtensions = this.config as unknown as { + getActiveExtensions?: () => Extension[]; + }; + return typeof configWithExtensions.getActiveExtensions === 'function' + ? configWithExtensions.getActiveExtensions() + : []; + } + /** * Start all LSP servers */ diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 9e74b07bf..84510a98f 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -43,6 +43,26 @@ describe('convertClaudeToQwenConfig', () => { expect(result.mcpServers).toBeUndefined(); }); + it('should preserve lspServers configuration', () => { + const claudeConfig: ClaudePluginConfig = { + name: 'lsp-plugin', + version: '1.0.0', + lspServers: { + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + extensionToLanguage: { + '.ts': 'typescript', + }, + }, + }, + }; + + const result = convertClaudeToQwenConfig(claudeConfig); + + expect(result.lspServers).toEqual(claudeConfig.lspServers); + }); + it('should throw error for missing name', () => { const invalidConfig = { version: '1.0.0', diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 224a22b11..506bc804a 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -39,7 +39,7 @@ export interface ClaudePluginConfig { hooks?: string; mcpServers?: string | Record; outputStyles?: string | string[]; - lspServers?: string; + lspServers?: string | Record; } /** @@ -318,17 +318,12 @@ export function convertClaudeToQwenConfig( `[Claude Converter] Output styles are not yet supported in ${claudeConfig.name}`, ); } - if (claudeConfig.lspServers) { - console.warn( - `[Claude Converter] LSP servers are not yet supported in ${claudeConfig.name}`, - ); - } - // Direct field mapping - commands, skills, agents will be collected as folders return { name: claudeConfig.name, version: claudeConfig.version, mcpServers, + lspServers: claudeConfig.lspServers, }; } diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 921d34739..72ffdb3df 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -100,6 +100,7 @@ export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; + lspServers?: string | Record; contextFileName?: string | string[]; commands?: string | string[]; skills?: string | string[]; From 0dde6ce3ce86b0ad9d1bfeea1e54ef0b6b4d119e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 29 Jan 2026 00:54:59 +0800 Subject: [PATCH 14/15] refactor(lsp): restructure the LSP service import and test configuration --- packages/cli/src/config/config.test.ts | 25 +-- packages/cli/src/config/config.ts | 8 +- packages/cli/src/services/lsp/LspTypes.ts | 205 ------------------ .../core/__snapshots__/prompts.test.ts.snap | 195 ----------------- packages/core/src/core/prompts.ts | 13 -- packages/core/src/index.ts | 8 + .../src}/lsp/LspConfigLoader.test.ts | 16 +- .../src}/lsp/LspConfigLoader.ts | 24 +- .../src}/lsp/LspConnectionFactory.ts | 2 +- .../src}/lsp/LspLanguageDetector.ts | 6 +- .../src}/lsp/LspResponseNormalizer.ts | 2 +- .../src}/lsp/LspServerManager.ts | 10 +- .../src}/lsp/NativeLspClient.ts | 4 +- .../lsp/NativeLspService.integration.test.ts | 13 +- .../src}/lsp/NativeLspService.test.ts | 11 +- .../src}/lsp/NativeLspService.ts | 16 +- .../services => core/src}/lsp/constants.ts | 5 +- packages/core/src/lsp/types.ts | 162 ++++++++++++++ packages/core/src/tools/lsp.ts | 2 +- 19 files changed, 230 insertions(+), 497 deletions(-) delete mode 100644 packages/cli/src/services/lsp/LspTypes.ts rename packages/{cli/src/services => core/src}/lsp/LspConfigLoader.test.ts (82%) rename packages/{cli/src/services => core/src}/lsp/LspConfigLoader.ts (96%) rename packages/{cli/src/services => core/src}/lsp/LspConnectionFactory.ts (99%) rename packages/{cli/src/services => core/src}/lsp/LspLanguageDetector.ts (97%) rename packages/{cli/src/services => core/src}/lsp/LspResponseNormalizer.ts (99%) rename packages/{cli/src/services => core/src}/lsp/LspServerManager.ts (98%) rename packages/{cli/src/services => core/src}/lsp/NativeLspClient.ts (98%) rename packages/{cli/src/services => core/src}/lsp/NativeLspService.integration.test.ts (98%) rename packages/{cli/src/services => core/src}/lsp/NativeLspService.test.ts (90%) rename packages/{cli/src/services => core/src}/lsp/NativeLspService.ts (98%) rename packages/{cli/src/services => core/src}/lsp/constants.ts (96%) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 8c71b8d9d..67d3b114b 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -13,12 +13,12 @@ import { WriteFileTool, DEFAULT_QWEN_MODEL, OutputFormat, + NativeLspService, } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { NativeLspService } from '../services/lsp/NativeLspService.js'; const createNativeLspServiceInstance = () => ({ discoverAndPrepare: vi.fn(), @@ -38,26 +38,6 @@ const createNativeLspServiceInstance = () => ({ applyWorkspaceEdit: vi.fn().mockResolvedValue(false), }); -vi.mock('../services/lsp/NativeLspService.js', () => ({ - NativeLspService: vi.fn().mockImplementation(() => ({ - discoverAndPrepare: vi.fn(), - start: vi.fn(), - definitions: vi.fn().mockResolvedValue([]), - references: vi.fn().mockResolvedValue([]), - workspaceSymbols: vi.fn().mockResolvedValue([]), - hover: vi.fn().mockResolvedValue(null), - documentSymbols: vi.fn().mockResolvedValue([]), - implementations: vi.fn().mockResolvedValue([]), - prepareCallHierarchy: vi.fn().mockResolvedValue([]), - incomingCalls: vi.fn().mockResolvedValue([]), - outgoingCalls: vi.fn().mockResolvedValue([]), - diagnostics: vi.fn().mockResolvedValue([]), - workspaceDiagnostics: vi.fn().mockResolvedValue([]), - codeActions: vi.fn().mockResolvedValue([]), - applyWorkspaceEdit: vi.fn().mockResolvedValue(false), - })), -})); - vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi .fn() @@ -129,6 +109,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actualServer = await importOriginal(); return { ...actualServer, + NativeLspService: vi + .fn() + .mockImplementation(() => createNativeLspServiceInstance()), IdeClient: { getInstance: vi.fn().mockResolvedValue({ getConnectionStatus: vi.fn(), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a6613e73e..26509c141 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -27,6 +27,8 @@ import { EditTool, ShellTool, WriteFileTool, + NativeLspClient, + NativeLspService, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; @@ -45,8 +47,6 @@ import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; -import { NativeLspClient } from '../services/lsp/NativeLspClient.js'; -import { NativeLspService } from '../services/lsp/NativeLspService.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { buildWebSearchConfig } from './webSearch.js'; @@ -729,9 +729,7 @@ export async function loadCliConfig( await loadHierarchicalGeminiMemory( cwd, - settings.context?.loadMemoryFromIncludeDirectories - ? includeDirectories - : [], + settings.context?.loadFromIncludeDirectories ? includeDirectories : [], debugMode, fileService, extensionContextFilePaths, diff --git a/packages/cli/src/services/lsp/LspTypes.ts b/packages/cli/src/services/lsp/LspTypes.ts deleted file mode 100644 index 55b89cbef..000000000 --- a/packages/cli/src/services/lsp/LspTypes.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * LSP Service Type Definitions - * - * Centralized type definitions for the LSP service modules. - */ - -import type { ChildProcess } from 'node:child_process'; - -// ============================================================================ -// LSP Initialization Options -// ============================================================================ - -/** - * LSP server initialization options passed during the initialize request. - */ -export interface LspInitializationOptions { - [key: string]: unknown; -} - -// ============================================================================ -// LSP Socket Options -// ============================================================================ - -/** - * Socket connection options for TCP or Unix socket transport. - */ -export interface LspSocketOptions { - /** Host address for TCP connections */ - host?: string; - /** Port number for TCP connections */ - port?: number; - /** Path for Unix socket connections */ - path?: string; -} - -// ============================================================================ -// LSP Server Configuration -// ============================================================================ - -/** - * Configuration for an LSP server instance. - */ -export interface LspServerConfig { - /** Unique name identifier for the server */ - name: string; - /** List of languages this server handles */ - languages: string[]; - /** Command to start the server (required for stdio transport) */ - command?: string; - /** Command line arguments */ - args?: string[]; - /** Transport type: stdio, tcp, or socket */ - transport: 'stdio' | 'tcp' | 'socket'; - /** Environment variables for the server process */ - env?: Record; - /** LSP initialization options */ - initializationOptions?: LspInitializationOptions; - /** Server-specific settings */ - settings?: Record; - /** Custom file extension to language mappings */ - extensionToLanguage?: Record; - /** Root URI for the workspace */ - rootUri: string; - /** Workspace folder path */ - workspaceFolder?: string; - /** Startup timeout in milliseconds */ - startupTimeout?: number; - /** Shutdown timeout in milliseconds */ - shutdownTimeout?: number; - /** Whether to restart on crash */ - restartOnCrash?: boolean; - /** Maximum number of restart attempts */ - maxRestarts?: number; - /** Whether trusted workspace is required */ - trustRequired?: boolean; - /** Socket connection options */ - socket?: LspSocketOptions; -} - -// ============================================================================ -// LSP JSON-RPC Message -// ============================================================================ - -/** - * JSON-RPC message format for LSP communication. - */ -export interface JsonRpcMessage { - jsonrpc: string; - id?: number | string; - method?: string; - params?: unknown; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; -} - -// ============================================================================ -// LSP Connection Interface -// ============================================================================ - -/** - * Interface for LSP JSON-RPC connection. - */ -export interface LspConnectionInterface { - /** Start listening on a readable stream */ - listen: (readable: NodeJS.ReadableStream) => void; - /** Send a message to the server */ - send: (message: JsonRpcMessage) => void; - /** Register a notification handler */ - onNotification: (handler: (notification: JsonRpcMessage) => void) => void; - /** Register a request handler */ - onRequest: (handler: (request: JsonRpcMessage) => Promise) => void; - /** Send a request and wait for response */ - request: (method: string, params: unknown) => Promise; - /** Send initialize request */ - initialize: (params: unknown) => Promise; - /** Send shutdown request */ - shutdown: () => Promise; - /** End the connection */ - end: () => void; -} - -// ============================================================================ -// LSP Server Status -// ============================================================================ - -/** - * Status of an LSP server instance. - */ -export type LspServerStatus = - | 'NOT_STARTED' - | 'IN_PROGRESS' - | 'READY' - | 'FAILED'; - -// ============================================================================ -// LSP Server Handle -// ============================================================================ - -/** - * Handle for managing an LSP server instance. - */ -export interface LspServerHandle { - /** Server configuration */ - config: LspServerConfig; - /** Current status */ - status: LspServerStatus; - /** Active connection to the server */ - connection?: LspConnectionInterface; - /** Server process (for stdio transport) */ - process?: ChildProcess; - /** Error that caused failure */ - error?: Error; - /** Whether TypeScript server has been warmed up */ - warmedUp?: boolean; - /** Whether stop was explicitly requested */ - stopRequested?: boolean; - /** Number of restart attempts */ - restartAttempts?: number; - /** Lock to prevent concurrent startup attempts */ - startingPromise?: Promise; -} - -// ============================================================================ -// LSP Service Options -// ============================================================================ - -/** - * Options for NativeLspService constructor. - */ -export interface NativeLspServiceOptions { - /** Whether to require trusted workspace */ - requireTrustedWorkspace?: boolean; - /** Override workspace root path */ - workspaceRoot?: string; -} - -// ============================================================================ -// LSP Connection Result -// ============================================================================ - -/** - * Result from creating an LSP connection. - */ -export interface LspConnectionResult { - /** The JSON-RPC connection */ - connection: LspConnectionInterface; - /** Server process (for stdio transport) */ - process?: ChildProcess; - /** Shutdown the connection gracefully */ - shutdown: () => Promise; - /** Force exit the connection */ - exit: () => void; - /** Send initialize request */ - initialize: (params: unknown) => Promise; -} diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 6d1eff9fc..0c0b6c6ad 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -124,19 +124,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -356,19 +343,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -598,19 +572,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -825,19 +786,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1052,19 +1000,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1279,19 +1214,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1506,19 +1428,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1733,19 +1642,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -1960,19 +1856,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2187,19 +2070,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2437,19 +2307,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2747,19 +2604,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -2997,19 +2841,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -3303,19 +3134,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -3530,19 +3348,6 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **Subagent Delegation:** When doing file search, prefer to use the 'task' tool in order to reduce context usage. You should proactively use the 'task' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the 'lsp' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use 'grep_search' or 'glob' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 94d54b911..8d3ff4683 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -258,19 +258,6 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t - **Subagent Delegation:** When doing file search, prefer to use the '${ToolNames.TASK}' tool in order to reduce context usage. You should proactively use the '${ToolNames.TASK}' tool with specialized agents when the task at hand matches the agent's description. - **Remembering Facts:** Use the '${ToolNames.MEMORY}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. -- **LSP (Language Server Protocol):** When the '${ToolNames.LSP}' tool is available, you MUST use it as the PRIMARY tool for code intelligence queries. Do NOT use '${ToolNames.GREP}' or '${ToolNames.GLOB}' first - go directly to LSP. Supported operations: - - goToDefinition: Find where a symbol is defined (requires filePath, line, character) - - findReferences: Find all references to a symbol (requires filePath, line, character) - - hover: Get hover information (documentation, type info) for a symbol (requires filePath, line, character) - - documentSymbol: Get all symbols (functions, classes, variables) in a document (requires filePath only) - - workspaceSymbol: Search for symbols across the entire workspace (requires query only, NOT filePath/line/character) - - goToImplementation: Find implementations of an interface or abstract method (requires filePath, line, character) - - prepareCallHierarchy: Get call hierarchy item at a position (requires filePath, line, character) - - incomingCalls: Find all functions/methods that call the given function (requires callHierarchyItem) - - outgoingCalls: Find all functions/methods called by the given function (requires callHierarchyItem) - - diagnostics: Get errors/warnings for a file (requires filePath only) - - workspaceDiagnostics: Get all errors/warnings across the workspace (no parameters required) - IMPORTANT: When user asks "where is X defined?" without specifying a file, use workspaceSymbol with query="X" directly! ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42950ffb9..a9c091a08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -141,6 +141,14 @@ export * from './tools/exitPlanMode.js'; // Export LSP types and tools export * from './lsp/types.js'; +export * from './lsp/constants.js'; +export * from './lsp/LspConfigLoader.js'; +export * from './lsp/LspConnectionFactory.js'; +export * from './lsp/LspLanguageDetector.js'; +export * from './lsp/LspResponseNormalizer.js'; +export * from './lsp/LspServerManager.js'; +export * from './lsp/NativeLspClient.js'; +export * from './lsp/NativeLspService.js'; export * from './tools/lsp.js'; // MCP OAuth diff --git a/packages/cli/src/services/lsp/LspConfigLoader.test.ts b/packages/core/src/lsp/LspConfigLoader.test.ts similarity index 82% rename from packages/cli/src/services/lsp/LspConfigLoader.test.ts rename to packages/core/src/lsp/LspConfigLoader.test.ts index 2207aa5ea..9f0ee8548 100644 --- a/packages/cli/src/services/lsp/LspConfigLoader.test.ts +++ b/packages/core/src/lsp/LspConfigLoader.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import mock from 'mock-fs'; import { LspConfigLoader } from './LspConfigLoader.js'; -import type { Extension } from '@qwen-code/qwen-code-core'; +import type { Extension } from '../extension/extensionManager.js'; describe('LspConfigLoader extension configs', () => { const workspaceRoot = '/workspace'; @@ -20,9 +20,15 @@ describe('LspConfigLoader extension configs', () => { it('loads inline lspServers config from extension', async () => { const loader = new LspConfigLoader(workspaceRoot); const extension = { + id: 'ts-plugin', name: 'ts-plugin', + version: '1.0.0', + isActive: true, path: extensionPath, + contextFiles: [], config: { + name: 'ts-plugin', + version: '1.0.0', lspServers: { typescript: { command: 'typescript-language-server', @@ -63,9 +69,15 @@ describe('LspConfigLoader extension configs', () => { const loader = new LspConfigLoader(workspaceRoot); const extension = { + id: 'ts-plugin', name: 'ts-plugin', + version: '1.0.0', + isActive: true, path: extensionPath, + contextFiles: [], config: { + name: 'ts-plugin', + version: '1.0.0', lspServers: './.lsp.json', }, } as Extension; @@ -73,6 +85,6 @@ describe('LspConfigLoader extension configs', () => { const configs = await loader.loadExtensionConfigs([extension]); expect(configs).toHaveLength(1); - expect(configs[0]?.env?.EXT_ROOT).toBe(extensionPath); + expect(configs[0]?.env?.['EXT_ROOT']).toBe(extensionPath); }); }); diff --git a/packages/cli/src/services/lsp/LspConfigLoader.ts b/packages/core/src/lsp/LspConfigLoader.ts similarity index 96% rename from packages/cli/src/services/lsp/LspConfigLoader.ts rename to packages/core/src/lsp/LspConfigLoader.ts index c82f5323a..b091a957a 100644 --- a/packages/cli/src/services/lsp/LspConfigLoader.ts +++ b/packages/core/src/lsp/LspConfigLoader.ts @@ -9,14 +9,14 @@ import * as path from 'path'; import { pathToFileURL } from 'url'; import { recursivelyHydrateStrings, - type Extension, type JsonValue, -} from '@qwen-code/qwen-code-core'; +} from '../extension/variables.js'; +import type { Extension } from '../extension/extensionManager.js'; import type { LspInitializationOptions, LspServerConfig, LspSocketOptions, -} from './LspTypes.js'; +} from './types.js'; export class LspConfigLoader { constructor(private readonly workspaceRoot: string) {} @@ -44,7 +44,9 @@ export class LspConfigLoader { /** * Load LSP configurations declared by extensions (Claude plugins). */ - async loadExtensionConfigs(extensions: Extension[]): Promise { + async loadExtensionConfigs( + extensions: Extension[], + ): Promise { const configs: LspServerConfig[] = []; for (const extension of extensions) { @@ -60,19 +62,14 @@ export class LspConfigLoader { lspServers, ); if (!fs.existsSync(configPath)) { - console.warn( - `LSP config not found for ${originBase}: ${configPath}`, - ); + console.warn(`LSP config not found for ${originBase}: ${configPath}`); continue; } try { const configContent = fs.readFileSync(configPath, 'utf-8'); const data = JSON.parse(configContent) as JsonValue; - const hydrated = this.hydrateExtensionLspConfig( - data, - extension.path, - ); + const hydrated = this.hydrateExtensionLspConfig(data, extension.path); configs.push( ...this.parseConfigSource( hydrated, @@ -91,10 +88,7 @@ export class LspConfigLoader { extension.path, ); configs.push( - ...this.parseConfigSource( - hydrated, - `${originBase} (lspServers)`, - ), + ...this.parseConfigSource(hydrated, `${originBase} (lspServers)`), ); } else { console.warn( diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/core/src/lsp/LspConnectionFactory.ts similarity index 99% rename from packages/cli/src/services/lsp/LspConnectionFactory.ts rename to packages/core/src/lsp/LspConnectionFactory.ts index 84b23878d..dfcecd86d 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/core/src/lsp/LspConnectionFactory.ts @@ -7,7 +7,7 @@ import * as cp from 'node:child_process'; import * as net from 'node:net'; import { DEFAULT_LSP_REQUEST_TIMEOUT_MS } from './constants.js'; -import type { JsonRpcMessage } from './LspTypes.js'; +import type { JsonRpcMessage } from './types.js'; interface PendingRequest { resolve: (value: unknown) => void; diff --git a/packages/cli/src/services/lsp/LspLanguageDetector.ts b/packages/core/src/lsp/LspLanguageDetector.ts similarity index 97% rename from packages/cli/src/services/lsp/LspLanguageDetector.ts rename to packages/core/src/lsp/LspLanguageDetector.ts index 863332867..9c3f96e73 100644 --- a/packages/cli/src/services/lsp/LspLanguageDetector.ts +++ b/packages/core/src/lsp/LspLanguageDetector.ts @@ -14,10 +14,8 @@ import * as fs from 'node:fs'; import * as path from 'path'; import { globSync } from 'glob'; -import type { - WorkspaceContext, - FileDiscoveryService, -} from '@qwen-code/qwen-code-core'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; /** * Extension to language ID mapping diff --git a/packages/cli/src/services/lsp/LspResponseNormalizer.ts b/packages/core/src/lsp/LspResponseNormalizer.ts similarity index 99% rename from packages/cli/src/services/lsp/LspResponseNormalizer.ts rename to packages/core/src/lsp/LspResponseNormalizer.ts index a9720a8a4..9a9a478c0 100644 --- a/packages/cli/src/services/lsp/LspResponseNormalizer.ts +++ b/packages/core/src/lsp/LspResponseNormalizer.ts @@ -27,7 +27,7 @@ import type { LspSymbolInformation, LspTextEdit, LspWorkspaceEdit, -} from '@qwen-code/qwen-code-core'; +} from './types.js'; import { CODE_ACTION_KIND_LABELS, DIAGNOSTIC_SEVERITY_LABELS, diff --git a/packages/cli/src/services/lsp/LspServerManager.ts b/packages/core/src/lsp/LspServerManager.ts similarity index 98% rename from packages/cli/src/services/lsp/LspServerManager.ts rename to packages/core/src/lsp/LspServerManager.ts index 0bb129529..74b25f779 100644 --- a/packages/cli/src/services/lsp/LspServerManager.ts +++ b/packages/core/src/lsp/LspServerManager.ts @@ -4,11 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config as CoreConfig, - WorkspaceContext, - FileDiscoveryService, -} from '@qwen-code/qwen-code-core'; +import type { Config as CoreConfig } from '../config/config.js'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; import { spawn, type ChildProcess } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'path'; @@ -29,7 +27,7 @@ import type { LspServerHandle, LspServerStatus, LspSocketOptions, -} from './LspTypes.js'; +} from './types.js'; export interface LspServerManagerOptions { requireTrustedWorkspace: boolean; diff --git a/packages/cli/src/services/lsp/NativeLspClient.ts b/packages/core/src/lsp/NativeLspClient.ts similarity index 98% rename from packages/cli/src/services/lsp/NativeLspClient.ts rename to packages/core/src/lsp/NativeLspClient.ts index 890ed0755..8510ed876 100644 --- a/packages/cli/src/services/lsp/NativeLspClient.ts +++ b/packages/core/src/lsp/NativeLspClient.ts @@ -9,7 +9,7 @@ * by delegating all calls to NativeLspService. * * This class bridges the gap between the generic LspClient interface (defined in core) - * and the CLI-specific NativeLspService implementation. + * and the NativeLspService implementation. */ import type { @@ -28,7 +28,7 @@ import type { LspReference, LspSymbolInformation, LspWorkspaceEdit, -} from '@qwen-code/qwen-code-core'; +} from './types.js'; import type { NativeLspService } from './NativeLspService.js'; diff --git a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts b/packages/core/src/lsp/NativeLspService.integration.test.ts similarity index 98% rename from packages/cli/src/services/lsp/NativeLspService.integration.test.ts rename to packages/core/src/lsp/NativeLspService.integration.test.ts index f9fc6b106..cf737fbf7 100644 --- a/packages/cli/src/services/lsp/NativeLspService.integration.test.ts +++ b/packages/core/src/lsp/NativeLspService.integration.test.ts @@ -7,14 +7,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'events'; import { NativeLspService } from './NativeLspService.js'; -import type { - Config as CoreConfig, - WorkspaceContext, - FileDiscoveryService, - IdeContextStore, - LspLocation, - LspDiagnostic, -} from '@qwen-code/qwen-code-core'; +import type { Config as CoreConfig } from '../config/config.js'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { IdeContextStore } from '../ide/ideContext.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; +import type { LspDiagnostic, LspLocation } from './types.js'; /** * Mock LSP server responses for integration testing. diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/core/src/lsp/NativeLspService.test.ts similarity index 90% rename from packages/cli/src/services/lsp/NativeLspService.test.ts rename to packages/core/src/lsp/NativeLspService.test.ts index 553581d29..218f2e3c7 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/core/src/lsp/NativeLspService.test.ts @@ -4,14 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { describe, beforeEach, expect, test } from 'vitest'; import { NativeLspService } from './NativeLspService.js'; import { EventEmitter } from 'events'; -import type { - Config as CoreConfig, - WorkspaceContext, - FileDiscoveryService, - IdeContextStore, -} from '@qwen-code/qwen-code-core'; +import type { Config as CoreConfig } from '../config/config.js'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { IdeContextStore } from '../ide/ideContext.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; // 模拟依赖项 class MockConfig { diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/core/src/lsp/NativeLspService.ts similarity index 98% rename from packages/cli/src/services/lsp/NativeLspService.ts rename to packages/core/src/lsp/NativeLspService.ts index a57ad3483..23447ad70 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/core/src/lsp/NativeLspService.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Config as CoreConfig } from '../config/config.js'; +import type { Extension } from '../extension/extensionManager.js'; +import type { IdeContextStore } from '../ide/ideContext.js'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; import type { - Config as CoreConfig, - WorkspaceContext, - FileDiscoveryService, - IdeContextStore, LspCallHierarchyIncomingCall, LspCallHierarchyItem, LspCallHierarchyOutgoingCall, @@ -24,19 +25,18 @@ import type { LspSymbolInformation, LspTextEdit, LspWorkspaceEdit, - Extension, -} from '@qwen-code/qwen-code-core'; +} from './types.js'; import type { EventEmitter } from 'events'; import { LspConfigLoader } from './LspConfigLoader.js'; import { LspLanguageDetector } from './LspLanguageDetector.js'; import { LspResponseNormalizer } from './LspResponseNormalizer.js'; import { LspServerManager } from './LspServerManager.js'; import type { + LspConnectionInterface, LspServerHandle, LspServerStatus, NativeLspServiceOptions, - LspConnectionInterface, -} from './LspTypes.js'; +} from './types.js'; import * as path from 'path'; import { fileURLToPath } from 'url'; import * as fs from 'node:fs'; diff --git a/packages/cli/src/services/lsp/constants.ts b/packages/core/src/lsp/constants.ts similarity index 96% rename from packages/cli/src/services/lsp/constants.ts rename to packages/core/src/lsp/constants.ts index e5874d9fc..04fa4bb31 100644 --- a/packages/cli/src/services/lsp/constants.ts +++ b/packages/core/src/lsp/constants.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - LspCodeActionKind, - LspDiagnosticSeverity, -} from '@qwen-code/qwen-code-core'; +import type { LspCodeActionKind, LspDiagnosticSeverity } from './types.js'; // ============================================================================ // Timeout Constants diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 780a45718..f7806fe12 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -359,3 +359,165 @@ export interface LspClient { serverName?: string, ): Promise; } + +// ============================================================================ +// LSP Service Types (migrated from cli) +// ============================================================================ + +import type { ChildProcess } from 'node:child_process'; + +/** + * LSP server initialization options passed during the initialize request. + */ +export interface LspInitializationOptions { + [key: string]: unknown; +} + +/** + * Socket connection options for TCP or Unix socket transport. + */ +export interface LspSocketOptions { + /** Host address for TCP connections */ + host?: string; + /** Port number for TCP connections */ + port?: number; + /** Path for Unix socket connections */ + path?: string; +} + +/** + * Configuration for an LSP server instance. + */ +export interface LspServerConfig { + /** Unique name identifier for the server */ + name: string; + /** List of languages this server handles */ + languages: string[]; + /** Command to start the server (required for stdio transport) */ + command?: string; + /** Command line arguments */ + args?: string[]; + /** Transport type: stdio, tcp, or socket */ + transport: 'stdio' | 'tcp' | 'socket'; + /** Environment variables for the server process */ + env?: Record; + /** LSP initialization options */ + initializationOptions?: LspInitializationOptions; + /** Server-specific settings */ + settings?: Record; + /** Custom file extension to language mappings */ + extensionToLanguage?: Record; + /** Root URI for the workspace */ + rootUri: string; + /** Workspace folder path */ + workspaceFolder?: string; + /** Startup timeout in milliseconds */ + startupTimeout?: number; + /** Shutdown timeout in milliseconds */ + shutdownTimeout?: number; + /** Whether to restart on crash */ + restartOnCrash?: boolean; + /** Maximum number of restart attempts */ + maxRestarts?: number; + /** Whether trusted workspace is required */ + trustRequired?: boolean; + /** Socket connection options */ + socket?: LspSocketOptions; +} + +/** + * JSON-RPC message format for LSP communication. + */ +export interface JsonRpcMessage { + jsonrpc: string; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +/** + * Interface for LSP JSON-RPC connection. + */ +export interface LspConnectionInterface { + /** Start listening on a readable stream */ + listen: (readable: NodeJS.ReadableStream) => void; + /** Send a message to the server */ + send: (message: JsonRpcMessage) => void; + /** Register a notification handler */ + onNotification: (handler: (notification: JsonRpcMessage) => void) => void; + /** Register a request handler */ + onRequest: (handler: (request: JsonRpcMessage) => Promise) => void; + /** Send a request and wait for response */ + request: (method: string, params: unknown) => Promise; + /** Send initialize request */ + initialize: (params: unknown) => Promise; + /** Send shutdown request */ + shutdown: () => Promise; + /** End the connection */ + end: () => void; +} + +/** + * Status of an LSP server instance. + */ +export type LspServerStatus = + | 'NOT_STARTED' + | 'IN_PROGRESS' + | 'READY' + | 'FAILED'; + +/** + * Handle for managing an LSP server instance. + */ +export interface LspServerHandle { + /** Server configuration */ + config: LspServerConfig; + /** Current status */ + status: LspServerStatus; + /** Active connection to the server */ + connection?: LspConnectionInterface; + /** Server process (for stdio transport) */ + process?: ChildProcess; + /** Error that caused failure */ + error?: Error; + /** Whether TypeScript server has been warmed up */ + warmedUp?: boolean; + /** Whether stop was explicitly requested */ + stopRequested?: boolean; + /** Number of restart attempts */ + restartAttempts?: number; + /** Lock to prevent concurrent startup attempts */ + startingPromise?: Promise; +} + +/** + * Options for NativeLspService constructor. + */ +export interface NativeLspServiceOptions { + /** Whether to require trusted workspace */ + requireTrustedWorkspace?: boolean; + /** Override workspace root path */ + workspaceRoot?: string; +} + +/** + * Result from creating an LSP connection. + */ +export interface LspConnectionResult { + /** The JSON-RPC connection */ + connection: LspConnectionInterface; + /** Server process (for stdio transport) */ + process?: ChildProcess; + /** Shutdown the connection gracefully */ + shutdown: () => Promise; + /** Force exit the connection */ + exit: () => void; + /** Send initialize request */ + initialize: (params: unknown) => Promise; +} diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts index 0a8fd0b76..27711a080 100644 --- a/packages/core/src/tools/lsp.ts +++ b/packages/core/src/tools/lsp.ts @@ -1018,7 +1018,7 @@ export class LspTool extends BaseDeclarativeTool { super( LspTool.Name, ToolDisplayNames.LSP, - 'Unified LSP operations for definitions, references, hover, symbols, call hierarchy, diagnostics, and code actions.', + 'Language Server Protocol (LSP) tool for code intelligence: definitions, references, hover, symbols, call hierarchy, diagnostics, and code actions.\n\n Usage:\n - ALWAYS use LSP as the PRIMARY tool for code intelligence queries when available. Do NOT use grep_search or glob first.\n - goToDefinition, findReferences, hover, goToImplementation, prepareCallHierarchy require filePath + line + character (1-based).\n - documentSymbol and diagnostics require filePath.\n - workspaceSymbol requires query (use when user asks "where is X defined?" without specifying a file).\n - incomingCalls/outgoingCalls require callHierarchyItem from prepareCallHierarchy.\n - workspaceDiagnostics needs no parameters.\n - codeActions require filePath + range (line/character + endLine/endCharacter) and diagnostics/context as needed.', Kind.Other, { type: 'object', From 9f3cfb361a6da4a5e65ca2dd2e05a7c8653af2ae Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 29 Jan 2026 14:22:54 +0800 Subject: [PATCH 15/15] chore(lsp): revert old code --- packages/cli/src/config/config.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 26509c141..d4752d4be 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -724,19 +724,6 @@ export async function loadCliConfig( .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); - // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version - const extensionContextFilePaths: string[] = []; - - await loadHierarchicalGeminiMemory( - cwd, - settings.context?.loadFromIncludeDirectories ? includeDirectories : [], - debugMode, - fileService, - extensionContextFilePaths, - trustedFolder, - settings.context?.importFormat || 'tree', - ); - // LSP configuration: enabled only via --experimental-lsp flag const lspEnabled = argv.experimentalLsp === true; let lspClient: LspClient | undefined;