Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
d0fac54711 chore(release): v0.7.2 2026-01-20 09:10:43 +00:00
32 changed files with 16 additions and 9469 deletions

View File

@@ -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
}

View File

@@ -1,147 +0,0 @@
# Qwen Code CLI LSP 集成实现方案分析
## 1. 项目概述
本方案旨在将 LSPLanguage 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 代理提供更丰富的代码理解能力。

View File

@@ -287,26 +287,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
>
> **Security Note for MCP servers:** These settings use simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
#### lsp
> [!warning]
> **Experimental Feature**: LSP support is currently experimental and disabled by default. Enable it using the `--experimental-lsp` command line flag.
Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details.
| Setting | Type | Description | Default |
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ----------- |
| `lsp.enabled` | boolean | Enable/disable LSP support. 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.
#### security
| Setting | Type | Description | Default |
@@ -507,7 +487,6 @@ Arguments passed directly when running the CLI can override other configurations
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
| `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |

View File

@@ -8,7 +8,6 @@ export default {
},
'approval-mode': 'Approval Mode',
mcp: 'MCP',
lsp: 'LSP (Language Server Protocol)',
'token-caching': 'Token Caching',
sandbox: 'Sandboxing',
language: 'i18n',

View File

@@ -1,383 +0,0 @@
# Language Server Protocol (LSP) Support
Qwen Code provides native Language Server Protocol (LSP) support, enabling advanced code intelligence features like go-to-definition, find references, diagnostics, and code actions. This integration allows the AI agent to understand your code more deeply and provide more accurate assistance.
## Overview
LSP support in Qwen Code works by connecting to language servers that understand your code. When you work with TypeScript, Python, Go, or other supported languages, Qwen Code can automatically start the appropriate language server and use it to:
- Navigate to symbol definitions
- Find all references to a symbol
- Get hover information (documentation, type info)
- View diagnostic messages (errors, warnings)
- Access code actions (quick fixes, refactorings)
- Analyze call hierarchies
## Quick Start
LSP is enabled by default in Qwen Code. For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system.
### Prerequisites
You need to have the language server for your programming language installed:
| Language | Language Server | Install Command |
|----------|----------------|-----------------|
| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` |
| Python | pylsp | `pip install python-lsp-server` |
| Go | gopls | `go install golang.org/x/tools/gopls@latest` |
| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) |
## Configuration
### Settings
You can configure LSP behavior in your `settings.json`:
```json
{
"lsp": {
"enabled": true,
"autoDetect": true,
"serverTimeout": 10000,
"allowed": [],
"excluded": []
}
}
```
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `lsp.enabled` | boolean | `true` | Enable/disable LSP support |
| `lsp.autoDetect` | boolean | `true` | Automatically detect and start language servers |
| `lsp.serverTimeout` | number | `10000` | Server startup timeout in milliseconds |
| `lsp.allowed` | string[] | `[]` | Allow only these servers (empty = allow all) |
| `lsp.excluded` | string[] | `[]` | Exclude these servers from starting |
### Custom Language Servers
You can configure custom language servers using a `.lsp.json` file in your project root:
```json
{
"languageServers": {
"my-custom-lsp": {
"languages": ["mylang"],
"command": "my-lsp-server",
"args": ["--stdio"],
"transport": "stdio",
"initializationOptions": {},
"settings": {}
}
}
}
```
#### Configuration Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `languages` | string[] | Yes | Languages this server handles |
| `command` | string | Yes* | Command to start the server |
| `args` | string[] | No | Command line arguments |
| `transport` | string | No | Transport type: `stdio` (default), `tcp`, or `socket` |
| `env` | object | No | Environment variables |
| `initializationOptions` | object | No | LSP initialization options |
| `settings` | object | No | Server settings |
| `workspaceFolder` | string | No | Override workspace folder |
| `startupTimeout` | number | No | Startup timeout in ms |
| `shutdownTimeout` | number | No | Shutdown timeout in ms |
| `restartOnCrash` | boolean | No | Auto-restart on crash |
| `maxRestarts` | number | No | Maximum restart attempts |
| `trustRequired` | boolean | No | Require trusted workspace |
*Required for `stdio` transport
#### TCP/Socket Transport
For servers that use TCP or Unix socket transport:
```json
{
"languageServers": {
"remote-lsp": {
"languages": ["custom"],
"transport": "tcp",
"socket": {
"host": "127.0.0.1",
"port": 9999
}
}
}
}
```
## Available LSP Operations
Qwen Code exposes LSP functionality through the unified `lsp` tool. Here are the available operations:
### Code Navigation
#### Go to Definition
Find where a symbol is defined.
```
Operation: goToDefinition
Parameters:
- filePath: Path to the file
- line: Line number (1-based)
- character: Column number (1-based)
```
#### Find References
Find all references to a symbol.
```
Operation: findReferences
Parameters:
- filePath: Path to the file
- line: Line number (1-based)
- character: Column number (1-based)
- includeDeclaration: Include the declaration itself (optional)
```
#### Go to Implementation
Find implementations of an interface or abstract method.
```
Operation: goToImplementation
Parameters:
- filePath: Path to the file
- line: Line number (1-based)
- character: Column number (1-based)
```
### Symbol Information
#### Hover
Get documentation and type information for a symbol.
```
Operation: hover
Parameters:
- filePath: Path to the file
- line: Line number (1-based)
- character: Column number (1-based)
```
#### Document Symbols
Get all symbols in a document.
```
Operation: documentSymbol
Parameters:
- filePath: Path to the file
```
#### Workspace Symbol Search
Search for symbols across the workspace.
```
Operation: workspaceSymbol
Parameters:
- query: Search query string
- limit: Maximum results (optional)
```
### Call Hierarchy
#### Prepare Call Hierarchy
Get the call hierarchy item at a position.
```
Operation: prepareCallHierarchy
Parameters:
- filePath: Path to the file
- line: Line number (1-based)
- character: Column number (1-based)
```
#### Incoming Calls
Find all functions that call the given function.
```
Operation: incomingCalls
Parameters:
- callHierarchyItem: Item from prepareCallHierarchy
```
#### Outgoing Calls
Find all functions called by the given function.
```
Operation: outgoingCalls
Parameters:
- callHierarchyItem: Item from prepareCallHierarchy
```
### Diagnostics
#### File Diagnostics
Get diagnostic messages (errors, warnings) for a file.
```
Operation: diagnostics
Parameters:
- filePath: Path to the file
```
#### Workspace Diagnostics
Get all diagnostic messages across the workspace.
```
Operation: workspaceDiagnostics
Parameters:
- limit: Maximum results (optional)
```
### Code Actions
#### Get Code Actions
Get available code actions (quick fixes, refactorings) at a location.
```
Operation: codeActions
Parameters:
- filePath: Path to the file
- line: Start line number (1-based)
- character: Start column number (1-based)
- endLine: End line number (optional, defaults to line)
- endCharacter: End column (optional, defaults to character)
- diagnostics: Diagnostics to get actions for (optional)
- codeActionKinds: Filter by action kind (optional)
```
Code action kinds:
- `quickfix` - Quick fixes for errors/warnings
- `refactor` - Refactoring operations
- `refactor.extract` - Extract to function/variable
- `refactor.inline` - Inline function/variable
- `source` - Source code actions
- `source.organizeImports` - Organize imports
- `source.fixAll` - Fix all auto-fixable issues
## Security
LSP servers are only started in trusted workspaces by default. This is because language servers run with your user permissions and can execute code.
### Trust Controls
- **Trusted Workspace**: LSP servers start automatically
- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false`
To mark a workspace as trusted, use the `/trust` command or configure trusted folders in settings.
### Server Allowlists
You can restrict which servers are allowed to run:
```json
{
"lsp": {
"allowed": ["typescript-language-server", "gopls"],
"excluded": ["untrusted-server"]
}
}
```
## Troubleshooting
### Server Not Starting
1. **Check if the server is installed**: Run the command manually to verify
2. **Check the PATH**: Ensure the server binary is in your system PATH
3. **Check workspace trust**: The workspace must be trusted for LSP
4. **Check logs**: Look for error messages in the console output
### Slow Performance
1. **Large projects**: Consider excluding `node_modules` and other large directories
2. **Server timeout**: Increase `lsp.serverTimeout` for slow servers
3. **Multiple servers**: Exclude unused language servers
### No Results
1. **Server not ready**: The server may still be indexing
2. **File not saved**: Save your file for the server to pick up changes
3. **Wrong language**: Check if the correct server is running for your language
### Debugging
Enable debug logging to see LSP communication:
```bash
DEBUG=lsp* qwen
```
Or check the LSP debugging guide at `packages/cli/LSP_DEBUGGING_GUIDE.md`.
## Claude Code Compatibility
Qwen Code supports Claude Code-style `.lsp.json` configuration files. If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes.
### Legacy Format
The legacy format (used by earlier versions) is still supported but deprecated:
```json
{
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"transport": "stdio"
}
}
```
We recommend migrating to the new `languageServers` format:
```json
{
"languageServers": {
"typescript-language-server": {
"languages": ["typescript", "javascript"],
"command": "typescript-language-server",
"args": ["--stdio"],
"transport": "stdio"
}
}
}
```
## Best Practices
1. **Install language servers globally**: This ensures they're available in all projects
2. **Use project-specific settings**: Configure server options per project when needed
3. **Keep servers updated**: Update your language servers regularly for best results
4. **Trust wisely**: Only trust workspaces from trusted sources
## FAQ
### Q: How do I know which language servers are running?
Use the `/lsp status` command to see all configured and running language servers.
### Q: Can I use multiple language servers for the same file type?
Yes, but only one will be used for each operation. The first server that returns results wins.
### Q: Does LSP work in sandbox mode?
LSP servers run outside the sandbox to access your code. They're subject to workspace trust controls.
### Q: How do I disable LSP for a specific project?
Add to your project's `.qwen/settings.json`:
```json
{
"lsp": {
"enabled": false
}
}
```

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2",
"workspaces": [
"packages/*"
],
@@ -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",
@@ -10805,13 +10804,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",
@@ -17318,7 +17310,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17955,7 +17947,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.2",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21416,7 +21408,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.2",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21428,7 +21420,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.1",
"version": "0.7.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.2"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -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",

View File

@@ -1,140 +0,0 @@
# LSP 调试指南
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
## 1. 启用调试模式
CLI 支持调试模式,可以提供额外的日志信息:
```bash
# 使用 debug 标志运行
qwen --debug [你的命令]
# 或设置环境变量
DEBUG=true qwen [你的命令]
DEBUG_MODE=true qwen [你的命令]
```
## 2. LSP 配置选项
LSP 功能通过设置系统配置,包含以下选项:
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
在 settings.json 中的示例配置:
```json
{
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server", "pylsp"],
"excluded": ["gopls"]
}
}
```
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
## 3. NativeLspService 调试功能
`NativeLspService` 类包含几个调试功能:
### 3.1 控制台日志
服务向控制台输出状态消息:
- `LSP 服务器 ${name} 启动成功` - 服务器成功启动
- `LSP 服务器 ${name} 启动失败` - 服务器启动失败
- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现
### 3.2 错误处理
服务具有全面的错误处理和详细的错误消息
### 3.3 状态跟踪
您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态
## 4. 调试命令
```bash
# 启用调试运行
qwen --debug --prompt "调试 LSP 功能"
# 检查在您的项目中检测到哪些 LSP 服务器
# 系统会自动检测语言和相应的 LSP 服务器
```
## 5. 手动 LSP 服务器配置
您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。
推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移:
```json
{
"languageServers": {
"pylsp": {
"command": "pylsp",
"args": [],
"languages": ["python"],
"transport": "stdio",
"settings": {},
"workspaceFolder": null,
"startupTimeout": 10000,
"shutdownTimeout": 3000,
"restartOnCrash": true,
"maxRestarts": 3,
"trustRequired": true
}
}
}
```
旧格式示例:
```json
{
"python": {
"command": "pylsp",
"args": [],
"transport": "stdio",
"trustRequired": true
}
}
```
## 6. LSP 问题排查
### 6.1 检查 LSP 服务器是否已安装
- 对于 TypeScript/JavaScript: `typescript-language-server`
- 对于 Python: `pylsp`
- 对于 Go: `gopls`
### 6.2 验证工作区信任
- LSP 服务器可能需要受信任的工作区才能启动
- 检查 `security.folderTrust.enabled` 设置
### 6.3 查看日志
- 查找以 `LSP 服务器` 开头的控制台消息
- 检查命令存在性和路径安全性问题
## 7. LSP 服务启动流程
LSP 服务的启动遵循以下流程:
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
3. **启动服务器**: `start()` 方法启动所有服务器句柄
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
## 8. 调试技巧
- 使用 `--debug` 标志查看详细的启动过程
- 检查工作区是否受信任(影响 LSP 服务器启动)
- 确认 LSP 服务器命令在系统 PATH 中可用
- 使用 `getStatus()` 方法监控服务器运行状态

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.2"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -20,25 +20,6 @@ import { ExtensionStorage, type Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
const createNativeLspServiceInstance = () => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
});
vi.mock('../services/lsp/NativeLspService.js', () => ({
NativeLspService: vi.fn().mockImplementation(() => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
@@ -46,17 +27,6 @@ vi.mock('./trustedFolders.js', () => ({
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
}));
const nativeLspServiceMock = vi.mocked(NativeLspService);
const getLastLspInstance = () => {
const results = nativeLspServiceMock.mock.results;
if (results.length === 0) {
return undefined;
}
return results[results.length - 1]?.value as ReturnType<
typeof createNativeLspServiceInstance
>;
};
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
@@ -546,10 +516,6 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(() =>
createNativeLspServiceInstance(),
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@@ -619,63 +585,6 @@ describe('loadCliConfig', () => {
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
lsp: {
enabled: true,
allowed: ['typescript-language-server'],
excluded: ['pylsp'],
},
};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.isLspEnabled()).toBe(true);
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
expect(config.getLspExcluded()).toEqual(['pylsp']);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
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'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { lsp: { enabled: true } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
undefined,
{ startLsp: false },
);
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).not.toHaveBeenCalled();
expect(getLastLspInstance()).toBeUndefined();
});
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);

View File

@@ -20,10 +20,8 @@ import {
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
type MCPServerConfig,
type LspClient,
type ToolName,
EditTool,
ShellTool,
@@ -48,7 +46,6 @@ import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -121,7 +118,6 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
experimentalLsp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
@@ -156,142 +152,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) {}
workspaceSymbols(query: string, limit?: number) {
return this.service.workspaceSymbols(query, limit);
}
definitions(
location: Parameters<NativeLspService['definitions']>[0],
serverName?: string,
limit?: number,
) {
return this.service.definitions(location, serverName, limit);
}
references(
location: Parameters<NativeLspService['references']>[0],
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
) {
return this.service.references(
location,
serverName,
includeDeclaration,
limit,
);
}
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: Parameters<NativeLspService['hover']>[0],
serverName?: string,
) {
return this.service.hover(location, serverName);
}
/**
* Get all symbols in a document.
*/
documentSymbols(uri: string, serverName?: string, limit?: number) {
return this.service.documentSymbols(uri, serverName, limit);
}
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: Parameters<NativeLspService['implementations']>[0],
serverName?: string,
limit?: number,
) {
return this.service.implementations(location, serverName, limit);
}
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: Parameters<NativeLspService['prepareCallHierarchy']>[0],
serverName?: string,
limit?: number,
) {
return this.service.prepareCallHierarchy(location, serverName, limit);
}
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: Parameters<NativeLspService['incomingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.incomingCalls(item, serverName, limit);
}
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: Parameters<NativeLspService['outgoingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.outgoingCalls(item, serverName, limit);
}
/**
* Get diagnostics for a specific document.
*/
diagnostics(uri: string, serverName?: string) {
return this.service.diagnostics(uri, serverName);
}
/**
* Get diagnostics for all open documents in the workspace.
*/
workspaceDiagnostics(serverName?: string, limit?: number) {
return this.service.workspaceDiagnostics(serverName, limit);
}
/**
* Get code actions available at a specific location.
*/
codeActions(
uri: string,
range: Parameters<NativeLspService['codeActions']>[1],
context: Parameters<NativeLspService['codeActions']>[2],
serverName?: string,
limit?: number,
) {
return this.service.codeActions(uri, range, context, serverName, limit);
}
/**
* Apply a workspace edit (from code action or other sources).
*/
applyWorkspaceEdit(
edit: Parameters<NativeLspService['applyWorkspaceEdit']>[0],
serverName?: string,
) {
return this.service.applyWorkspaceEdit(edit, serverName);
}
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
@@ -481,12 +341,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
})
.option('experimental-lsp', {
type: 'boolean',
description:
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
@@ -837,7 +691,6 @@ export async function loadCliConfig(
extensionEnablementManager: ExtensionEnablementManager,
argv: CliArgs,
cwd: string = process.cwd(),
options: LoadCliConfigOptions = {},
): Promise<Config> {
const debugMode = isDebugMode(argv);
@@ -908,13 +761,6 @@ export async function loadCliConfig(
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
// 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 =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@@ -1141,7 +987,7 @@ export async function loadCliConfig(
const modelProvidersConfig = settings.modelProviders;
const config = new Config({
return new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@@ -1231,40 +1077,7 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
allowed: lspAllowed,
excluded: lspExcluded,
},
});
const shouldStartLsp = options.startLsp ?? true;
if (shouldStartLsp && lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
allowedServers: lspAllowed,
excludedServers: lspExcluded,
requireTrustedWorkspace: folderTrust,
inlineServerConfigs: lspLanguageServers,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function allowedMcpServers(

View File

@@ -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 服务器启动超时时间(毫秒)'
}
}
};

View File

@@ -159,39 +159,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 {
@@ -755,9 +722,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,
@@ -862,14 +826,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;
@@ -883,13 +839,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;
@@ -903,18 +857,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

View File

@@ -1032,59 +1032,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',

View File

@@ -254,8 +254,6 @@ export async function main() {
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
argv,
undefined,
{ startLsp: false },
);
if (!settings.merged.security?.auth?.useExternal) {

View File

@@ -1,391 +0,0 @@
import * as cp from 'node:child_process';
import * as net from 'node:net';
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: NodeJS.Timeout;
}
class JsonRpcConnection {
private buffer = '';
private nextId = 1;
private disposed = false;
private pendingRequests = new Map<string | number, PendingRequest>();
private notificationHandlers: Array<(notification: JsonRpcMessage) => void> =
[];
private requestHandlers: Array<
(request: JsonRpcMessage) => Promise<unknown>
> = [];
constructor(
private readonly writer: (data: string) => void,
private readonly disposer?: () => void,
) {}
listen(readable: NodeJS.ReadableStream): void {
readable.on('data', (chunk: Buffer) => this.handleData(chunk));
readable.on('error', (error) =>
this.disposePending(
error instanceof Error ? error : new Error(String(error)),
),
);
}
send(message: JsonRpcMessage): void {
this.writeMessage(message);
}
onNotification(handler: (notification: JsonRpcMessage) => void): void {
this.notificationHandlers.push(handler);
}
onRequest(handler: (request: JsonRpcMessage) => Promise<unknown>): void {
this.requestHandlers.push(handler);
}
async initialize(params: unknown): Promise<unknown> {
return this.sendRequest('initialize', params);
}
async shutdown(): Promise<void> {
try {
await this.sendRequest('shutdown', {});
} catch (_error) {
// Ignore shutdown errors the server may already be gone.
} finally {
this.end();
}
}
request(method: string, params: unknown): Promise<unknown> {
return this.sendRequest(method, params);
}
end(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.disposePending();
this.disposer?.();
}
private sendRequest(method: string, params: unknown): Promise<unknown> {
if (this.disposed) {
return Promise.resolve(undefined);
}
const id = this.nextId++;
const payload: JsonRpcMessage = {
jsonrpc: '2.0',
id,
method,
params,
};
const requestPromise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`LSP request timeout: ${method}`));
}, 15000);
this.pendingRequests.set(id, { resolve, reject, timer });
});
this.writeMessage(payload);
return requestPromise;
}
private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
const handler = this.requestHandlers[this.requestHandlers.length - 1];
if (!handler) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: `Method not supported: ${message.method}`,
},
});
return;
}
try {
const result = await handler(message);
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
result: result ?? null,
});
} catch (error) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: (error as Error).message ?? 'Internal error',
},
});
}
}
private handleData(chunk: Buffer): void {
if (this.disposed) {
return;
}
this.buffer += chunk.toString('utf8');
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
break;
}
const header = this.buffer.slice(0, headerEnd);
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const contentLength = Number(lengthMatch[1]);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (this.buffer.length < messageEnd) {
break;
}
const body = this.buffer.slice(messageStart, messageEnd);
this.buffer = this.buffer.slice(messageEnd);
try {
const message = JSON.parse(body);
this.routeMessage(message);
} catch {
// ignore malformed messages
}
}
}
private routeMessage(message: JsonRpcMessage): void {
if (typeof message?.id !== 'undefined' && !message.method) {
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(
new Error(message.error.message || 'LSP request failed'),
);
} else {
pending.resolve(message.result);
}
return;
}
if (message?.method && typeof message.id !== 'undefined') {
void this.handleServerRequest(message);
return;
}
if (message?.method) {
for (const handler of this.notificationHandlers) {
try {
handler(message);
} catch {
// ignore handler errors
}
}
}
}
private writeMessage(message: JsonRpcMessage): void {
if (this.disposed) {
return;
}
const json = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
this.writer(header + json);
}
private disposePending(error?: Error): void {
for (const [, pending] of Array.from(this.pendingRequests)) {
clearTimeout(pending.timer);
pending.reject(error ?? new Error('LSP connection closed'));
}
this.pendingRequests.clear();
}
}
interface LspConnection {
connection: JsonRpcConnection;
process?: cp.ChildProcess;
socket?: net.Socket;
}
interface SocketConnectionOptions {
host?: string;
port?: number;
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 连接
*/
static async createStdioConnection(
command: string,
args: string[],
options?: cp.SpawnOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const spawnOptions: cp.SpawnOptions = {
stdio: 'pipe',
...options,
};
const processInstance = cp.spawn(command, args, spawnOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server spawn timeout'));
if (!processInstance.killed) {
processInstance.kill();
}
}, timeoutMs);
processInstance.once('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
});
processInstance.once('spawn', () => {
clearTimeout(timeoutId);
if (!processInstance.stdout || !processInstance.stdin) {
reject(new Error('LSP server stdio not available'));
return;
}
const connection = new JsonRpcConnection(
(payload) => processInstance.stdin?.write(payload),
() => processInstance.stdin?.end(),
);
connection.listen(processInstance.stdout);
processInstance.once('exit', () => connection.end());
processInstance.once('close', () => connection.end());
resolve({
connection,
process: processInstance,
});
});
});
}
/**
* 创建基于 TCP 的 LSP 连接
*/
static async createTcpConnection(
host: string,
port: number,
timeoutMs = 10000,
): Promise<LspConnection> {
return LspConnectionFactory.createSocketConnection(
{ host, port },
timeoutMs,
);
}
/**
* 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket
*/
static async createSocketConnection(
options: SocketConnectionOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const socketOptions = options.path
? { path: options.path }
: { host: options.host ?? '127.0.0.1', port: options.port };
if (!('path' in socketOptions) && !socketOptions.port) {
reject(new Error('Socket transport requires port or path'));
return;
}
const socket = net.createConnection(socketOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server connection timeout'));
socket.destroy();
}, timeoutMs);
const onError = (error: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
};
socket.once('error', onError);
socket.on('connect', () => {
clearTimeout(timeoutId);
socket.off('error', onError);
const connection = new JsonRpcConnection(
(payload) => socket.write(payload),
() => socket.destroy(),
);
connection.listen(socket);
socket.once('close', () => connection.end());
socket.once('error', () => connection.end());
resolve({
connection,
socket,
});
});
});
}
/**
* 关闭 LSP 连接
*/
static async closeConnection(lspConnection: LspConnection): Promise<void> {
if (lspConnection.connection) {
try {
await lspConnection.connection.shutdown();
} catch (e) {
console.warn('LSP shutdown failed:', e);
} finally {
lspConnection.connection.end();
}
}
if (lspConnection.process && !lspConnection.process.killed) {
lspConnection.process.kill();
}
if (lspConnection.socket && !lspConnection.socket.destroyed) {
lspConnection.socket.destroy();
}
}
}

View File

@@ -1,818 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { NativeLspService } from './NativeLspService.js';
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
IdeContextStore,
LspLocation,
LspDiagnostic,
} from '@qwen-code/qwen-code-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
/**
* Mock LSP server responses for integration testing.
* This simulates real LSP server behavior without requiring an actual server.
*/
const MOCK_LSP_RESPONSES = {
'initialize': {
capabilities: {
textDocumentSync: 1,
completionProvider: {},
hoverProvider: true,
definitionProvider: true,
referencesProvider: true,
documentSymbolProvider: true,
workspaceSymbolProvider: true,
codeActionProvider: true,
diagnosticProvider: {
interFileDependencies: true,
workspaceDiagnostics: true,
},
},
serverInfo: {
name: 'mock-lsp-server',
version: '1.0.0',
},
},
'textDocument/definition': [
{
uri: 'file:///test/workspace/src/types.ts',
range: {
start: { line: 10, character: 0 },
end: { line: 10, character: 20 },
},
},
],
'textDocument/references': [
{
uri: 'file:///test/workspace/src/app.ts',
range: {
start: { line: 5, character: 10 },
end: { line: 5, character: 20 },
},
},
{
uri: 'file:///test/workspace/src/utils.ts',
range: {
start: { line: 15, character: 5 },
end: { line: 15, character: 15 },
},
},
],
'textDocument/hover': {
contents: {
kind: 'markdown',
value: '```typescript\nfunction testFunc(): void\n```\n\nA test function.',
},
range: {
start: { line: 10, character: 0 },
end: { line: 10, character: 8 },
},
},
'textDocument/documentSymbol': [
{
name: 'TestClass',
kind: 5, // Class
range: {
start: { line: 0, character: 0 },
end: { line: 20, character: 1 },
},
selectionRange: {
start: { line: 0, character: 6 },
end: { line: 0, character: 15 },
},
children: [
{
name: 'constructor',
kind: 9, // Constructor
range: {
start: { line: 2, character: 2 },
end: { line: 4, character: 3 },
},
selectionRange: {
start: { line: 2, character: 2 },
end: { line: 2, character: 13 },
},
},
],
},
],
'workspace/symbol': [
{
name: 'TestClass',
kind: 5, // Class
location: {
uri: 'file:///test/workspace/src/test.ts',
range: {
start: { line: 0, character: 0 },
end: { line: 20, character: 1 },
},
},
},
{
name: 'testFunction',
kind: 12, // Function
location: {
uri: 'file:///test/workspace/src/utils.ts',
range: {
start: { line: 5, character: 0 },
end: { line: 10, character: 1 },
},
},
containerName: 'utils',
},
],
'textDocument/implementation': [
{
uri: 'file:///test/workspace/src/impl.ts',
range: {
start: { line: 20, character: 0 },
end: { line: 40, character: 1 },
},
},
],
'textDocument/prepareCallHierarchy': [
{
name: 'testFunction',
kind: 12, // Function
detail: '(param: string) => void',
uri: 'file:///test/workspace/src/utils.ts',
range: {
start: { line: 5, character: 0 },
end: { line: 10, character: 1 },
},
selectionRange: {
start: { line: 5, character: 9 },
end: { line: 5, character: 21 },
},
},
],
'callHierarchy/incomingCalls': [
{
from: {
name: 'callerFunction',
kind: 12,
uri: 'file:///test/workspace/src/caller.ts',
range: {
start: { line: 10, character: 0 },
end: { line: 15, character: 1 },
},
selectionRange: {
start: { line: 10, character: 9 },
end: { line: 10, character: 23 },
},
},
fromRanges: [
{
start: { line: 12, character: 2 },
end: { line: 12, character: 16 },
},
],
},
],
'callHierarchy/outgoingCalls': [
{
to: {
name: 'helperFunction',
kind: 12,
uri: 'file:///test/workspace/src/helper.ts',
range: {
start: { line: 0, character: 0 },
end: { line: 5, character: 1 },
},
selectionRange: {
start: { line: 0, character: 9 },
end: { line: 0, character: 23 },
},
},
fromRanges: [
{
start: { line: 7, character: 2 },
end: { line: 7, character: 16 },
},
],
},
],
'textDocument/diagnostic': {
kind: 'full',
items: [
{
range: {
start: { line: 5, character: 0 },
end: { line: 5, character: 10 },
},
severity: 1, // Error
code: 'TS2304',
source: 'typescript',
message: "Cannot find name 'undeclaredVar'.",
},
{
range: {
start: { line: 10, character: 0 },
end: { line: 10, character: 15 },
},
severity: 2, // Warning
code: 'TS6133',
source: 'typescript',
message: "'unusedVar' is declared but its value is never read.",
tags: [1], // Unnecessary
},
],
},
'workspace/diagnostic': {
items: [
{
kind: 'full',
uri: 'file:///test/workspace/src/app.ts',
items: [
{
range: {
start: { line: 5, character: 0 },
end: { line: 5, character: 10 },
},
severity: 1,
code: 'TS2304',
source: 'typescript',
message: "Cannot find name 'undeclaredVar'.",
},
],
},
{
kind: 'full',
uri: 'file:///test/workspace/src/utils.ts',
items: [
{
range: {
start: { line: 10, character: 0 },
end: { line: 10, character: 15 },
},
severity: 2,
code: 'TS6133',
source: 'typescript',
message: "'unusedVar' is declared but its value is never read.",
},
],
},
],
},
'textDocument/codeAction': [
{
title: "Add missing import 'React'",
kind: 'quickfix',
diagnostics: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
severity: 1,
message: "Cannot find name 'React'.",
},
],
edit: {
changes: {
'file:///test/workspace/src/app.tsx': [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: "import React from 'react';\n",
},
],
},
},
isPreferred: true,
},
{
title: 'Organize imports',
kind: 'source.organizeImports',
edit: {
changes: {
'file:///test/workspace/src/app.tsx': [
{
range: {
start: { line: 0, character: 0 },
end: { line: 5, character: 0 },
},
newText: "import { Component } from 'react';\nimport { helper } from './utils';\n",
},
],
},
},
},
],
};
/**
* Mock configuration for testing.
*/
class MockConfig {
rootPath = '/test/workspace';
private trusted = true;
isTrustedFolder(): boolean {
return this.trusted;
}
setTrusted(trusted: boolean): void {
this.trusted = trusted;
}
get(_key: string) {
return undefined;
}
getProjectRoot(): string {
return this.rootPath;
}
}
/**
* Mock workspace context for testing.
*/
class MockWorkspaceContext {
rootPath = '/test/workspace';
async fileExists(filePath: string): Promise<boolean> {
return (
filePath.endsWith('.json') ||
filePath.includes('package.json') ||
filePath.includes('.ts')
);
}
async readFile(filePath: string): Promise<string> {
if (filePath.includes('.lsp.json')) {
return JSON.stringify({
'mock-lsp': {
languages: ['typescript', 'javascript'],
command: 'mock-lsp-server',
args: ['--stdio'],
transport: 'stdio',
},
});
}
return '{}';
}
resolvePath(relativePath: string): string {
return this.rootPath + '/' + relativePath;
}
isPathWithinWorkspace(_path: string): boolean {
return true;
}
getDirectories(): string[] {
return [this.rootPath];
}
}
/**
* Mock file discovery service for testing.
*/
class MockFileDiscoveryService {
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
return [
'/test/workspace/src/index.ts',
'/test/workspace/src/app.ts',
'/test/workspace/src/utils.ts',
'/test/workspace/src/types.ts',
];
}
shouldIgnoreFile(file: string): boolean {
return file.includes('node_modules') || file.includes('.git');
}
}
/**
* Mock IDE context store for testing.
*/
class MockIdeContextStore {}
describe('NativeLspService Integration Tests', () => {
let lspService: NativeLspService;
let mockConfig: MockConfig;
let mockWorkspace: MockWorkspaceContext;
let mockFileDiscovery: MockFileDiscoveryService;
let mockIdeStore: MockIdeContextStore;
let eventEmitter: EventEmitter;
beforeEach(() => {
mockConfig = new MockConfig();
mockWorkspace = new MockWorkspaceContext();
mockFileDiscovery = new MockFileDiscoveryService();
mockIdeStore = new MockIdeContextStore();
eventEmitter = new EventEmitter();
lspService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
workspaceRoot: mockWorkspace.rootPath,
},
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Service Lifecycle', () => {
it('should initialize service correctly', () => {
expect(lspService).toBeDefined();
});
it('should discover and prepare without errors', async () => {
await expect(lspService.discoverAndPrepare()).resolves.not.toThrow();
});
it('should return status after discovery', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
expect(status).toBeDefined();
expect(status instanceof Map).toBe(true);
});
it('should skip discovery for untrusted workspace', async () => {
mockConfig.setTrusted(false);
const untrustedService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
workspaceRoot: mockWorkspace.rootPath,
requireTrustedWorkspace: true,
},
);
await untrustedService.discoverAndPrepare();
const status = untrustedService.getStatus();
expect(status.size).toBe(0);
});
});
describe('Configuration Merging', () => {
it('should detect TypeScript/JavaScript in workspace', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// Should have detected TypeScript based on mock file discovery
// The exact server name depends on built-in presets
expect(status.size).toBeGreaterThanOrEqual(0);
});
it('should respect allowed servers list', async () => {
const restrictedService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
workspaceRoot: mockWorkspace.rootPath,
allowedServers: ['typescript-language-server'],
},
);
await restrictedService.discoverAndPrepare();
const status = restrictedService.getStatus();
// Only allowed servers should be 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', () => {
// Note: These tests verify the structure of expected responses
// In a real integration test, you would mock the connection or use a real server
it('should format definition response correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/definition'];
expect(response).toHaveLength(1);
expect(response[0]).toHaveProperty('uri');
expect(response[0]).toHaveProperty('range');
expect(response[0].range.start).toHaveProperty('line');
expect(response[0].range.start).toHaveProperty('character');
});
it('should format references response correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/references'];
expect(response).toHaveLength(2);
for (const ref of response) {
expect(ref).toHaveProperty('uri');
expect(ref).toHaveProperty('range');
}
});
it('should format hover response correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/hover'];
expect(response).toHaveProperty('contents');
expect(response.contents).toHaveProperty('value');
expect(response.contents.value).toContain('testFunc');
});
it('should format document symbols correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/documentSymbol'];
expect(response).toHaveLength(1);
expect(response[0].name).toBe('TestClass');
expect(response[0].kind).toBe(5); // Class
expect(response[0].children).toHaveLength(1);
});
it('should format workspace symbols correctly', () => {
const response = MOCK_LSP_RESPONSES['workspace/symbol'];
expect(response).toHaveLength(2);
expect(response[0].name).toBe('TestClass');
expect(response[1].name).toBe('testFunction');
expect(response[1].containerName).toBe('utils');
});
it('should format call hierarchy items correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/prepareCallHierarchy'];
expect(response).toHaveLength(1);
expect(response[0].name).toBe('testFunction');
expect(response[0]).toHaveProperty('detail');
expect(response[0]).toHaveProperty('range');
expect(response[0]).toHaveProperty('selectionRange');
});
it('should format incoming calls correctly', () => {
const response = MOCK_LSP_RESPONSES['callHierarchy/incomingCalls'];
expect(response).toHaveLength(1);
expect(response[0].from.name).toBe('callerFunction');
expect(response[0].fromRanges).toHaveLength(1);
});
it('should format outgoing calls correctly', () => {
const response = MOCK_LSP_RESPONSES['callHierarchy/outgoingCalls'];
expect(response).toHaveLength(1);
expect(response[0].to.name).toBe('helperFunction');
expect(response[0].fromRanges).toHaveLength(1);
});
it('should format diagnostics correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/diagnostic'];
expect(response.items).toHaveLength(2);
expect(response.items[0].severity).toBe(1); // Error
expect(response.items[0].code).toBe('TS2304');
expect(response.items[1].severity).toBe(2); // Warning
expect(response.items[1].tags).toContain(1); // Unnecessary
});
it('should format workspace diagnostics correctly', () => {
const response = MOCK_LSP_RESPONSES['workspace/diagnostic'];
expect(response.items).toHaveLength(2);
expect(response.items[0].uri).toContain('app.ts');
expect(response.items[1].uri).toContain('utils.ts');
});
it('should format code actions correctly', () => {
const response = MOCK_LSP_RESPONSES['textDocument/codeAction'];
expect(response).toHaveLength(2);
const quickfix = response[0];
expect(quickfix.title).toContain('import');
expect(quickfix.kind).toBe('quickfix');
expect(quickfix.isPreferred).toBe(true);
expect(quickfix.edit).toHaveProperty('changes');
const organizeImports = response[1];
expect(organizeImports.kind).toBe('source.organizeImports');
});
});
describe('Diagnostic Normalization', () => {
it('should normalize severity levels correctly', () => {
const severityMap: Record<number, string> = {
1: 'error',
2: 'warning',
3: 'information',
4: 'hint',
};
for (const [num, label] of Object.entries(severityMap)) {
expect(severityMap[Number(num)]).toBe(label);
}
});
it('should normalize diagnostic tags correctly', () => {
const tagMap: Record<number, string> = {
1: 'unnecessary',
2: 'deprecated',
};
expect(tagMap[1]).toBe('unnecessary');
expect(tagMap[2]).toBe('deprecated');
});
});
describe('Code Action Context', () => {
it('should support filtering by code action kind', () => {
const kinds = ['quickfix', 'refactor', 'source.organizeImports'];
const filteredActions = MOCK_LSP_RESPONSES['textDocument/codeAction'].filter(
(action) => kinds.includes(action.kind),
);
expect(filteredActions).toHaveLength(2);
});
it('should support quick fix actions with diagnostics', () => {
const quickfix = MOCK_LSP_RESPONSES['textDocument/codeAction'][0];
expect(quickfix.diagnostics).toBeDefined();
expect(quickfix.diagnostics).toHaveLength(1);
expect(quickfix.edit).toBeDefined();
});
});
describe('Workspace Edit Application', () => {
it('should structure workspace edits correctly', () => {
const codeAction = MOCK_LSP_RESPONSES['textDocument/codeAction'][0];
const edit = codeAction.edit;
expect(edit).toHaveProperty('changes');
expect(edit?.changes).toBeDefined();
const uri = Object.keys(edit?.changes ?? {})[0];
expect(uri).toContain('app.tsx');
const edits = edit?.changes?.[uri];
expect(edits).toHaveLength(1);
expect(edits?.[0]).toHaveProperty('range');
expect(edits?.[0]).toHaveProperty('newText');
});
});
describe('Error Handling', () => {
it('should handle missing workspace gracefully', async () => {
const emptyWorkspace = new MockWorkspaceContext();
emptyWorkspace.getDirectories = () => [];
const service = new NativeLspService(
mockConfig as unknown as CoreConfig,
emptyWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
);
await expect(service.discoverAndPrepare()).resolves.not.toThrow();
});
it('should return empty results when no server is ready', async () => {
// Before starting any servers, operations should return empty
const results = await lspService.workspaceSymbols('test');
expect(results).toEqual([]);
});
it('should return empty diagnostics when no server is ready', async () => {
const uri = 'file:///test/workspace/src/app.ts';
const results = await lspService.diagnostics(uri);
expect(results).toEqual([]);
});
it('should return empty code actions when no server is ready', async () => {
const uri = 'file:///test/workspace/src/app.ts';
const range = {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
};
const context = {
diagnostics: [],
only: undefined,
triggerKind: 'invoked' as const,
};
const results = await lspService.codeActions(uri, range, context);
expect(results).toEqual([]);
});
});
describe('Security Controls', () => {
it('should respect trust requirements', async () => {
mockConfig.setTrusted(false);
const strictService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
requireTrustedWorkspace: true,
},
);
await strictService.discoverAndPrepare();
const status = strictService.getStatus();
// No servers should be discovered in untrusted workspace
expect(status.size).toBe(0);
});
it('should allow operations in trusted workspace', async () => {
mockConfig.setTrusted(true);
await lspService.discoverAndPrepare();
// Service should be ready to accept operations (even if no real server)
expect(lspService).toBeDefined();
});
});
});
describe('LSP Response Type Validation', () => {
describe('LspDiagnostic', () => {
it('should have correct structure', () => {
const diagnostic: LspDiagnostic = {
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
severity: 'error',
code: 'TS2304',
source: 'typescript',
message: 'Cannot find name.',
};
expect(diagnostic.range).toBeDefined();
expect(diagnostic.severity).toBe('error');
expect(diagnostic.code).toBe('TS2304');
expect(diagnostic.source).toBe('typescript');
expect(diagnostic.message).toBeDefined();
});
it('should support optional fields', () => {
const minimalDiagnostic: LspDiagnostic = {
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
message: 'Error message',
};
expect(minimalDiagnostic.severity).toBeUndefined();
expect(minimalDiagnostic.code).toBeUndefined();
expect(minimalDiagnostic.source).toBeUndefined();
});
});
describe('LspLocation', () => {
it('should have correct structure', () => {
const location: LspLocation = {
uri: 'file:///test/file.ts',
range: {
start: { line: 10, character: 5 },
end: { line: 10, character: 15 },
},
};
expect(location.uri).toBe('file:///test/file.ts');
expect(location.range.start.line).toBe(10);
expect(location.range.start.character).toBe(5);
expect(location.range.end.line).toBe(10);
expect(location.range.end.character).toBe(15);
});
});
});

View File

@@ -1,127 +0,0 @@
import { NativeLspService } from './NativeLspService.js';
import { EventEmitter } from 'events';
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
IdeContextStore,
} from '@qwen-code/qwen-code-core';
// 模拟依赖项
class MockConfig {
rootPath = '/test/workspace';
isTrustedFolder(): boolean {
return true;
}
get(_key: string) {
return undefined;
}
getProjectRoot(): string {
return this.rootPath;
}
}
class MockWorkspaceContext {
rootPath = '/test/workspace';
async fileExists(_path: string): Promise<boolean> {
return _path.endsWith('.json') || _path.includes('package.json');
}
async readFile(_path: string): Promise<string> {
if (_path.includes('.lsp.json')) {
return JSON.stringify({
typescript: {
command: 'typescript-language-server',
args: ['--stdio'],
transport: 'stdio',
},
});
}
return '{}';
}
resolvePath(_path: string): string {
return this.rootPath + '/' + _path;
}
isPathWithinWorkspace(_path: string): boolean {
return true;
}
getDirectories(): string[] {
return [this.rootPath];
}
}
class MockFileDiscoveryService {
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
// 模拟发现一些文件
return [
'/test/workspace/src/index.ts',
'/test/workspace/src/utils.ts',
'/test/workspace/server.py',
'/test/workspace/main.go',
];
}
shouldIgnoreFile(): boolean {
return false;
}
}
class MockIdeContextStore {
// 模拟 IDE 上下文存储
}
describe('NativeLspService', () => {
let lspService: NativeLspService;
let mockConfig: MockConfig;
let mockWorkspace: MockWorkspaceContext;
let mockFileDiscovery: MockFileDiscoveryService;
let mockIdeStore: MockIdeContextStore;
let eventEmitter: EventEmitter;
beforeEach(() => {
mockConfig = new MockConfig();
mockWorkspace = new MockWorkspaceContext();
mockFileDiscovery = new MockFileDiscoveryService();
mockIdeStore = new MockIdeContextStore();
eventEmitter = new EventEmitter();
lspService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
);
});
test('should initialize correctly', () => {
expect(lspService).toBeDefined();
});
test('should detect languages from workspace files', async () => {
// 这个测试需要修改,因为我们无法直接访问私有方法
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
test('should merge built-in presets with user configs', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
});
// 注意:实际的单元测试需要适当的测试框架配置
// 这里只是一个结构示例

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.2",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -61,11 +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';
// Other modules
import { ideContextStore } from '../ide/ideContext.js';
@@ -292,12 +287,6 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
lsp?: {
enabled?: boolean;
allowed?: string[];
excluded?: string[];
};
lspClient?: LspClient;
userMemory?: string;
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
@@ -440,10 +429,6 @@ export class Config {
private readonly toolCallCommand: string | undefined;
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 sessionSubagents: SubagentConfig[];
private userMemory: string;
private sdkMode: boolean;
@@ -549,10 +534,6 @@ export class Config {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.lspEnabled = params.lsp?.enabled ?? false;
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
this.lspClient = params.lspClient;
this.sessionSubagents = params.sessionSubagents ?? [];
this.sdkMode = params.sdkMode ?? false;
this.userMemory = params.userMemory ?? '';
@@ -1053,32 +1034,6 @@ export class Config {
this.mcpServers = { ...this.mcpServers, ...servers };
}
isLspEnabled(): boolean {
return this.lspEnabled;
}
getLspAllowed(): string[] | undefined {
return this.lspAllowed;
}
getLspExcluded(): string[] | undefined {
return this.lspExcluded;
}
getLspClient(): LspClient | undefined {
return this.lspClient;
}
/**
* Allows wiring an LSP client after Config construction but before initialize().
*/
setLspClient(client: LspClient | undefined): void {
if (this.initialized) {
throw new Error('Cannot set LSP client after initialization');
}
this.lspClient = client;
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}
@@ -1586,14 +1541,6 @@ export class Config {
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this);
}
if (this.isLspEnabled() && this.getLspClient()) {
// Register the unified LSP tool (recommended)
registerCoreTool(LspTool, this);
// Keep legacy tools for backward compatibility
registerCoreTool(LspGoToDefinitionTool, this);
registerCoreTool(LspFindReferencesTool, this);
registerCoreTool(LspWorkspaceSymbolTool, this);
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());

View File

@@ -111,7 +111,6 @@ export * from './skills/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';
export * from './lsp/types.js';
// Export specific tool logic
export * from './tools/read-file.js';
@@ -126,8 +125,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';

View File

@@ -1,360 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface LspPosition {
line: number;
character: number;
}
export interface LspRange {
start: LspPosition;
end: LspPosition;
}
export interface LspLocation {
uri: string;
range: LspRange;
}
export interface LspLocationWithServer extends LspLocation {
serverName?: string;
}
export interface LspSymbolInformation {
name: string;
kind?: string;
location: LspLocation;
containerName?: string;
serverName?: string;
}
export interface LspReference extends LspLocationWithServer {
readonly serverName?: string;
}
export interface LspDefinition extends LspLocationWithServer {
readonly serverName?: string;
}
/**
* Hover result containing documentation or type information.
*/
export interface LspHoverResult {
/** The hover content as a string (normalized from MarkupContent/MarkedString). */
contents: string;
/** Optional range that the hover applies to. */
range?: LspRange;
/** The LSP server that provided this result. */
serverName?: string;
}
/**
* Call hierarchy item representing a function, method, or callable.
*/
export interface LspCallHierarchyItem {
/** The name of this item. */
name: string;
/** The kind of this item (function, method, constructor, etc.) as readable string. */
kind?: string;
/** The raw numeric SymbolKind from LSP, preserved for server communication. */
rawKind?: number;
/** Additional details like signature or file path. */
detail?: string;
/** The URI of the document containing this item. */
uri: string;
/** The full range of this item. */
range: LspRange;
/** The range that should be selected when navigating to this item. */
selectionRange: LspRange;
/** Opaque data used by the server for subsequent calls. */
data?: unknown;
/** The LSP server that provided this item. */
serverName?: string;
}
/**
* Incoming call representing a function that calls the target.
*/
export interface LspCallHierarchyIncomingCall {
/** The caller item. */
from: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
/**
* Outgoing call representing a function called by the target.
*/
export interface LspCallHierarchyOutgoingCall {
/** The callee item. */
to: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
/**
* Diagnostic severity levels from LSP specification.
*/
export type LspDiagnosticSeverity = 'error' | 'warning' | 'information' | 'hint';
/**
* A diagnostic message from a language server.
*/
export interface LspDiagnostic {
/** The range at which the diagnostic applies. */
range: LspRange;
/** The diagnostic's severity (error, warning, information, hint). */
severity?: LspDiagnosticSeverity;
/** The diagnostic's code (string or number). */
code?: string | number;
/** A human-readable string describing the source (e.g., 'typescript'). */
source?: string;
/** The diagnostic's message. */
message: string;
/** Additional metadata about the diagnostic. */
tags?: LspDiagnosticTag[];
/** Related diagnostic information. */
relatedInformation?: LspDiagnosticRelatedInformation[];
/** The LSP server that provided this diagnostic. */
serverName?: string;
}
/**
* Diagnostic tags from LSP specification.
*/
export type LspDiagnosticTag = 'unnecessary' | 'deprecated';
/**
* Related diagnostic information.
*/
export interface LspDiagnosticRelatedInformation {
/** The location of the related diagnostic. */
location: LspLocation;
/** The message of the related diagnostic. */
message: string;
}
/**
* A file's diagnostics grouped by URI.
*/
export interface LspFileDiagnostics {
/** The document URI. */
uri: string;
/** The diagnostics for this document. */
diagnostics: LspDiagnostic[];
/** The LSP server that provided these diagnostics. */
serverName?: string;
}
/**
* A code action represents a change that can be performed in code.
*/
export interface LspCodeAction {
/** A short, human-readable title for this code action. */
title: string;
/** The kind of the code action (quickfix, refactor, etc.). */
kind?: LspCodeActionKind;
/** The diagnostics that this code action resolves. */
diagnostics?: LspDiagnostic[];
/** Marks this as a preferred action. */
isPreferred?: boolean;
/** The workspace edit this code action performs. */
edit?: LspWorkspaceEdit;
/** A command this code action executes. */
command?: LspCommand;
/** Opaque data used by the server for subsequent resolve calls. */
data?: unknown;
/** The LSP server that provided this code action. */
serverName?: string;
}
/**
* Code action kinds from LSP specification.
*/
export type LspCodeActionKind =
| 'quickfix'
| 'refactor'
| 'refactor.extract'
| 'refactor.inline'
| 'refactor.rewrite'
| 'source'
| 'source.organizeImports'
| 'source.fixAll'
| string;
/**
* A workspace edit represents changes to many resources managed in the workspace.
*/
export interface LspWorkspaceEdit {
/** Holds changes to existing documents. */
changes?: Record<string, LspTextEdit[]>;
/** Versioned document changes (more precise control). */
documentChanges?: LspTextDocumentEdit[];
}
/**
* A text edit applicable to a document.
*/
export interface LspTextEdit {
/** The range of the text document to be manipulated. */
range: LspRange;
/** The string to be inserted (empty string for delete). */
newText: string;
}
/**
* Describes textual changes on a single text document.
*/
export interface LspTextDocumentEdit {
/** The text document to change. */
textDocument: {
uri: string;
version?: number | null;
};
/** The edits to be applied. */
edits: LspTextEdit[];
}
/**
* A command represents a reference to a command.
*/
export interface LspCommand {
/** Title of the command. */
title: string;
/** The identifier of the actual command handler. */
command: string;
/** Arguments to the command handler. */
arguments?: unknown[];
}
/**
* Context for code action requests.
*/
export interface LspCodeActionContext {
/** The diagnostics for which code actions are requested. */
diagnostics: LspDiagnostic[];
/** Requested kinds of code actions to return. */
only?: LspCodeActionKind[];
/** The reason why code actions were requested. */
triggerKind?: 'invoked' | 'automatic';
}
export interface LspClient {
/**
* Search for symbols across the workspace.
*/
workspaceSymbols(
query: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: LspLocation,
serverName?: string,
): Promise<LspHoverResult | null>;
/**
* Get all symbols in a document.
*/
documentSymbols(
uri: string,
serverName?: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Find where a symbol is defined.
*/
definitions(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find all references to a symbol.
*/
references(
location: LspLocation,
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
): Promise<LspReference[]>;
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyItem[]>;
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyIncomingCall[]>;
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyOutgoingCall[]>;
/**
* Get diagnostics for a specific document.
*/
diagnostics(
uri: string,
serverName?: string,
): Promise<LspDiagnostic[]>;
/**
* Get diagnostics for all open documents in the workspace.
*/
workspaceDiagnostics(
serverName?: string,
limit?: number,
): Promise<LspFileDiagnostics[]>;
/**
* Get code actions available at a specific location.
*/
codeActions(
uri: string,
range: LspRange,
context: LspCodeActionContext,
serverName?: string,
limit?: number,
): Promise<LspCodeAction[]>;
/**
* Apply a workspace edit (from code action or other sources).
*/
applyWorkspaceEdit(
edit: LspWorkspaceEdit,
serverName?: string,
): Promise<boolean>;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +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,11 +48,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;
// Migration from old tool names to new tool names
@@ -66,8 +56,6 @@ export const ToolDisplayNames = {
export const ToolNamesMigration = {
search_file_content: ToolNames.GREP, // Legacy name from grep tool
replace: ToolNames.EDIT, // Legacy name from edit tool
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
find_references: ToolNames.LSP_FIND_REFERENCES,
} as const;
// Migration from old tool display names to new tool display names

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

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

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.7.1",
"version": "0.7.2",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {