mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat(lsp): Removes built-in LSP configuration options and improves configuration loading mechanism
- remove configuration options such as lsp.enabled, lsp.allowed, lsp.excluded, etc. from settings.json schema - Delete lspSettingsSchema.ts files and associated JSON schema definitions - Removed VS Code settings loading function, no longer merge. vscode/settings.json configuration - Updated LSP documentation to reflect new configurations and experimental flags -remove allow/exclude parameters in NativeLspService constructor - Create new LspConfigLoader classes to handle LSP configuration loading and merging - Updated debug guide to match the new configuration mechanism - Simplify loadCliConfig functions, remove startLsp options - Reconstruct the configuration loading process to remove duplicate configuration merge logic - Add LspConfigLoader classes to implement configuration parsing and merging functions
This commit is contained in:
parent
45e947dcbc
commit
8420386d14
33 changed files with 3064 additions and 3907 deletions
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 代理提供更丰富的代码理解能力。
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 调试功能
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<Config> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 服务器启动超时时间(毫秒)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
description:
|
||||
'Inline LSP server configuration (same format as .lsp.json).',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
useSmartEdit: {
|
||||
type: 'boolean',
|
||||
label: 'Use Smart Edit',
|
||||
|
|
|
|||
|
|
@ -247,7 +247,6 @@ export async function main() {
|
|||
argv,
|
||||
undefined,
|
||||
[],
|
||||
{ startLsp: false },
|
||||
);
|
||||
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
|
|
|
|||
458
packages/cli/src/services/lsp/LspConfigLoader.ts
Normal file
458
packages/cli/src/services/lsp/LspConfigLoader.ts
Normal file
|
|
@ -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<LspServerConfig[]> {
|
||||
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<string, string> {
|
||||
const overrides: Record<string, string> = {};
|
||||
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<string, unknown> = source;
|
||||
let usedLegacyFormat = false;
|
||||
|
||||
if (this.isRecord(source['languageServers'])) {
|
||||
serverMap = source['languageServers'] as Record<string, unknown>;
|
||||
} 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<string, unknown>,
|
||||
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<string, unknown>)
|
||||
: 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<string, unknown>): boolean {
|
||||
return Object.values(value).some(
|
||||
(entry) => this.isRecord(entry) && this.isNewFormatServerSpec(entry),
|
||||
);
|
||||
}
|
||||
|
||||
private isNewFormatServerSpec(value: Record<string, unknown>): 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<string, unknown> {
|
||||
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<string, string> | undefined {
|
||||
if (!this.isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
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<string, string> | undefined {
|
||||
if (!this.isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const mapping: Record<string, string> = {};
|
||||
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<string, unknown>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 连接
|
||||
|
|
|
|||
222
packages/cli/src/services/lsp/LspLanguageDetector.ts
Normal file
222
packages/cli/src/services/lsp/LspLanguageDetector.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
jsx: 'javascriptreact',
|
||||
tsx: 'typescriptreact',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
php: 'php',
|
||||
rb: 'ruby',
|
||||
cs: 'csharp',
|
||||
vue: 'vue',
|
||||
svelte: 'svelte',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
};
|
||||
|
||||
/**
|
||||
* Root marker file to language ID mapping
|
||||
*/
|
||||
const MARKER_TO_LANGUAGE: Record<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',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, string> = {},
|
||||
): Promise<string[]> {
|
||||
const extensionMap = this.getExtensionToLanguageMap(extensionOverrides);
|
||||
const extensions = Object.keys(extensionMap);
|
||||
const patterns =
|
||||
extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*'];
|
||||
|
||||
const files = new Set<string>();
|
||||
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<string, number>();
|
||||
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<string[]> {
|
||||
const markers = new Set<string>();
|
||||
|
||||
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, string>,
|
||||
): string | null {
|
||||
return extensionMap[ext] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension to language mapping with overrides applied
|
||||
*/
|
||||
private getExtensionToLanguageMap(
|
||||
extensionOverrides: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
911
packages/cli/src/services/lsp/LspResponseNormalizer.ts
Normal file
911
packages/cli/src/services/lsp/LspResponseNormalizer.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
const severityMap: Record<LspDiagnosticSeverity, number> = {
|
||||
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<string, unknown>;
|
||||
const location = itemObj['location'];
|
||||
if (!location || typeof location !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const locObj = location as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
const result: LspWorkspaceEdit = {};
|
||||
|
||||
// Handle changes (map of URI to TextEdit[])
|
||||
if (editObj['changes'] && typeof editObj['changes'] === 'object') {
|
||||
const changes = editObj['changes'] as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const textDocument = docEditObj['textDocument'];
|
||||
if (!textDocument || typeof textDocument !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textDocObj = textDocument as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const uri = (itemObj['uri'] ??
|
||||
itemObj['targetUri'] ??
|
||||
(itemObj['target'] as Record<string, unknown>)?.['uri']) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const range = (itemObj['range'] ??
|
||||
itemObj['targetSelectionRange'] ??
|
||||
itemObj['targetRange'] ??
|
||||
(itemObj['target'] as Record<string, unknown>)?.['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<string, unknown>;
|
||||
const location = itemObj['location'] ?? itemObj['target'] ?? item;
|
||||
if (!location || typeof location !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const locationObj = location as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const start = rangeObj['start'];
|
||||
const end = rangeObj['end'];
|
||||
|
||||
if (
|
||||
!start ||
|
||||
typeof start !== 'object' ||
|
||||
!end ||
|
||||
typeof end !== 'object'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startObj = start as Record<string, unknown>;
|
||||
const endObj = end as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
// 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<string, unknown>): 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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
uri,
|
||||
serverName,
|
||||
results,
|
||||
limit,
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
713
packages/cli/src/services/lsp/LspServerManager.ts
Normal file
713
packages/cli/src/services/lsp/LspServerManager.ts
Normal file
|
|
@ -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<string, LspServerHandle> = 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<string, LspServerHandle> {
|
||||
return this.serverHandles;
|
||||
}
|
||||
|
||||
getStatus(): Map<string, LspServerStatus> {
|
||||
const statusMap = new Map<string, LspServerStatus>();
|
||||
for (const [name, handle] of Array.from(this.serverHandles)) {
|
||||
statusMap.set(name, handle.status);
|
||||
}
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
async startAll(): Promise<void> {
|
||||
for (const [name, handle] of Array.from(this.serverHandles)) {
|
||||
await this.startServer(name, handle);
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!handle.connection) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const shutdownPromise = handle.connection.shutdown();
|
||||
if (typeof handle.config.shutdownTimeout === 'number') {
|
||||
await Promise.race([
|
||||
shutdownPromise,
|
||||
new Promise<void>((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<string, string> | 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<ReturnType<typeof LspConnectionFactory.createSocketConnection>>
|
||||
> {
|
||||
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<LspConnectionResult> {
|
||||
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<void>((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<void> {
|
||||
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<string, string>,
|
||||
cwd?: string,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
205
packages/cli/src/services/lsp/LspTypes.ts
Normal file
205
packages/cli/src/services/lsp/LspTypes.ts
Normal file
|
|
@ -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<string, string>;
|
||||
/** LSP initialization options */
|
||||
initializationOptions?: LspInitializationOptions;
|
||||
/** Server-specific settings */
|
||||
settings?: Record<string, unknown>;
|
||||
/** Custom file extension to language mappings */
|
||||
extensionToLanguage?: Record<string, string>;
|
||||
/** 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<unknown>) => void;
|
||||
/** Send a request and wait for response */
|
||||
request: (method: string, params: unknown) => Promise<unknown>;
|
||||
/** Send initialize request */
|
||||
initialize: (params: unknown) => Promise<unknown>;
|
||||
/** Send shutdown request */
|
||||
shutdown: () => Promise<void>;
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<void>;
|
||||
/** Force exit the connection */
|
||||
exit: () => void;
|
||||
/** Send initialize request */
|
||||
initialize: (params: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
210
packages/cli/src/services/lsp/constants.ts
Normal file
210
packages/cli/src/services/lsp/constants.ts
Normal file
|
|
@ -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<number, string> = {
|
||||
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<number, LspDiagnosticSeverity> =
|
||||
{
|
||||
1: 'error',
|
||||
2: 'warning',
|
||||
3: 'information',
|
||||
4: 'hint',
|
||||
};
|
||||
|
||||
/**
|
||||
* Code action kind labels from LSP specification.
|
||||
*/
|
||||
export const CODE_ACTION_KIND_LABELS: Record<string, LspCodeActionKind> = {
|
||||
'': '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<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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default mapping from file extensions to language identifiers.
|
||||
*/
|
||||
export const DEFAULT_EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
jsx: 'javascriptreact',
|
||||
tsx: 'typescriptreact',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
php: 'php',
|
||||
rb: 'ruby',
|
||||
cs: 'csharp',
|
||||
vue: 'vue',
|
||||
svelte: 'svelte',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
|
@ -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<string, MCPServerConfig>;
|
||||
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<string, MCPServerConfig> | 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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<LspDiagnostic[]>;
|
||||
diagnostics(uri: string, serverName?: string): Promise<LspDiagnostic[]>;
|
||||
|
||||
/**
|
||||
* Get diagnostics for all open documents in the workspace.
|
||||
|
|
|
|||
|
|
@ -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<ToolResult> {
|
||||
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<LspClient, 'workspaceSymbols'>,
|
||||
): Promise<ResolvedTarget> {
|
||||
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<LspFindReferencesParams, ToolResult> {
|
||||
return new LspFindReferencesInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ToolResult> {
|
||||
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<LspClient, 'workspaceSymbols'>,
|
||||
): Promise<ResolvedTarget> {
|
||||
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<LspGoToDefinitionParams, ToolResult> {
|
||||
return new LspGoToDefinitionInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ToolResult> {
|
||||
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<LspWorkspaceSymbolParams, ToolResult> {
|
||||
return new LspWorkspaceSymbolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<LspSymbolInformation[]>;
|
||||
definitions(location, serverName, limit): Promise<LspDefinition[]>;
|
||||
references(
|
||||
location,
|
||||
serverName,
|
||||
includeDeclaration,
|
||||
limit,
|
||||
): Promise<LspReference[]>;
|
||||
|
||||
// 新增方法
|
||||
hover(location, serverName): Promise<LspHoverResult | null>;
|
||||
documentSymbols(uri, serverName, limit): Promise<LspSymbolInformation[]>;
|
||||
implementations(location, serverName, limit): Promise<LspDefinition[]>;
|
||||
prepareCallHierarchy(location, serverName): Promise<LspCallHierarchyItem[]>;
|
||||
incomingCalls(
|
||||
item,
|
||||
serverName,
|
||||
limit,
|
||||
): Promise<LspCallHierarchyIncomingCall[]>;
|
||||
outgoingCalls(
|
||||
item,
|
||||
serverName,
|
||||
limit,
|
||||
): Promise<LspCallHierarchyOutgoingCall[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 逻辑,减少首次调用延迟
|
||||
Loading…
Add table
Add a link
Reference in a new issue