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 逻辑,减少首次调用延迟