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] 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