feat(lsp): Removes built-in LSP configuration options and improves configuration loading mechanism

- remove configuration options such as lsp.enabled, lsp.allowed, lsp.excluded, etc. from settings.json schema
- Delete lspSettingsSchema.ts files and associated JSON schema definitions
- Removed VS Code settings loading function, no longer merge. vscode/settings.json configuration
- Updated LSP documentation to reflect new configurations and experimental flags
-remove allow/exclude parameters in NativeLspService constructor
- Create new LspConfigLoader classes to handle LSP configuration loading and merging
- Updated debug guide to match the new configuration mechanism
- Simplify loadCliConfig functions, remove startLsp options
- Reconstruct the configuration loading process to remove duplicate configuration merge logic
- Add LspConfigLoader classes to implement configuration parsing and merging functions
This commit is contained in:
yiliang114 2026-01-25 20:59:44 +08:00
parent 45e947dcbc
commit 8420386d14
33 changed files with 3064 additions and 3907 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

@ -288,20 +288,9 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
> [!warning]
> **Experimental Feature**: LSP support is currently experimental and disabled by default. Enable it using the `--experimental-lsp` command line flag.
Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details.
Language Server Protocol (LSP) provides code intelligence features like go-to-definition, find references, and diagnostics.
| Setting | Type | Description | Default |
| --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `lsp.enabled` | boolean | Enable/disable LSP support. Has no effect unless `--experimental-lsp` is provided. | `false` |
| `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` |
| `lsp.serverTimeout` | number | LSP server startup timeout in milliseconds. | `10000` |
| `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` |
| `lsp.excluded` | array of strings | A denylist of LSP servers to exclude. A server listed in both is excluded. | `[]` |
| `lsp.languageServers` | object | Custom language server configurations. See the [LSP documentation](../features/lsp#custom-language-servers) for configuration format. | `{}` |
> [!note]
>
> **Security Note for LSP servers:** LSP servers run with your user permissions and can execute code. They are only started in trusted workspaces by default. You can configure per-server trust requirements in the `.lsp.json` configuration file.
LSP server configuration is done through `.lsp.json` files in your project root directory, not through `settings.json`. See the [LSP documentation](../features/lsp) for configuration details and examples.
#### security

View file

@ -15,55 +15,61 @@ LSP support in Qwen Code works by connecting to language servers that understand
## Quick Start
LSP is enabled by default in Qwen Code. For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system.
LSP is an experimental feature in Qwen Code. To enable it, use the `--experimental-lsp` command line flag:
```bash
qwen --experimental-lsp
```
For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system.
### Prerequisites
You need to have the language server for your programming language installed:
| Language | Language Server | Install Command |
|----------|----------------|-----------------|
| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` |
| Python | pylsp | `pip install python-lsp-server` |
| Go | gopls | `go install golang.org/x/tools/gopls@latest` |
| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) |
| Language | Language Server | Install Command |
| --------------------- | -------------------------- | ------------------------------------------------------------------------------ |
| TypeScript/JavaScript | typescript-language-server | `npm install -g typescript-language-server typescript` |
| Python | pylsp | `pip install python-lsp-server` |
| Go | gopls | `go install golang.org/x/tools/gopls@latest` |
| Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) |
## Configuration
### Settings
### .lsp.json File
You can configure LSP behavior in your `settings.json`:
You can configure language servers using a `.lsp.json` file in your project root. This follows the [Claude Code plugin LSP configuration format](https://code.claude.com/docs/en/plugins-reference#lsp-servers).
**Basic format:**
```json
{
"lsp": {
"enabled": true,
"autoDetect": true,
"serverTimeout": 10000,
"allowed": [],
"excluded": []
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensionToLanguage": {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact"
}
}
}
```
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `lsp.enabled` | boolean | `true` | Enable/disable LSP support |
| `lsp.autoDetect` | boolean | `true` | Automatically detect and start language servers |
| `lsp.serverTimeout` | number | `10000` | Server startup timeout in milliseconds |
| `lsp.allowed` | string[] | `[]` | Allow only these servers (empty = allow all) |
| `lsp.excluded` | string[] | `[]` | Exclude these servers from starting |
### Custom Language Servers
You can configure custom language servers using a `.lsp.json` file in your project root:
**Extended format with `languageServers` wrapper:**
```json
{
"languageServers": {
"my-custom-lsp": {
"languages": ["mylang"],
"command": "my-lsp-server",
"typescript-language-server": {
"languages": [
"typescript",
"javascript",
"typescriptreact",
"javascriptreact"
],
"command": "typescript-language-server",
"args": ["--stdio"],
"transport": "stdio",
"initializationOptions": {},
@ -73,40 +79,45 @@ You can configure custom language servers using a `.lsp.json` file in your proje
}
```
#### Configuration Options
### Configuration Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `languages` | string[] | Yes | Languages this server handles |
| `command` | string | Yes* | Command to start the server |
| `args` | string[] | No | Command line arguments |
| `transport` | string | No | Transport type: `stdio` (default), `tcp`, or `socket` |
| `env` | object | No | Environment variables |
| `initializationOptions` | object | No | LSP initialization options |
| `settings` | object | No | Server settings |
| `workspaceFolder` | string | No | Override workspace folder |
| `startupTimeout` | number | No | Startup timeout in ms |
| `shutdownTimeout` | number | No | Shutdown timeout in ms |
| `restartOnCrash` | boolean | No | Auto-restart on crash |
| `maxRestarts` | number | No | Maximum restart attempts |
| `trustRequired` | boolean | No | Require trusted workspace |
#### Required Fields
*Required for `stdio` transport
| Option | Type | Description |
| --------------------- | ------ | ------------------------------------------------- |
| `command` | string | Command to start the LSP server (must be in PATH) |
| `extensionToLanguage` | object | Maps file extensions to language identifiers |
#### TCP/Socket Transport
#### Optional Fields
| Option | Type | Default | Description |
| ----------------------- | -------- | --------- | ------------------------------------------------------ |
| `args` | string[] | `[]` | Command line arguments |
| `transport` | string | `"stdio"` | Transport type: `stdio` or `socket` |
| `env` | object | - | Environment variables |
| `initializationOptions` | object | - | LSP initialization options |
| `settings` | object | - | Server settings via `workspace/didChangeConfiguration` |
| `workspaceFolder` | string | - | Override workspace folder |
| `startupTimeout` | number | `10000` | Startup timeout in milliseconds |
| `shutdownTimeout` | number | `5000` | Shutdown timeout in milliseconds |
| `restartOnCrash` | boolean | `false` | Auto-restart on crash |
| `maxRestarts` | number | `3` | Maximum restart attempts |
| `trustRequired` | boolean | `true` | Require trusted workspace |
### TCP/Socket Transport
For servers that use TCP or Unix socket transport:
```json
{
"languageServers": {
"remote-lsp": {
"languages": ["custom"],
"transport": "tcp",
"socket": {
"host": "127.0.0.1",
"port": 9999
}
"remote-lsp": {
"transport": "tcp",
"socket": {
"host": "127.0.0.1",
"port": 9999
},
"extensionToLanguage": {
".custom": "custom"
}
}
}
@ -119,6 +130,7 @@ Qwen Code exposes LSP functionality through the unified `lsp` tool. Here are the
### Code Navigation
#### Go to Definition
Find where a symbol is defined.
```
@ -130,6 +142,7 @@ Parameters:
```
#### Find References
Find all references to a symbol.
```
@ -142,6 +155,7 @@ Parameters:
```
#### Go to Implementation
Find implementations of an interface or abstract method.
```
@ -155,6 +169,7 @@ Parameters:
### Symbol Information
#### Hover
Get documentation and type information for a symbol.
```
@ -166,6 +181,7 @@ Parameters:
```
#### Document Symbols
Get all symbols in a document.
```
@ -175,6 +191,7 @@ Parameters:
```
#### Workspace Symbol Search
Search for symbols across the workspace.
```
@ -187,6 +204,7 @@ Parameters:
### Call Hierarchy
#### Prepare Call Hierarchy
Get the call hierarchy item at a position.
```
@ -198,6 +216,7 @@ Parameters:
```
#### Incoming Calls
Find all functions that call the given function.
```
@ -207,6 +226,7 @@ Parameters:
```
#### Outgoing Calls
Find all functions called by the given function.
```
@ -218,6 +238,7 @@ Parameters:
### Diagnostics
#### File Diagnostics
Get diagnostic messages (errors, warnings) for a file.
```
@ -227,6 +248,7 @@ Parameters:
```
#### Workspace Diagnostics
Get all diagnostic messages across the workspace.
```
@ -238,6 +260,7 @@ Parameters:
### Code Actions
#### Get Code Actions
Get available code actions (quick fixes, refactorings) at a location.
```
@ -253,6 +276,7 @@ Parameters:
```
Code action kinds:
- `quickfix` - Quick fixes for errors/warnings
- `refactor` - Refactoring operations
- `refactor.extract` - Extract to function/variable
@ -268,19 +292,23 @@ LSP servers are only started in trusted workspaces by default. This is because l
### Trust Controls
- **Trusted Workspace**: LSP servers start automatically
- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false`
- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` is set in the server configuration
To mark a workspace as trusted, use the `/trust` command or configure trusted folders in settings.
### Server Allowlists
### Per-Server Trust Override
You can restrict which servers are allowed to run:
You can override trust requirements for specific servers in their configuration:
```json
{
"lsp": {
"allowed": ["typescript-language-server", "gopls"],
"excluded": ["untrusted-server"]
"safe-server": {
"command": "safe-language-server",
"args": ["--stdio"],
"trustRequired": false,
"extensionToLanguage": {
".safe": "safe"
}
}
}
```
@ -293,12 +321,12 @@ You can restrict which servers are allowed to run:
2. **Check the PATH**: Ensure the server binary is in your system PATH
3. **Check workspace trust**: The workspace must be trusted for LSP
4. **Check logs**: Look for error messages in the console output
5. **Verify --experimental-lsp flag**: Make sure you're using the flag when starting Qwen Code
### Slow Performance
1. **Large projects**: Consider excluding `node_modules` and other large directories
2. **Server timeout**: Increase `lsp.serverTimeout` for slow servers
3. **Multiple servers**: Exclude unused language servers
2. **Server timeout**: Increase `startupTimeout` in server configuration for slow servers
### No Results
@ -311,39 +339,40 @@ You can restrict which servers are allowed to run:
Enable debug logging to see LSP communication:
```bash
DEBUG=lsp* qwen
DEBUG=lsp* qwen --experimental-lsp
```
Or check the LSP debugging guide at `packages/cli/LSP_DEBUGGING_GUIDE.md`.
## Claude Code Compatibility
Qwen Code supports Claude Code-style `.lsp.json` configuration files. If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes.
Qwen Code supports Claude Code-style `.lsp.json` configuration files as defined in the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference#lsp-servers). If you're migrating from Claude Code, your existing LSP configuration should work with minimal changes.
### Legacy Format
### Configuration Format
The legacy format (used by earlier versions) is still supported but deprecated:
The recommended format follows Claude Code's specification:
```json
{
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"transport": "stdio"
"go": {
"command": "gopls",
"args": ["serve"],
"extensionToLanguage": {
".go": "go"
}
}
}
```
We recommend migrating to the new `languageServers` format:
The `languageServers` wrapper format is also supported:
```json
{
"languageServers": {
"typescript-language-server": {
"languages": ["typescript", "javascript"],
"command": "typescript-language-server",
"args": ["--stdio"],
"transport": "stdio"
"gopls": {
"languages": ["go"],
"command": "gopls",
"args": ["serve"]
}
}
}
@ -352,12 +381,20 @@ We recommend migrating to the new `languageServers` format:
## Best Practices
1. **Install language servers globally**: This ensures they're available in all projects
2. **Use project-specific settings**: Configure server options per project when needed
2. **Use project-specific settings**: Configure server options per project when needed via `.lsp.json`
3. **Keep servers updated**: Update your language servers regularly for best results
4. **Trust wisely**: Only trust workspaces from trusted sources
## FAQ
### Q: How do I enable LSP?
Use the `--experimental-lsp` flag when starting Qwen Code:
```bash
qwen --experimental-lsp
```
### Q: How do I know which language servers are running?
Use the `/lsp status` command to see all configured and running language servers.
@ -369,15 +406,3 @@ Yes, but only one will be used for each operation. The first server that returns
### Q: Does LSP work in sandbox mode?
LSP servers run outside the sandbox to access your code. They're subject to workspace trust controls.
### Q: How do I disable LSP for a specific project?
Add to your project's `.qwen/settings.json`:
```json
{
"lsp": {
"enabled": false
}
}
```

8
package-lock.json generated
View file

@ -39,7 +39,6 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",
@ -10816,13 +10815,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",

View file

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

@ -17,25 +17,41 @@ DEBUG_MODE=true qwen [你的命令]
## 2. LSP 配置选项
LSP 功能通过设置系统配置,包含以下选项
LSP 功能通过 `--experimental-lsp` 命令行参数启用。服务器配置通过以下方式定义
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
- `.lsp.json` 文件:在项目根目录创建配置文件
- `lsp.languageServers`:在 `settings.json` 中内联配置
在 settings.json 中的示例配置
### 在 settings.json 中的示例配置
```json
{
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server", "pylsp"],
"excluded": ["gopls"]
"languageServers": {
"typescript-language-server": {
"languages": ["typescript", "javascript"],
"command": "typescript-language-server",
"args": ["--stdio"]
}
}
}
}
```
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
### 在 .lsp.json 中的示例配置
```json
{
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensionToLanguage": {
".ts": "typescript",
".tsx": "typescriptreact"
}
}
}
```
## 3. NativeLspService 调试功能

View file

@ -600,42 +600,17 @@ describe('loadCliConfig', () => {
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js', '--experimental-lsp'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
lsp: {
allowed: ['typescript-language-server'],
excluded: ['pylsp'],
},
};
const settings: Settings = {};
const config = await loadCliConfig(settings, argv);
// LSP is enabled via --experimental-lsp flag
expect(config.isLspEnabled()).toBe(true);
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
expect(config.getLspExcluded()).toEqual(['pylsp']);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
const lspInstance = getLastLspInstance();
expect(lspInstance).toBeDefined();
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
const options = nativeLspServiceMock.mock.calls[0][5];
expect(options?.allowedServers).toEqual(['typescript-language-server']);
expect(options?.excludedServers).toEqual(['pylsp']);
});
it('should skip native LSP startup when startLsp option is false', async () => {
process.argv = ['node', 'script.js', '--experimental-lsp'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, undefined, {
startLsp: false,
});
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).not.toHaveBeenCalled();
expect(getLastLspInstance()).toBeUndefined();
});
describe('Proxy configuration', () => {

View file

@ -151,14 +151,6 @@ export interface CliArgs {
channel: string | undefined;
}
export interface LoadCliConfigOptions {
/**
* Whether to start the native LSP service during config load.
* Disable when doing preflight runs (e.g., sandbox preparation).
*/
startLsp?: boolean;
}
class NativeLspClient implements LspClient {
constructor(private readonly service: NativeLspService) {}
@ -819,7 +811,6 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
options: LoadCliConfigOptions = {},
): Promise<Config> {
const debugMode = isDebugMode(argv);
@ -877,9 +868,6 @@ export async function loadCliConfig(
// LSP configuration: enabled only via --experimental-lsp flag
const lspEnabled = argv.experimentalLsp === true;
const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed;
const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded;
const lspLanguageServers = settings.lsp?.languageServers;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
@ -1186,13 +1174,10 @@ export async function loadCliConfig(
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
allowed: lspAllowed,
excluded: lspExcluded,
},
});
const shouldStartLsp = options.startLsp ?? true;
if (shouldStartLsp && lspEnabled) {
if (lspEnabled) {
try {
const lspService = new NativeLspService(
config,
@ -1201,10 +1186,7 @@ export async function loadCliConfig(
fileService,
ideContextStore,
{
allowedServers: lspAllowed,
excludedServers: lspExcluded,
requireTrustedWorkspace: folderTrust,
inlineServerConfigs: lspLanguageServers,
},
);

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

@ -150,39 +150,6 @@ export function getSystemDefaultsPath(): string {
);
}
function getVsCodeSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, '.vscode', 'settings.json');
}
function loadVsCodeSettings(workspaceDir: string): Settings {
const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir);
try {
if (fs.existsSync(vscodeSettingsPath)) {
const content = fs.readFileSync(vscodeSettingsPath, 'utf-8');
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
if (
typeof rawSettings !== 'object' ||
rawSettings === null ||
Array.isArray(rawSettings)
) {
console.error(
`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`,
);
return {};
}
return rawSettings as Settings;
}
} catch (error: unknown) {
console.error(
`Error loading VS Code settings from ${vscodeSettingsPath}:`,
getErrorMessage(error),
);
}
return {};
}
export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
@ -746,9 +713,6 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
// Load VS Code settings as an additional source of configuration
const vscodeSettings = loadVsCodeSettings(workspaceDir);
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
@ -853,14 +817,6 @@ export function loadSettings(
userSettings = resolveEnvVarsInObject(userResult.settings);
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
// Merge VS Code settings into workspace settings (VS Code settings take precedence)
workspaceSettings = customDeepMerge(
getMergeStrategyForPath,
{},
workspaceSettings,
vscodeSettings,
) as Settings;
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
userSettings.ui.theme = DefaultLight.name;
@ -874,13 +830,11 @@ export function loadSettings(
}
// For the initial trust check, we can only use user and system settings.
// We also include VS Code settings as they may contain trust-related settings
const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath,
{},
systemSettings,
userSettings,
vscodeSettings, // Include VS Code settings
);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
@ -894,18 +848,9 @@ export function loadSettings(
isTrusted,
);
// Add VS Code settings to the temp merged settings for environment loading
// Since loadEnvironment depends on settings, we need to consider VS Code settings as well
const tempMergedSettingsWithVsCode = customDeepMerge(
getMergeStrategyForPath,
{},
tempMergedSettings,
vscodeSettings,
) as Settings;
// loadEnviroment depends on settings so we have to create a temp version of
// the settings to avoid a cycle
loadEnvironment(tempMergedSettingsWithVsCode);
loadEnvironment(tempMergedSettings);
// Create LoadedSettings first

View file

@ -967,59 +967,6 @@ const SETTINGS_SCHEMA = {
},
},
},
lsp: {
type: 'object',
label: 'LSP',
category: 'LSP',
requiresRestart: true,
default: {},
description:
'Settings for the native Language Server Protocol integration. Enable with --experimental-lsp flag.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable LSP',
category: 'LSP',
requiresRestart: true,
default: false,
description:
'Enable the native LSP client. Prefer using --experimental-lsp command line flag instead.',
showInDialog: false,
},
allowed: {
type: 'array',
label: 'Allow LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional allowlist of LSP server names. If set, only matching servers will start.',
showInDialog: false,
},
excluded: {
type: 'array',
label: 'Exclude LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional blocklist of LSP server names that should not start.',
showInDialog: false,
},
languageServers: {
type: 'object',
label: 'LSP Language Servers',
category: 'LSP',
requiresRestart: true,
default: {} as Record<string, unknown>,
description:
'Inline LSP server configuration (same format as .lsp.json).',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',

View file

@ -247,7 +247,6 @@ export async function main() {
argv,
undefined,
[],
{ startLsp: false },
);
if (!settings.merged.security?.auth?.useExternal) {

View file

@ -0,0 +1,458 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'path';
import { pathToFileURL } from 'url';
import type {
LspInitializationOptions,
LspServerConfig,
LspSocketOptions,
} from './LspTypes.js';
export class LspConfigLoader {
private warnedLegacyConfig = false;
constructor(private readonly workspaceRoot: string) {}
/**
* Load user .lsp.json configuration
*/
async loadUserConfigs(): Promise<LspServerConfig[]> {
const configs: LspServerConfig[] = [];
const sources: Array<{ origin: string; data: unknown }> = [];
const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json');
if (fs.existsSync(lspConfigPath)) {
try {
const configContent = fs.readFileSync(lspConfigPath, 'utf-8');
sources.push({
origin: lspConfigPath,
data: JSON.parse(configContent),
});
} catch (error) {
console.warn('Failed to load user .lsp.json config:', error);
}
}
for (const source of sources) {
const parsed = this.parseConfigSource(source.data, source.origin);
if (parsed.usedLegacyFormat && parsed.configs.length > 0) {
this.warnLegacyConfig(source.origin);
}
configs.push(...parsed.configs);
}
return configs;
}
/**
* Merge configs: built-in presets + user configs + compatibility layer
*/
mergeConfigs(
detectedLanguages: string[],
userConfigs: LspServerConfig[],
): LspServerConfig[] {
// Built-in preset configurations
const presets = this.getBuiltInPresets(detectedLanguages);
// Merge configs, user configs take priority
const mergedConfigs = [...presets];
for (const userConfig of userConfigs) {
// Find if there's a preset with the same name, if so replace it
const existingIndex = mergedConfigs.findIndex(
(c) => c.name === userConfig.name,
);
if (existingIndex !== -1) {
mergedConfigs[existingIndex] = userConfig;
} else {
mergedConfigs.push(userConfig);
}
}
return mergedConfigs;
}
collectExtensionToLanguageOverrides(
configs: LspServerConfig[],
): Record<string, string> {
const overrides: Record<string, string> = {};
for (const config of configs) {
if (!config.extensionToLanguage) {
continue;
}
for (const [key, value] of Object.entries(config.extensionToLanguage)) {
if (typeof value !== 'string') {
continue;
}
const normalized = key.startsWith('.') ? key.slice(1) : key;
if (!normalized) {
continue;
}
overrides[normalized.toLowerCase()] = value;
}
}
return overrides;
}
/**
* Get built-in preset configurations
*/
private getBuiltInPresets(detectedLanguages: string[]): LspServerConfig[] {
const presets: LspServerConfig[] = [];
// Convert directory path to file URI format
const rootUri = pathToFileURL(this.workspaceRoot).toString();
// Generate corresponding LSP server config based on detected languages
if (
detectedLanguages.includes('typescript') ||
detectedLanguages.includes('javascript')
) {
presets.push({
name: 'typescript-language-server',
languages: [
'typescript',
'javascript',
'typescriptreact',
'javascriptreact',
],
command: 'typescript-language-server',
args: ['--stdio'],
transport: 'stdio',
initializationOptions: {},
rootUri,
workspaceFolder: this.workspaceRoot,
trustRequired: true,
});
}
if (detectedLanguages.includes('python')) {
presets.push({
name: 'pylsp',
languages: ['python'],
command: 'pylsp',
args: [],
transport: 'stdio',
initializationOptions: {},
rootUri,
workspaceFolder: this.workspaceRoot,
trustRequired: true,
});
}
if (detectedLanguages.includes('go')) {
presets.push({
name: 'gopls',
languages: ['go'],
command: 'gopls',
args: [],
transport: 'stdio',
initializationOptions: {},
rootUri,
workspaceFolder: this.workspaceRoot,
trustRequired: true,
});
}
// Additional language presets can be added as needed
return presets;
}
private parseConfigSource(
source: unknown,
origin: string,
): { configs: LspServerConfig[]; usedLegacyFormat: boolean } {
if (!this.isRecord(source)) {
return { configs: [], usedLegacyFormat: false };
}
const configs: LspServerConfig[] = [];
let serverMap: Record<string, unknown> = source;
let usedLegacyFormat = false;
if (this.isRecord(source['languageServers'])) {
serverMap = source['languageServers'] as Record<string, unknown>;
} else if (this.isNewFormatServerMap(source)) {
serverMap = source;
} else {
usedLegacyFormat = true;
}
for (const [key, spec] of Object.entries(serverMap)) {
if (!this.isRecord(spec)) {
continue;
}
const languagesValue = spec['languages'];
const languages = usedLegacyFormat
? [key]
: (this.normalizeStringArray(languagesValue) ??
(typeof languagesValue === 'string' ? [languagesValue] : []));
const name = usedLegacyFormat
? typeof spec['command'] === 'string'
? (spec['command'] as string)
: key
: key;
const config = this.buildServerConfig(name, languages, spec, origin);
if (config) {
configs.push(config);
}
}
return { configs, usedLegacyFormat };
}
private buildServerConfig(
name: string,
languages: string[],
spec: Record<string, unknown>,
origin: string,
): LspServerConfig | null {
const transport = this.normalizeTransport(spec['transport']);
const command =
typeof spec['command'] === 'string'
? (spec['command'] as string)
: undefined;
const args = this.normalizeStringArray(spec['args']) ?? [];
const env = this.normalizeEnv(spec['env']);
const initializationOptions = this.isRecord(spec['initializationOptions'])
? (spec['initializationOptions'] as LspInitializationOptions)
: undefined;
const settings = this.isRecord(spec['settings'])
? (spec['settings'] as Record<string, unknown>)
: undefined;
const extensionToLanguage = this.normalizeExtensionToLanguage(
spec['extensionToLanguage'],
);
const workspaceFolder = this.resolveWorkspaceFolder(
spec['workspaceFolder'],
);
const rootUri = pathToFileURL(workspaceFolder).toString();
const startupTimeout = this.normalizeTimeout(spec['startupTimeout']);
const shutdownTimeout = this.normalizeTimeout(spec['shutdownTimeout']);
const restartOnCrash =
typeof spec['restartOnCrash'] === 'boolean'
? (spec['restartOnCrash'] as boolean)
: undefined;
const maxRestarts = this.normalizeMaxRestarts(spec['maxRestarts']);
const trustRequired =
typeof spec['trustRequired'] === 'boolean'
? (spec['trustRequired'] as boolean)
: true;
const socket = this.normalizeSocketOptions(spec);
if (transport === 'stdio' && !command) {
console.warn(`LSP config error in ${origin}: ${name} missing command`);
return null;
}
if (transport !== 'stdio' && !socket) {
console.warn(
`LSP config error in ${origin}: ${name} missing socket info`,
);
return null;
}
return {
name,
languages,
command,
args,
transport,
env,
initializationOptions,
settings,
extensionToLanguage,
rootUri,
workspaceFolder,
startupTimeout,
shutdownTimeout,
restartOnCrash,
maxRestarts,
trustRequired,
socket,
};
}
private isNewFormatServerMap(value: Record<string, unknown>): boolean {
return Object.values(value).some(
(entry) => this.isRecord(entry) && this.isNewFormatServerSpec(entry),
);
}
private isNewFormatServerSpec(value: Record<string, unknown>): boolean {
return (
Array.isArray(value['languages']) ||
this.isRecord(value['extensionToLanguage']) ||
this.isRecord(value['settings']) ||
value['workspaceFolder'] !== undefined ||
value['startupTimeout'] !== undefined ||
value['shutdownTimeout'] !== undefined ||
value['restartOnCrash'] !== undefined ||
value['maxRestarts'] !== undefined ||
this.isRecord(value['env']) ||
value['socket'] !== undefined
);
}
private warnLegacyConfig(origin: string): void {
if (this.warnedLegacyConfig) {
return;
}
console.warn(
`Legacy LSP config detected in ${origin}. Please migrate to the languageServers format.`,
);
this.warnedLegacyConfig = true;
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value.filter((item): item is string => typeof item === 'string');
}
private normalizeEnv(value: unknown): Record<string, string> | undefined {
if (!this.isRecord(value)) {
return undefined;
}
const env: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {
if (
typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean'
) {
env[key] = String(val);
}
}
return Object.keys(env).length > 0 ? env : undefined;
}
private normalizeExtensionToLanguage(
value: unknown,
): Record<string, string> | undefined {
if (!this.isRecord(value)) {
return undefined;
}
const mapping: Record<string, string> = {};
for (const [key, lang] of Object.entries(value)) {
if (typeof lang !== 'string') {
continue;
}
const normalized = key.startsWith('.') ? key.slice(1) : key;
if (!normalized) {
continue;
}
mapping[normalized.toLowerCase()] = lang;
}
return Object.keys(mapping).length > 0 ? mapping : undefined;
}
private normalizeTransport(value: unknown): 'stdio' | 'tcp' | 'socket' {
if (typeof value !== 'string') {
return 'stdio';
}
const normalized = value.toLowerCase();
if (normalized === 'tcp' || normalized === 'socket') {
return normalized;
}
return 'stdio';
}
private normalizeTimeout(value: unknown): number | undefined {
if (typeof value !== 'number') {
return undefined;
}
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
private normalizeMaxRestarts(value: unknown): number | undefined {
if (typeof value !== 'number') {
return undefined;
}
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
private normalizeSocketOptions(
value: Record<string, unknown>,
): LspSocketOptions | undefined {
const socketValue = value['socket'];
if (typeof socketValue === 'string') {
return { path: socketValue };
}
const source = this.isRecord(socketValue) ? socketValue : value;
const host =
typeof source['host'] === 'string'
? (source['host'] as string)
: undefined;
const pathValue =
typeof source['path'] === 'string'
? (source['path'] as string)
: typeof source['socketPath'] === 'string'
? (source['socketPath'] as string)
: undefined;
const portValue = source['port'];
const port =
typeof portValue === 'number'
? portValue
: typeof portValue === 'string'
? Number(portValue)
: undefined;
const socket: LspSocketOptions = {};
if (host) {
socket.host = host;
}
if (Number.isFinite(port) && (port as number) > 0) {
socket.port = port as number;
}
if (pathValue) {
socket.path = pathValue;
}
if (!socket.path && !socket.port) {
return undefined;
}
return socket;
}
private resolveWorkspaceFolder(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
return this.workspaceRoot;
}
const resolved = path.isAbsolute(value)
? path.resolve(value)
: path.resolve(this.workspaceRoot, value);
const root = path.resolve(this.workspaceRoot);
if (resolved === root || resolved.startsWith(root + path.sep)) {
return resolved;
}
console.warn(
`LSP workspaceFolder must be within ${this.workspaceRoot}; using workspace root instead.`,
);
return this.workspaceRoot;
}
}

View file

@ -1,5 +1,13 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as cp from 'node:child_process';
import * as net from 'node:net';
import { DEFAULT_LSP_REQUEST_TIMEOUT_MS } from './constants.js';
import type { JsonRpcMessage } from './LspTypes.js';
interface PendingRequest {
resolve: (value: unknown) => void;
@ -88,7 +96,7 @@ class JsonRpcConnection {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`LSP request timeout: ${method}`));
}, 15000);
}, DEFAULT_LSP_REQUEST_TIMEOUT_MS);
this.pendingRequests.set(id, { resolve, reject, timer });
});
@ -234,19 +242,6 @@ interface SocketConnectionOptions {
path?: string;
}
interface JsonRpcMessage {
jsonrpc: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export class LspConnectionFactory {
/**
* stdio LSP

View file

@ -0,0 +1,222 @@
/**
* LSP Language Detector
*
* Detects programming languages in a workspace by analyzing file extensions
* and root marker files (e.g., package.json, tsconfig.json).
*/
import * as fs from 'node:fs';
import * as path from 'path';
import { globSync } from 'glob';
import type {
WorkspaceContext,
FileDiscoveryService,
} from '@qwen-code/qwen-code-core';
/**
* Extension to language ID mapping
*/
const DEFAULT_EXTENSION_TO_LANGUAGE: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
jsx: 'javascriptreact',
tsx: 'typescriptreact',
py: 'python',
go: 'go',
rs: 'rust',
java: 'java',
cpp: 'cpp',
c: 'c',
php: 'php',
rb: 'ruby',
cs: 'csharp',
vue: 'vue',
svelte: 'svelte',
html: 'html',
css: 'css',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
};
/**
* Root marker file to language ID mapping
*/
const MARKER_TO_LANGUAGE: Record<string, string> = {
'package.json': 'javascript',
'tsconfig.json': 'typescript',
'pyproject.toml': 'python',
'go.mod': 'go',
'Cargo.toml': 'rust',
'pom.xml': 'java',
'build.gradle': 'java',
'composer.json': 'php',
Gemfile: 'ruby',
'*.sln': 'csharp',
'mix.exs': 'elixir',
'deno.json': 'deno',
};
/**
* Common root marker files to look for
*/
const COMMON_MARKERS = [
'package.json',
'tsconfig.json',
'pyproject.toml',
'go.mod',
'Cargo.toml',
'pom.xml',
'build.gradle',
'composer.json',
'Gemfile',
'mix.exs',
'deno.json',
];
/**
* Default exclude patterns for file search
*/
const DEFAULT_EXCLUDE_PATTERNS = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
];
/**
* Detects programming languages in a workspace.
*/
export class LspLanguageDetector {
constructor(
private readonly workspaceContext: WorkspaceContext,
private readonly fileDiscoveryService: FileDiscoveryService,
) {}
/**
* Detect programming languages in workspace by analyzing files and markers.
* Returns languages sorted by frequency (most common first).
*
* @param extensionOverrides - Custom extension to language mappings
* @returns Array of detected language IDs
*/
async detectLanguages(
extensionOverrides: Record<string, string> = {},
): Promise<string[]> {
const extensionMap = this.getExtensionToLanguageMap(extensionOverrides);
const extensions = Object.keys(extensionMap);
const patterns =
extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*'];
const files = new Set<string>();
const searchRoots = this.workspaceContext.getDirectories();
for (const root of searchRoots) {
for (const pattern of patterns) {
try {
const matches = globSync(pattern, {
cwd: root,
ignore: DEFAULT_EXCLUDE_PATTERNS,
absolute: true,
nodir: true,
});
for (const match of matches) {
if (this.fileDiscoveryService.shouldIgnoreFile(match)) {
continue;
}
files.add(match);
}
} catch {
// Ignore glob errors for missing/invalid directories
}
}
}
// Count files per language
const languageCounts = new Map<string, number>();
for (const file of Array.from(files)) {
const ext = path.extname(file).slice(1).toLowerCase();
if (ext) {
const lang = this.mapExtensionToLanguage(ext, extensionMap);
if (lang) {
languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1);
}
}
}
// Also detect languages via root marker files
const rootMarkers = await this.detectRootMarkers();
for (const marker of rootMarkers) {
const lang = this.mapMarkerToLanguage(marker);
if (lang) {
// Give higher weight to config files
const currentCount = languageCounts.get(lang) || 0;
languageCounts.set(lang, currentCount + 100);
}
}
// Return languages sorted by count (descending)
return Array.from(languageCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([lang]) => lang);
}
/**
* Detect root marker files in workspace directories
*/
private async detectRootMarkers(): Promise<string[]> {
const markers = new Set<string>();
for (const root of this.workspaceContext.getDirectories()) {
for (const marker of COMMON_MARKERS) {
try {
const fullPath = path.join(root, marker);
if (fs.existsSync(fullPath)) {
markers.add(marker);
}
} catch {
// ignore missing files
}
}
}
return Array.from(markers);
}
/**
* Map file extension to programming language ID
*/
private mapExtensionToLanguage(
ext: string,
extensionMap: Record<string, string>,
): string | null {
return extensionMap[ext] || null;
}
/**
* Get extension to language mapping with overrides applied
*/
private getExtensionToLanguageMap(
extensionOverrides: Record<string, string> = {},
): Record<string, string> {
const extToLang = { ...DEFAULT_EXTENSION_TO_LANGUAGE };
for (const [key, value] of Object.entries(extensionOverrides)) {
const normalized = key.startsWith('.') ? key.slice(1) : key;
if (!normalized) {
continue;
}
extToLang[normalized.toLowerCase()] = value;
}
return extToLang;
}
/**
* Map root marker file to programming language ID
*/
private mapMarkerToLanguage(marker: string): string | null {
return MARKER_TO_LANGUAGE[marker] || null;
}
}

View file

@ -0,0 +1,911 @@
/**
* LSP Response Normalizer
*
* Converts raw LSP protocol responses to normalized internal types.
* Handles various response formats from different language servers.
*/
import type {
LspCallHierarchyIncomingCall,
LspCallHierarchyItem,
LspCallHierarchyOutgoingCall,
LspCodeAction,
LspCodeActionKind,
LspDiagnostic,
LspDiagnosticSeverity,
LspFileDiagnostics,
LspHoverResult,
LspLocation,
LspRange,
LspReference,
LspSymbolInformation,
LspTextEdit,
LspWorkspaceEdit,
} from '@qwen-code/qwen-code-core';
import {
CODE_ACTION_KIND_LABELS,
DIAGNOSTIC_SEVERITY_LABELS,
SYMBOL_KIND_LABELS,
} from './constants.js';
/**
* Normalizes LSP protocol responses to internal types.
*/
export class LspResponseNormalizer {
// ============================================================================
// Diagnostic Normalization
// ============================================================================
/**
* Normalize diagnostic result from LSP response
*/
normalizeDiagnostic(item: unknown, serverName: string): LspDiagnostic | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const range = this.normalizeRange(itemObj['range']);
if (!range) {
return null;
}
const message =
typeof itemObj['message'] === 'string'
? (itemObj['message'] as string)
: '';
if (!message) {
return null;
}
const severityNum =
typeof itemObj['severity'] === 'number'
? (itemObj['severity'] as number)
: undefined;
const severity = severityNum
? DIAGNOSTIC_SEVERITY_LABELS[severityNum]
: undefined;
const code = itemObj['code'];
const codeValue =
typeof code === 'string' || typeof code === 'number' ? code : undefined;
const source =
typeof itemObj['source'] === 'string'
? (itemObj['source'] as string)
: undefined;
const tags = this.normalizeDiagnosticTags(itemObj['tags']);
const relatedInfo = this.normalizeDiagnosticRelatedInfo(
itemObj['relatedInformation'],
);
return {
range,
severity,
code: codeValue,
source,
message,
tags: tags.length > 0 ? tags : undefined,
relatedInformation: relatedInfo.length > 0 ? relatedInfo : undefined,
serverName,
};
}
/**
* Convert diagnostic back to LSP format for requests
*/
denormalizeDiagnostic(diagnostic: LspDiagnostic): Record<string, unknown> {
const severityMap: Record<LspDiagnosticSeverity, number> = {
error: 1,
warning: 2,
information: 3,
hint: 4,
};
return {
range: diagnostic.range,
message: diagnostic.message,
severity: diagnostic.severity
? severityMap[diagnostic.severity]
: undefined,
code: diagnostic.code,
source: diagnostic.source,
};
}
/**
* Normalize diagnostic tags
*/
normalizeDiagnosticTags(tags: unknown): Array<'unnecessary' | 'deprecated'> {
if (!Array.isArray(tags)) {
return [];
}
const result: Array<'unnecessary' | 'deprecated'> = [];
for (const tag of tags) {
if (tag === 1) {
result.push('unnecessary');
} else if (tag === 2) {
result.push('deprecated');
}
}
return result;
}
/**
* Normalize diagnostic related information
*/
normalizeDiagnosticRelatedInfo(
info: unknown,
): Array<{ location: LspLocation; message: string }> {
if (!Array.isArray(info)) {
return [];
}
const result: Array<{ location: LspLocation; message: string }> = [];
for (const item of info) {
if (!item || typeof item !== 'object') {
continue;
}
const itemObj = item as Record<string, unknown>;
const location = itemObj['location'];
if (!location || typeof location !== 'object') {
continue;
}
const locObj = location as Record<string, unknown>;
const uri = locObj['uri'];
const range = this.normalizeRange(locObj['range']);
const message = itemObj['message'];
if (typeof uri === 'string' && range && typeof message === 'string') {
result.push({
location: { uri, range },
message,
});
}
}
return result;
}
/**
* Normalize file diagnostics result
*/
normalizeFileDiagnostics(
item: unknown,
serverName: string,
): LspFileDiagnostics | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const uri =
typeof itemObj['uri'] === 'string' ? (itemObj['uri'] as string) : '';
if (!uri) {
return null;
}
const items = itemObj['items'];
if (!Array.isArray(items)) {
return null;
}
const diagnostics: LspDiagnostic[] = [];
for (const diagItem of items) {
const normalized = this.normalizeDiagnostic(diagItem, serverName);
if (normalized) {
diagnostics.push(normalized);
}
}
return {
uri,
diagnostics,
serverName,
};
}
// ============================================================================
// Code Action Normalization
// ============================================================================
/**
* Normalize code action result
*/
normalizeCodeAction(item: unknown, serverName: string): LspCodeAction | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
// Check if this is a Command instead of CodeAction
if (
itemObj['command'] &&
typeof itemObj['title'] === 'string' &&
!itemObj['kind']
) {
// This is a raw Command, wrap it
return {
title: itemObj['title'] as string,
command: {
title: itemObj['title'] as string,
command: (itemObj['command'] as string) ?? '',
arguments: itemObj['arguments'] as unknown[] | undefined,
},
serverName,
};
}
const title =
typeof itemObj['title'] === 'string' ? (itemObj['title'] as string) : '';
if (!title) {
return null;
}
const kind =
typeof itemObj['kind'] === 'string'
? (CODE_ACTION_KIND_LABELS[itemObj['kind'] as string] ??
(itemObj['kind'] as LspCodeActionKind))
: undefined;
const isPreferred =
typeof itemObj['isPreferred'] === 'boolean'
? (itemObj['isPreferred'] as boolean)
: undefined;
const edit = this.normalizeWorkspaceEdit(itemObj['edit']);
const command = this.normalizeCommand(itemObj['command']);
const diagnostics: LspDiagnostic[] = [];
if (Array.isArray(itemObj['diagnostics'])) {
for (const diag of itemObj['diagnostics']) {
const normalized = this.normalizeDiagnostic(diag, serverName);
if (normalized) {
diagnostics.push(normalized);
}
}
}
return {
title,
kind,
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
isPreferred,
edit: edit ?? undefined,
command: command ?? undefined,
data: itemObj['data'],
serverName,
};
}
// ============================================================================
// Workspace Edit Normalization
// ============================================================================
/**
* Normalize workspace edit
*/
normalizeWorkspaceEdit(edit: unknown): LspWorkspaceEdit | null {
if (!edit || typeof edit !== 'object') {
return null;
}
const editObj = edit as Record<string, unknown>;
const result: LspWorkspaceEdit = {};
// Handle changes (map of URI to TextEdit[])
if (editObj['changes'] && typeof editObj['changes'] === 'object') {
const changes = editObj['changes'] as Record<string, unknown>;
result.changes = {};
for (const [uri, edits] of Object.entries(changes)) {
if (Array.isArray(edits)) {
const normalizedEdits: LspTextEdit[] = [];
for (const e of edits) {
const normalized = this.normalizeTextEdit(e);
if (normalized) {
normalizedEdits.push(normalized);
}
}
if (normalizedEdits.length > 0) {
result.changes[uri] = normalizedEdits;
}
}
}
}
// Handle documentChanges
if (Array.isArray(editObj['documentChanges'])) {
result.documentChanges = [];
for (const docChange of editObj['documentChanges']) {
const normalized = this.normalizeTextDocumentEdit(docChange);
if (normalized) {
result.documentChanges.push(normalized);
}
}
}
if (
(!result.changes || Object.keys(result.changes).length === 0) &&
(!result.documentChanges || result.documentChanges.length === 0)
) {
return null;
}
return result;
}
/**
* Normalize text edit
*/
normalizeTextEdit(edit: unknown): LspTextEdit | null {
if (!edit || typeof edit !== 'object') {
return null;
}
const editObj = edit as Record<string, unknown>;
const range = this.normalizeRange(editObj['range']);
if (!range) {
return null;
}
const newText =
typeof editObj['newText'] === 'string'
? (editObj['newText'] as string)
: '';
return { range, newText };
}
/**
* Normalize text document edit
*/
normalizeTextDocumentEdit(docEdit: unknown): {
textDocument: { uri: string; version?: number | null };
edits: LspTextEdit[];
} | null {
if (!docEdit || typeof docEdit !== 'object') {
return null;
}
const docEditObj = docEdit as Record<string, unknown>;
const textDocument = docEditObj['textDocument'];
if (!textDocument || typeof textDocument !== 'object') {
return null;
}
const textDocObj = textDocument as Record<string, unknown>;
const uri =
typeof textDocObj['uri'] === 'string'
? (textDocObj['uri'] as string)
: '';
if (!uri) {
return null;
}
const version =
typeof textDocObj['version'] === 'number'
? (textDocObj['version'] as number)
: null;
const edits = docEditObj['edits'];
if (!Array.isArray(edits)) {
return null;
}
const normalizedEdits: LspTextEdit[] = [];
for (const e of edits) {
const normalized = this.normalizeTextEdit(e);
if (normalized) {
normalizedEdits.push(normalized);
}
}
if (normalizedEdits.length === 0) {
return null;
}
return {
textDocument: { uri, version },
edits: normalizedEdits,
};
}
/**
* Normalize command
*/
normalizeCommand(
cmd: unknown,
): { title: string; command: string; arguments?: unknown[] } | null {
if (!cmd || typeof cmd !== 'object') {
return null;
}
const cmdObj = cmd as Record<string, unknown>;
const title =
typeof cmdObj['title'] === 'string' ? (cmdObj['title'] as string) : '';
const command =
typeof cmdObj['command'] === 'string'
? (cmdObj['command'] as string)
: '';
if (!command) {
return null;
}
const args = Array.isArray(cmdObj['arguments'])
? (cmdObj['arguments'] as unknown[])
: undefined;
return { title, command, arguments: args };
}
// ============================================================================
// Location and Symbol Normalization
// ============================================================================
/**
* Normalize location result (definitions, references, implementations)
*/
normalizeLocationResult(
item: unknown,
serverName: string,
): LspReference | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const uri = (itemObj['uri'] ??
itemObj['targetUri'] ??
(itemObj['target'] as Record<string, unknown>)?.['uri']) as
| string
| undefined;
const range = (itemObj['range'] ??
itemObj['targetSelectionRange'] ??
itemObj['targetRange'] ??
(itemObj['target'] as Record<string, unknown>)?.['range']) as
| { start?: unknown; end?: unknown }
| undefined;
if (!uri || !range?.start || !range?.end) {
return null;
}
const start = range.start as { line?: number; character?: number };
const end = range.end as { line?: number; character?: number };
return {
uri,
range: {
start: {
line: Number(start?.line ?? 0),
character: Number(start?.character ?? 0),
},
end: {
line: Number(end?.line ?? 0),
character: Number(end?.character ?? 0),
},
},
serverName,
};
}
/**
* Normalize symbol result (workspace symbols, document symbols)
*/
normalizeSymbolResult(
item: unknown,
serverName: string,
): LspSymbolInformation | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const location = itemObj['location'] ?? itemObj['target'] ?? item;
if (!location || typeof location !== 'object') {
return null;
}
const locationObj = location as Record<string, unknown>;
const range = (locationObj['range'] ??
locationObj['targetRange'] ??
itemObj['range'] ??
undefined) as { start?: unknown; end?: unknown } | undefined;
if (!locationObj['uri'] || !range?.start || !range?.end) {
return null;
}
const start = range.start as { line?: number; character?: number };
const end = range.end as { line?: number; character?: number };
return {
name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string,
kind: this.normalizeSymbolKind(itemObj['kind']),
containerName: (itemObj['containerName'] ?? itemObj['container']) as
| string
| undefined,
location: {
uri: locationObj['uri'] as string,
range: {
start: {
line: Number(start?.line ?? 0),
character: Number(start?.character ?? 0),
},
end: {
line: Number(end?.line ?? 0),
character: Number(end?.character ?? 0),
},
},
},
serverName,
};
}
// ============================================================================
// Range Normalization
// ============================================================================
/**
* Normalize a single range
*/
normalizeRange(range: unknown): LspRange | null {
if (!range || typeof range !== 'object') {
return null;
}
const rangeObj = range as Record<string, unknown>;
const start = rangeObj['start'];
const end = rangeObj['end'];
if (
!start ||
typeof start !== 'object' ||
!end ||
typeof end !== 'object'
) {
return null;
}
const startObj = start as Record<string, unknown>;
const endObj = end as Record<string, unknown>;
return {
start: {
line: Number(startObj['line'] ?? 0),
character: Number(startObj['character'] ?? 0),
},
end: {
line: Number(endObj['line'] ?? 0),
character: Number(endObj['character'] ?? 0),
},
};
}
/**
* Normalize an array of ranges
*/
normalizeRanges(ranges: unknown): LspRange[] {
if (!Array.isArray(ranges)) {
return [];
}
const results: LspRange[] = [];
for (const range of ranges) {
const normalized = this.normalizeRange(range);
if (normalized) {
results.push(normalized);
}
}
return results;
}
/**
* Normalize symbol kind from number to string label
*/
normalizeSymbolKind(kind: unknown): string | undefined {
if (typeof kind === 'number') {
return SYMBOL_KIND_LABELS[kind] ?? String(kind);
}
if (typeof kind === 'string') {
const trimmed = kind.trim();
if (trimmed === '') {
return undefined;
}
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && SYMBOL_KIND_LABELS[numeric]) {
return SYMBOL_KIND_LABELS[numeric];
}
return trimmed;
}
return undefined;
}
// ============================================================================
// Hover Normalization
// ============================================================================
/**
* Normalize hover contents to string
*/
normalizeHoverContents(contents: unknown): string {
if (!contents) {
return '';
}
if (typeof contents === 'string') {
return contents;
}
if (Array.isArray(contents)) {
const parts = contents
.map((item) => this.normalizeHoverContents(item))
.map((item) => item.trim())
.filter((item) => item.length > 0);
return parts.join('\n');
}
if (typeof contents === 'object') {
const contentsObj = contents as Record<string, unknown>;
const value = contentsObj['value'];
if (typeof value === 'string') {
const language = contentsObj['language'];
if (typeof language === 'string' && language.trim() !== '') {
return `\`\`\`${language}\n${value}\n\`\`\``;
}
return value;
}
}
return '';
}
/**
* Normalize hover result
*/
normalizeHoverResult(
response: unknown,
serverName: string,
): LspHoverResult | null {
if (!response) {
return null;
}
if (typeof response !== 'object') {
const contents = this.normalizeHoverContents(response);
if (!contents.trim()) {
return null;
}
return {
contents,
serverName,
};
}
const responseObj = response as Record<string, unknown>;
const contents = this.normalizeHoverContents(responseObj['contents']);
if (!contents.trim()) {
return null;
}
const range = this.normalizeRange(responseObj['range']);
return {
contents,
range: range ?? undefined,
serverName,
};
}
// ============================================================================
// Call Hierarchy Normalization
// ============================================================================
/**
* Normalize call hierarchy item
*/
normalizeCallHierarchyItem(
item: unknown,
serverName: string,
): LspCallHierarchyItem | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const nameValue = itemObj['name'] ?? itemObj['label'] ?? 'symbol';
const name =
typeof nameValue === 'string' ? nameValue : String(nameValue ?? '');
const uri = itemObj['uri'];
if (!name || typeof uri !== 'string') {
return null;
}
const range = this.normalizeRange(itemObj['range']);
const selectionRange =
this.normalizeRange(itemObj['selectionRange']) ?? range;
if (!range || !selectionRange) {
return null;
}
const serverOverride =
typeof itemObj['serverName'] === 'string'
? (itemObj['serverName'] as string)
: undefined;
// Preserve raw numeric kind for server communication
let rawKind: number | undefined;
if (typeof itemObj['rawKind'] === 'number') {
rawKind = itemObj['rawKind'];
} else if (typeof itemObj['kind'] === 'number') {
rawKind = itemObj['kind'];
} else if (typeof itemObj['kind'] === 'string') {
const parsed = Number(itemObj['kind']);
if (Number.isFinite(parsed)) {
rawKind = parsed;
}
}
return {
name,
kind: this.normalizeSymbolKind(itemObj['kind']),
rawKind,
detail:
typeof itemObj['detail'] === 'string'
? (itemObj['detail'] as string)
: undefined,
uri,
range,
selectionRange,
data: itemObj['data'],
serverName: serverOverride ?? serverName,
};
}
/**
* Normalize incoming call
*/
normalizeIncomingCall(
item: unknown,
serverName: string,
): LspCallHierarchyIncomingCall | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const from = this.normalizeCallHierarchyItem(itemObj['from'], serverName);
if (!from) {
return null;
}
return {
from,
fromRanges: this.normalizeRanges(itemObj['fromRanges']),
};
}
/**
* Normalize outgoing call
*/
normalizeOutgoingCall(
item: unknown,
serverName: string,
): LspCallHierarchyOutgoingCall | null {
if (!item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const to = this.normalizeCallHierarchyItem(itemObj['to'], serverName);
if (!to) {
return null;
}
return {
to,
fromRanges: this.normalizeRanges(itemObj['fromRanges']),
};
}
/**
* Convert call hierarchy item back to LSP params format
*/
toCallHierarchyItemParams(
item: LspCallHierarchyItem,
): Record<string, unknown> {
// Use rawKind (numeric) for server communication
let numericKind: number | undefined = item.rawKind;
if (numericKind === undefined && item.kind !== undefined) {
const parsed = Number(item.kind);
if (Number.isFinite(parsed)) {
numericKind = parsed;
}
}
return {
name: item.name,
kind: numericKind,
detail: item.detail,
uri: item.uri,
range: item.range,
selectionRange: item.selectionRange,
data: item.data,
};
}
// ============================================================================
// Document Symbol Helpers
// ============================================================================
/**
* Check if item is a DocumentSymbol (has range and selectionRange)
*/
isDocumentSymbol(item: Record<string, unknown>): boolean {
const range = item['range'];
const selectionRange = item['selectionRange'];
return (
typeof range === 'object' &&
range !== null &&
typeof selectionRange === 'object' &&
selectionRange !== null
);
}
/**
* Recursively collect document symbols from a tree structure
*/
collectDocumentSymbol(
item: Record<string, unknown>,
uri: string,
serverName: string,
results: LspSymbolInformation[],
limit: number,
containerName?: string,
): void {
if (results.length >= limit) {
return;
}
const nameValue = item['name'] ?? item['label'] ?? 'symbol';
const name = typeof nameValue === 'string' ? nameValue : String(nameValue);
const selectionRange =
this.normalizeRange(item['selectionRange']) ??
this.normalizeRange(item['range']);
if (!selectionRange) {
return;
}
results.push({
name,
kind: this.normalizeSymbolKind(item['kind']),
containerName,
location: {
uri,
range: selectionRange,
},
serverName,
});
if (results.length >= limit) {
return;
}
const children = item['children'];
if (Array.isArray(children)) {
for (const child of children) {
if (results.length >= limit) {
break;
}
if (child && typeof child === 'object') {
this.collectDocumentSymbol(
child as Record<string, unknown>,
uri,
serverName,
results,
limit,
name,
);
}
}
}
}
}

View file

@ -0,0 +1,713 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
} from '@qwen-code/qwen-code-core';
import { spawn, type ChildProcess } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { globSync } from 'glob';
import { LspConnectionFactory } from './LspConnectionFactory.js';
import {
DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS,
DEFAULT_LSP_MAX_RESTARTS,
DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS,
DEFAULT_LSP_SOCKET_RETRY_DELAY_MS,
DEFAULT_LSP_STARTUP_TIMEOUT_MS,
DEFAULT_LSP_WARMUP_DELAY_MS,
} from './constants.js';
import type {
LspConnectionResult,
LspServerConfig,
LspServerHandle,
LspServerStatus,
LspSocketOptions,
} from './LspTypes.js';
export interface LspServerManagerOptions {
requireTrustedWorkspace: boolean;
workspaceRoot: string;
}
export class LspServerManager {
private serverHandles: Map<string, LspServerHandle> = new Map();
private requireTrustedWorkspace: boolean;
private workspaceRoot: string;
constructor(
private readonly config: CoreConfig,
private readonly workspaceContext: WorkspaceContext,
private readonly fileDiscoveryService: FileDiscoveryService,
options: LspServerManagerOptions,
) {
this.requireTrustedWorkspace = options.requireTrustedWorkspace;
this.workspaceRoot = options.workspaceRoot;
}
setServerConfigs(configs: LspServerConfig[]): void {
this.serverHandles.clear();
for (const config of configs) {
this.serverHandles.set(config.name, {
config,
status: 'NOT_STARTED',
});
}
}
clearServerHandles(): void {
this.serverHandles.clear();
}
getHandles(): ReadonlyMap<string, LspServerHandle> {
return this.serverHandles;
}
getStatus(): Map<string, LspServerStatus> {
const statusMap = new Map<string, LspServerStatus>();
for (const [name, handle] of Array.from(this.serverHandles)) {
statusMap.set(name, handle.status);
}
return statusMap;
}
async startAll(): Promise<void> {
for (const [name, handle] of Array.from(this.serverHandles)) {
await this.startServer(name, handle);
}
}
async stopAll(): Promise<void> {
for (const [name, handle] of Array.from(this.serverHandles)) {
await this.stopServer(name, handle);
}
this.serverHandles.clear();
}
/**
* Ensure tsserver has at least one file open so navto/navtree requests succeed.
* Sets warmedUp flag only after successful warm-up to allow retry on failure.
*/
async warmupTypescriptServer(
handle: LspServerHandle,
force = false,
): Promise<void> {
if (!handle.connection || !this.isTypescriptServer(handle)) {
return;
}
if (handle.warmedUp && !force) {
return;
}
const tsFile = this.findFirstTypescriptFile();
if (!tsFile) {
return;
}
const uri = pathToFileURL(tsFile).toString();
const languageId = tsFile.endsWith('.tsx')
? 'typescriptreact'
: tsFile.endsWith('.jsx')
? 'javascriptreact'
: tsFile.endsWith('.js')
? 'javascript'
: 'typescript';
try {
const text = fs.readFileSync(tsFile, 'utf-8');
handle.connection.send({
jsonrpc: '2.0',
method: 'textDocument/didOpen',
params: {
textDocument: {
uri,
languageId,
version: 1,
text,
},
},
});
// Give tsserver a moment to build the project.
await new Promise((resolve) =>
setTimeout(resolve, DEFAULT_LSP_WARMUP_DELAY_MS),
);
// Only mark as warmed up after successful completion
handle.warmedUp = true;
} catch (error) {
// Do not set warmedUp to true on failure, allowing retry
console.warn('TypeScript server warm-up failed:', error);
}
}
private isTypescriptServer(handle: LspServerHandle): boolean {
return (
handle.config.name.includes('typescript') ||
(handle.config.command?.includes('typescript') ?? false)
);
}
/**
* Start individual LSP server with lock to prevent concurrent startup attempts.
*
* @param name - The name of the LSP server
* @param handle - The LSP server handle
*/
private async startServer(
name: string,
handle: LspServerHandle,
): Promise<void> {
// If already starting, wait for the existing promise
if (handle.startingPromise) {
return handle.startingPromise;
}
if (handle.status === 'IN_PROGRESS' || handle.status === 'READY') {
return;
}
handle.stopRequested = false;
// Create a promise to lock concurrent calls
handle.startingPromise = this.doStartServer(name, handle).finally(() => {
handle.startingPromise = undefined;
});
return handle.startingPromise;
}
/**
* Internal method that performs the actual server startup.
*
* @param name - The name of the LSP server
* @param handle - The LSP server handle
*/
private async doStartServer(
name: string,
handle: LspServerHandle,
): Promise<void> {
const workspaceTrusted = this.config.isTrustedFolder();
if (
(this.requireTrustedWorkspace || handle.config.trustRequired) &&
!workspaceTrusted
) {
console.log(
`LSP server ${name} requires trusted workspace, skipping startup`,
);
handle.status = 'FAILED';
return;
}
// Request user confirmation
const consent = await this.requestUserConsent(
name,
handle.config,
workspaceTrusted,
);
if (!consent) {
console.log(`User declined to start LSP server ${name}`);
handle.status = 'FAILED';
return;
}
// Check if command exists
if (handle.config.command) {
const commandCwd = handle.config.workspaceFolder ?? this.workspaceRoot;
if (
!(await this.commandExists(
handle.config.command,
handle.config.env,
commandCwd,
))
) {
console.warn(
`LSP server ${name} command not found: ${handle.config.command}`,
);
handle.status = 'FAILED';
return;
}
// Check path safety
if (
!this.isPathSafe(handle.config.command, this.workspaceRoot, commandCwd)
) {
console.warn(
`LSP server ${name} command path is unsafe: ${handle.config.command}`,
);
handle.status = 'FAILED';
return;
}
}
try {
handle.error = undefined;
handle.warmedUp = false;
handle.status = 'IN_PROGRESS';
// Create LSP connection
const connection = await this.createLspConnection(handle.config);
handle.connection = connection.connection;
handle.process = connection.process;
// Initialize LSP server
await this.initializeLspServer(connection, handle.config);
handle.status = 'READY';
this.attachRestartHandler(name, handle);
console.log(`LSP server ${name} started successfully`);
} catch (error) {
handle.status = 'FAILED';
handle.error = error as Error;
console.error(`LSP server ${name} failed to start:`, error);
}
}
/**
* Stop individual LSP server
*/
private async stopServer(
name: string,
handle: LspServerHandle,
): Promise<void> {
handle.stopRequested = true;
if (handle.connection) {
try {
await this.shutdownConnection(handle);
} catch (error) {
console.error(`Error closing LSP server ${name}:`, error);
}
} else if (handle.process && handle.process.exitCode === null) {
handle.process.kill();
}
handle.connection = undefined;
handle.process = undefined;
handle.status = 'NOT_STARTED';
handle.warmedUp = false;
handle.restartAttempts = 0;
}
private async shutdownConnection(handle: LspServerHandle): Promise<void> {
if (!handle.connection) {
return;
}
try {
const shutdownPromise = handle.connection.shutdown();
if (typeof handle.config.shutdownTimeout === 'number') {
await Promise.race([
shutdownPromise,
new Promise<void>((resolve) =>
setTimeout(resolve, handle.config.shutdownTimeout),
),
]);
} else {
await shutdownPromise;
}
} finally {
handle.connection.end();
}
}
private attachRestartHandler(name: string, handle: LspServerHandle): void {
if (!handle.process) {
return;
}
handle.process.once('exit', (code) => {
if (handle.stopRequested) {
return;
}
if (!handle.config.restartOnCrash) {
handle.status = 'FAILED';
return;
}
const maxRestarts = handle.config.maxRestarts ?? DEFAULT_LSP_MAX_RESTARTS;
if (maxRestarts <= 0) {
handle.status = 'FAILED';
return;
}
const attempts = handle.restartAttempts ?? 0;
if (attempts >= maxRestarts) {
console.warn(
`LSP server ${name} reached max restart attempts (${maxRestarts}), stopping restarts`,
);
handle.status = 'FAILED';
return;
}
handle.restartAttempts = attempts + 1;
console.warn(
`LSP server ${name} exited (code ${code ?? 'unknown'}), restarting (${handle.restartAttempts}/${maxRestarts})`,
);
this.resetHandle(handle);
void this.startServer(name, handle);
});
}
private resetHandle(handle: LspServerHandle): void {
if (handle.connection) {
handle.connection.end();
}
if (handle.process && handle.process.exitCode === null) {
handle.process.kill();
}
handle.connection = undefined;
handle.process = undefined;
handle.status = 'NOT_STARTED';
handle.error = undefined;
handle.warmedUp = false;
handle.stopRequested = false;
}
private buildProcessEnv(
env: Record<string, string> | undefined,
): NodeJS.ProcessEnv | undefined {
if (!env || Object.keys(env).length === 0) {
return undefined;
}
return { ...process.env, ...env };
}
private async connectSocketWithRetry(
socket: LspSocketOptions,
timeoutMs: number,
): Promise<
Awaited<ReturnType<typeof LspConnectionFactory.createSocketConnection>>
> {
const deadline = Date.now() + timeoutMs;
let attempt = 0;
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error('LSP server connection timeout');
}
try {
return await LspConnectionFactory.createSocketConnection(
socket,
remaining,
);
} catch (error) {
attempt += 1;
if (Date.now() >= deadline) {
throw error;
}
const delay = Math.min(
DEFAULT_LSP_SOCKET_RETRY_DELAY_MS * attempt,
DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/**
* Create LSP connection
*/
private async createLspConnection(
config: LspServerConfig,
): Promise<LspConnectionResult> {
const workspaceFolder = config.workspaceFolder ?? this.workspaceRoot;
const startupTimeout =
config.startupTimeout ?? DEFAULT_LSP_STARTUP_TIMEOUT_MS;
const env = this.buildProcessEnv(config.env);
if (config.transport === 'stdio') {
if (!config.command) {
throw new Error('LSP stdio transport requires a command');
}
// Fix: use cwd as cwd instead of rootUri
const lspConnection = await LspConnectionFactory.createStdioConnection(
config.command,
config.args ?? [],
{ cwd: workspaceFolder, env },
startupTimeout,
);
return {
connection: lspConnection.connection,
process: lspConnection.process as ChildProcess,
shutdown: async () => {
await lspConnection.connection.shutdown();
},
exit: () => {
if (lspConnection.process && !lspConnection.process.killed) {
(lspConnection.process as ChildProcess).kill();
}
lspConnection.connection.end();
},
initialize: async (params: unknown) =>
lspConnection.connection.initialize(params),
};
} else if (config.transport === 'tcp' || config.transport === 'socket') {
if (!config.socket) {
throw new Error('LSP socket transport requires host/port or path');
}
let process: ChildProcess | undefined;
if (config.command) {
process = spawn(config.command, config.args ?? [], {
cwd: workspaceFolder,
env,
stdio: 'ignore',
});
await new Promise<void>((resolve, reject) => {
process?.once('spawn', () => resolve());
process?.once('error', (error) => {
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
});
});
}
try {
const lspConnection = await this.connectSocketWithRetry(
config.socket,
startupTimeout,
);
return {
connection: lspConnection.connection,
process,
shutdown: async () => {
await lspConnection.connection.shutdown();
},
exit: () => {
lspConnection.connection.end();
},
initialize: async (params: unknown) =>
lspConnection.connection.initialize(params),
};
} catch (error) {
if (process && process.exitCode === null) {
process.kill();
}
throw error;
}
} else {
throw new Error(`Unsupported transport: ${config.transport}`);
}
}
/**
* Initialize LSP server
*/
private async initializeLspServer(
connection: LspConnectionResult,
config: LspServerConfig,
): Promise<void> {
const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot;
const workspaceFolder = {
name: path.basename(workspaceFolderPath) || workspaceFolderPath,
uri: config.rootUri,
};
const initializeParams = {
processId: process.pid,
rootUri: config.rootUri,
rootPath: workspaceFolderPath,
workspaceFolders: [workspaceFolder],
capabilities: {
textDocument: {
completion: { dynamicRegistration: true },
hover: { dynamicRegistration: true },
definition: { dynamicRegistration: true },
references: { dynamicRegistration: true },
documentSymbol: { dynamicRegistration: true },
codeAction: { dynamicRegistration: true },
},
workspace: {
workspaceFolders: { supported: true },
},
},
initializationOptions: config.initializationOptions,
};
await connection.initialize(initializeParams);
// Send initialized notification and workspace folders change to help servers (e.g. tsserver)
// create projects in the correct workspace.
connection.connection.send({
jsonrpc: '2.0',
method: 'initialized',
params: {},
});
connection.connection.send({
jsonrpc: '2.0',
method: 'workspace/didChangeWorkspaceFolders',
params: {
event: {
added: [workspaceFolder],
removed: [],
},
},
});
if (config.settings && Object.keys(config.settings).length > 0) {
connection.connection.send({
jsonrpc: '2.0',
method: 'workspace/didChangeConfiguration',
params: {
settings: config.settings,
},
});
}
// Warm up TypeScript server by opening a workspace file so it can create a project.
if (
config.name.includes('typescript') ||
(config.command?.includes('typescript') ?? false)
) {
try {
const tsFile = this.findFirstTypescriptFile();
if (tsFile) {
const uri = pathToFileURL(tsFile).toString();
const languageId = tsFile.endsWith('.tsx')
? 'typescriptreact'
: 'typescript';
const text = fs.readFileSync(tsFile, 'utf-8');
connection.connection.send({
jsonrpc: '2.0',
method: 'textDocument/didOpen',
params: {
textDocument: {
uri,
languageId,
version: 1,
text,
},
},
});
}
} catch (error) {
console.warn('TypeScript LSP warm-up failed:', error);
}
}
}
/**
* Check if command exists
*/
private async commandExists(
command: string,
env?: Record<string, string>,
cwd?: string,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const child = spawn(command, ['--version'], {
stdio: ['ignore', 'ignore', 'ignore'],
cwd: cwd ?? this.workspaceRoot,
env: this.buildProcessEnv(env),
});
child.on('error', () => {
settled = true;
resolve(false);
});
child.on('exit', (code) => {
if (settled) {
return;
}
// If command exists, it typically returns 0 or other non-error codes
// Some commands with --version may return non-0, but won't throw error
resolve(code !== 127); // 127 typically indicates command not found
});
// Set timeout to avoid long waits
setTimeout(() => {
settled = true;
child.kill();
resolve(false);
}, DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS);
});
}
/**
* Check path safety
*/
private isPathSafe(
command: string,
workspacePath: string,
cwd?: string,
): boolean {
// Allow commands without path separators (global PATH commands like 'typescript-language-server')
// These are resolved by the shell from PATH and are generally safe
if (!command.includes(path.sep) && !command.includes('/')) {
return true;
}
// For explicit paths (absolute or relative), verify they're within workspace
const resolvedWorkspacePath = path.resolve(workspacePath);
const basePath = cwd ? path.resolve(cwd) : resolvedWorkspacePath;
const resolvedPath = path.isAbsolute(command)
? path.resolve(command)
: path.resolve(basePath, command);
return (
resolvedPath.startsWith(resolvedWorkspacePath + path.sep) ||
resolvedPath === resolvedWorkspacePath
);
}
/**
* LSP
*/
private async requestUserConsent(
serverName: string,
serverConfig: LspServerConfig,
workspaceTrusted: boolean,
): Promise<boolean> {
if (workspaceTrusted) {
return true; // Auto-allow in trusted workspace
}
if (this.requireTrustedWorkspace || serverConfig.trustRequired) {
console.log(
`Workspace not trusted, skipping LSP server ${serverName} (${serverConfig.command ?? serverConfig.transport})`,
);
return false;
}
console.log(
`Untrusted workspace, but LSP server ${serverName} has trustRequired=false, attempting cautious startup`,
);
return true;
}
/**
* Find a representative TypeScript/JavaScript file to warm up tsserver.
*/
private findFirstTypescriptFile(): string | undefined {
const patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'];
const excludePatterns = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
];
for (const root of this.workspaceContext.getDirectories()) {
for (const pattern of patterns) {
try {
const matches = globSync(pattern, {
cwd: root,
ignore: excludePatterns,
absolute: true,
nodir: true,
});
for (const file of matches) {
if (this.fileDiscoveryService.shouldIgnoreFile(file)) {
continue;
}
return file;
}
} catch (_error) {
// ignore glob errors
}
}
}
return undefined;
}
}

View file

@ -0,0 +1,205 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* LSP Service Type Definitions
*
* Centralized type definitions for the LSP service modules.
*/
import type { ChildProcess } from 'node:child_process';
// ============================================================================
// LSP Initialization Options
// ============================================================================
/**
* LSP server initialization options passed during the initialize request.
*/
export interface LspInitializationOptions {
[key: string]: unknown;
}
// ============================================================================
// LSP Socket Options
// ============================================================================
/**
* Socket connection options for TCP or Unix socket transport.
*/
export interface LspSocketOptions {
/** Host address for TCP connections */
host?: string;
/** Port number for TCP connections */
port?: number;
/** Path for Unix socket connections */
path?: string;
}
// ============================================================================
// LSP Server Configuration
// ============================================================================
/**
* Configuration for an LSP server instance.
*/
export interface LspServerConfig {
/** Unique name identifier for the server */
name: string;
/** List of languages this server handles */
languages: string[];
/** Command to start the server (required for stdio transport) */
command?: string;
/** Command line arguments */
args?: string[];
/** Transport type: stdio, tcp, or socket */
transport: 'stdio' | 'tcp' | 'socket';
/** Environment variables for the server process */
env?: Record<string, string>;
/** LSP initialization options */
initializationOptions?: LspInitializationOptions;
/** Server-specific settings */
settings?: Record<string, unknown>;
/** Custom file extension to language mappings */
extensionToLanguage?: Record<string, string>;
/** Root URI for the workspace */
rootUri: string;
/** Workspace folder path */
workspaceFolder?: string;
/** Startup timeout in milliseconds */
startupTimeout?: number;
/** Shutdown timeout in milliseconds */
shutdownTimeout?: number;
/** Whether to restart on crash */
restartOnCrash?: boolean;
/** Maximum number of restart attempts */
maxRestarts?: number;
/** Whether trusted workspace is required */
trustRequired?: boolean;
/** Socket connection options */
socket?: LspSocketOptions;
}
// ============================================================================
// LSP JSON-RPC Message
// ============================================================================
/**
* JSON-RPC message format for LSP communication.
*/
export interface JsonRpcMessage {
jsonrpc: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
// ============================================================================
// LSP Connection Interface
// ============================================================================
/**
* Interface for LSP JSON-RPC connection.
*/
export interface LspConnectionInterface {
/** Start listening on a readable stream */
listen: (readable: NodeJS.ReadableStream) => void;
/** Send a message to the server */
send: (message: JsonRpcMessage) => void;
/** Register a notification handler */
onNotification: (handler: (notification: JsonRpcMessage) => void) => void;
/** Register a request handler */
onRequest: (handler: (request: JsonRpcMessage) => Promise<unknown>) => void;
/** Send a request and wait for response */
request: (method: string, params: unknown) => Promise<unknown>;
/** Send initialize request */
initialize: (params: unknown) => Promise<unknown>;
/** Send shutdown request */
shutdown: () => Promise<void>;
/** End the connection */
end: () => void;
}
// ============================================================================
// LSP Server Status
// ============================================================================
/**
* Status of an LSP server instance.
*/
export type LspServerStatus =
| 'NOT_STARTED'
| 'IN_PROGRESS'
| 'READY'
| 'FAILED';
// ============================================================================
// LSP Server Handle
// ============================================================================
/**
* Handle for managing an LSP server instance.
*/
export interface LspServerHandle {
/** Server configuration */
config: LspServerConfig;
/** Current status */
status: LspServerStatus;
/** Active connection to the server */
connection?: LspConnectionInterface;
/** Server process (for stdio transport) */
process?: ChildProcess;
/** Error that caused failure */
error?: Error;
/** Whether TypeScript server has been warmed up */
warmedUp?: boolean;
/** Whether stop was explicitly requested */
stopRequested?: boolean;
/** Number of restart attempts */
restartAttempts?: number;
/** Lock to prevent concurrent startup attempts */
startingPromise?: Promise<void>;
}
// ============================================================================
// LSP Service Options
// ============================================================================
/**
* Options for NativeLspService constructor.
*/
export interface NativeLspServiceOptions {
/** Whether to require trusted workspace */
requireTrustedWorkspace?: boolean;
/** Override workspace root path */
workspaceRoot?: string;
}
// ============================================================================
// LSP Connection Result
// ============================================================================
/**
* Result from creating an LSP connection.
*/
export interface LspConnectionResult {
/** The JSON-RPC connection */
connection: LspConnectionInterface;
/** Server process (for stdio transport) */
process?: ChildProcess;
/** Shutdown the connection gracefully */
shutdown: () => Promise<void>;
/** Force exit the connection */
exit: () => void;
/** Send initialize request */
initialize: (params: unknown) => Promise<unknown>;
}

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@ -477,52 +477,6 @@ describe('NativeLspService Integration Tests', () => {
// The exact server name depends on built-in presets
expect(status.size).toBeGreaterThanOrEqual(0);
});
it('should respect allowed servers list', async () => {
const restrictedService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
workspaceRoot: mockWorkspace.rootPath,
allowedServers: ['typescript-language-server'],
},
);
await restrictedService.discoverAndPrepare();
const status = restrictedService.getStatus();
// Only allowed servers should be READY
const readyServers = Array.from(status.entries())
.filter(([, state]) => state === 'READY')
.map(([name]) => name);
for (const name of readyServers) {
expect(['typescript-language-server']).toContain(name);
}
});
it('should respect excluded servers list', async () => {
const restrictedService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{
workspaceRoot: mockWorkspace.rootPath,
excludedServers: ['pylsp'],
},
);
await restrictedService.discoverAndPrepare();
const status = restrictedService.getStatus();
// pylsp should not be present or should be FAILED
const pylspStatus = status.get('pylsp');
expect(pylspStatus !== 'READY').toBe(true);
});
});
describe('LSP Operations - Mock Responses', () => {

View file

@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { NativeLspService } from './NativeLspService.js';
import { EventEmitter } from 'events';
import type {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
LspCodeActionKind,
LspDiagnosticSeverity,
} from '@qwen-code/qwen-code-core';
// ============================================================================
// Timeout Constants
// ============================================================================
/** Default timeout for LSP server startup in milliseconds */
export const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000;
/** Default timeout for LSP requests in milliseconds */
export const DEFAULT_LSP_REQUEST_TIMEOUT_MS = 15000;
/** Default delay for TypeScript server warm-up in milliseconds */
export const DEFAULT_LSP_WARMUP_DELAY_MS = 150;
/** Default timeout for command existence check in milliseconds */
export const DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS = 2000;
/** Default timeout for LSP server shutdown in milliseconds */
export const DEFAULT_LSP_SHUTDOWN_TIMEOUT_MS = 5000;
// ============================================================================
// Retry Constants
// ============================================================================
/** Default maximum number of server restart attempts */
export const DEFAULT_LSP_MAX_RESTARTS = 3;
/** Default initial delay between socket connection retries in milliseconds */
export const DEFAULT_LSP_SOCKET_RETRY_DELAY_MS = 250;
/** Default maximum delay between socket connection retries in milliseconds */
export const DEFAULT_LSP_SOCKET_MAX_RETRY_DELAY_MS = 1000;
// ============================================================================
// LSP Protocol Labels
// ============================================================================
/**
* Symbol kind labels for converting numeric LSP SymbolKind to readable strings.
* Based on the LSP specification:
* https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind
*/
export const SYMBOL_KIND_LABELS: Record<number, string> = {
1: 'File',
2: 'Module',
3: 'Namespace',
4: 'Package',
5: 'Class',
6: 'Method',
7: 'Property',
8: 'Field',
9: 'Constructor',
10: 'Enum',
11: 'Interface',
12: 'Function',
13: 'Variable',
14: 'Constant',
15: 'String',
16: 'Number',
17: 'Boolean',
18: 'Array',
19: 'Object',
20: 'Key',
21: 'Null',
22: 'EnumMember',
23: 'Struct',
24: 'Event',
25: 'Operator',
26: 'TypeParameter',
};
/**
* Diagnostic severity labels for converting numeric LSP DiagnosticSeverity to readable strings.
* Based on the LSP specification.
*/
export const DIAGNOSTIC_SEVERITY_LABELS: Record<number, LspDiagnosticSeverity> =
{
1: 'error',
2: 'warning',
3: 'information',
4: 'hint',
};
/**
* Code action kind labels from LSP specification.
*/
export const CODE_ACTION_KIND_LABELS: Record<string, LspCodeActionKind> = {
'': 'quickfix',
quickfix: 'quickfix',
refactor: 'refactor',
'refactor.extract': 'refactor.extract',
'refactor.inline': 'refactor.inline',
'refactor.rewrite': 'refactor.rewrite',
source: 'source',
'source.organizeImports': 'source.organizeImports',
'source.fixAll': 'source.fixAll',
};
// ============================================================================
// Language Detection
// ============================================================================
/**
* Common root marker files that indicate project type/language.
*/
export const COMMON_ROOT_MARKERS = [
'package.json',
'tsconfig.json',
'pyproject.toml',
'go.mod',
'Cargo.toml',
'pom.xml',
'build.gradle',
'composer.json',
'Gemfile',
'mix.exs',
'deno.json',
] as const;
/**
* Mapping from root marker files to programming languages.
*/
export const MARKER_TO_LANGUAGE: Record<string, string> = {
'package.json': 'javascript',
'tsconfig.json': 'typescript',
'pyproject.toml': 'python',
'go.mod': 'go',
'Cargo.toml': 'rust',
'pom.xml': 'java',
'build.gradle': 'java',
'composer.json': 'php',
Gemfile: 'ruby',
'*.sln': 'csharp',
'mix.exs': 'elixir',
'deno.json': 'deno',
};
/**
* Default mapping from file extensions to language identifiers.
*/
export const DEFAULT_EXTENSION_TO_LANGUAGE: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
jsx: 'javascriptreact',
tsx: 'typescriptreact',
py: 'python',
go: 'go',
rs: 'rust',
java: 'java',
cpp: 'cpp',
c: 'c',
php: 'php',
rb: 'ruby',
cs: 'csharp',
vue: 'vue',
svelte: 'svelte',
html: 'html',
css: 'css',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
};
/**
* Glob patterns to exclude when detecting languages.
*/
export const LANGUAGE_DETECTION_EXCLUDE_PATTERNS = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
] as const;
// ============================================================================
// Default Limits for LSP Operations
// ============================================================================
/** Default limit for workspace symbol search results */
export const DEFAULT_LSP_WORKSPACE_SYMBOL_LIMIT = 50;
/** Default limit for definition/implementation results */
export const DEFAULT_LSP_DEFINITION_LIMIT = 50;
/** Default limit for reference results */
export const DEFAULT_LSP_REFERENCE_LIMIT = 200;
/** Default limit for document symbol results */
export const DEFAULT_LSP_DOCUMENT_SYMBOL_LIMIT = 200;
/** Default limit for call hierarchy results */
export const DEFAULT_LSP_CALL_HIERARCHY_LIMIT = 50;
/** Default limit for diagnostics results */
export const DEFAULT_LSP_DIAGNOSTICS_LIMIT = 100;
/** Default limit for code action results */
export const DEFAULT_LSP_CODE_ACTION_LIMIT = 20;
/** Maximum number of files to scan during language detection */
export const DEFAULT_LSP_LANGUAGE_DETECTION_FILE_LIMIT = 1000;

View file

@ -61,9 +61,6 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js';
import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js';
import { LspFindReferencesTool } from '../tools/lsp-find-references.js';
import { LspTool } from '../tools/lsp.js';
import type { LspClient } from '../lsp/types.js';
@ -296,8 +293,6 @@ export interface ConfigParameters {
mcpServers?: Record<string, MCPServerConfig>;
lsp?: {
enabled?: boolean;
allowed?: string[];
excluded?: string[];
};
lspClient?: LspClient;
userMemory?: string;
@ -444,8 +439,6 @@ export class Config {
private readonly mcpServerCommand: string | undefined;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly lspEnabled: boolean;
private readonly lspAllowed?: string[];
private readonly lspExcluded?: string[];
private lspClient?: LspClient;
private readonly allowedMcpServers?: string[];
private readonly excludedMcpServers?: string[];
@ -551,8 +544,6 @@ export class Config {
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.lspEnabled = params.lsp?.enabled ?? false;
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
this.lspClient = params.lspClient;
this.allowedMcpServers = params.allowedMcpServers;
this.excludedMcpServers = params.excludedMcpServers;
@ -1120,14 +1111,6 @@ export class Config {
return this.lspEnabled;
}
getLspAllowed(): string[] | undefined {
return this.lspAllowed;
}
getLspExcluded(): string[] | undefined {
return this.lspExcluded;
}
getLspClient(): LspClient | undefined {
return this.lspClient;
}
@ -1690,12 +1673,8 @@ export class Config {
registerCoreTool(WebSearchTool, this);
}
if (this.isLspEnabled() && this.getLspClient()) {
// Register the unified LSP tool (recommended)
// Register the unified LSP tool
registerCoreTool(LspTool, this);
// Keep legacy tools for backward compatibility
registerCoreTool(LspGoToDefinitionTool, this);
registerCoreTool(LspFindReferencesTool, this);
registerCoreTool(LspWorkspaceSymbolTool, this);
}
await registry.discoverAllTools();

View file

@ -116,7 +116,6 @@ export * from './extension/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';
export * from './lsp/types.js';
// Export specific tool logic
export * from './tools/read-file.js';
@ -131,8 +130,6 @@ export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/lsp-go-to-definition.js';
export * from './tools/lsp-find-references.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-client-manager.js';
export * from './tools/mcp-tool.js';
@ -142,6 +139,10 @@ export * from './tools/skill.js';
export * from './tools/todoWrite.js';
export * from './tools/exitPlanMode.js';
// Export LSP types and tools
export * from './lsp/types.js';
export * from './tools/lsp.js';
// MCP OAuth
export { MCPOAuthProvider } from './mcp/oauth-provider.js';
export type {

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@ -98,7 +98,11 @@ export interface LspCallHierarchyOutgoingCall {
/**
* Diagnostic severity levels from LSP specification.
*/
export type LspDiagnosticSeverity = 'error' | 'warning' | 'information' | 'hint';
export type LspDiagnosticSeverity =
| 'error'
| 'warning'
| 'information'
| 'hint';
/**
* A diagnostic message from a language server.
@ -326,10 +330,7 @@ export interface LspClient {
/**
* Get diagnostics for a specific document.
*/
diagnostics(
uri: string,
serverName?: string,
): Promise<LspDiagnostic[]>;
diagnostics(uri: string, serverName?: string): Promise<LspDiagnostic[]>;
/**
* Get diagnostics for all open documents in the workspace.

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

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@ -1163,11 +1163,11 @@ describe('LspTool', () => {
// Should include at least these definitions
expect(definitionNames).toEqual(
expect.arrayContaining([
'LspCallHierarchyItem',
'LspDiagnostic',
'LspPosition',
'LspRange',
]),
'LspCallHierarchyItem',
'LspDiagnostic',
'LspPosition',
'LspRange',
]),
);
});
});

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

View file

@ -25,9 +25,6 @@ export const ToolNames = {
WEB_FETCH: 'web_fetch',
WEB_SEARCH: 'web_search',
LS: 'list_directory',
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
/** Unified LSP tool supporting all LSP operations. */
LSP: 'lsp',
} as const;
@ -53,9 +50,6 @@ export const ToolDisplayNames = {
WEB_FETCH: 'WebFetch',
WEB_SEARCH: 'WebSearch',
LS: 'ListFiles',
LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol',
LSP_GO_TO_DEFINITION: 'LspGoToDefinition',
LSP_FIND_REFERENCES: 'LspFindReferences',
/** Unified LSP tool display name. */
LSP: 'Lsp',
} as const;
@ -66,8 +60,9 @@ export const ToolDisplayNames = {
export const ToolNamesMigration = {
search_file_content: ToolNames.GREP, // Legacy name from grep tool
replace: ToolNames.EDIT, // Legacy name from edit tool
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
find_references: ToolNames.LSP_FIND_REFERENCES,
// Legacy LSP tools now use unified LSP tool with operation parameter
go_to_definition: ToolNames.LSP,
find_references: ToolNames.LSP,
} as const;
// Migration from old tool display names to new tool display names

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