mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-18 14:56:20 +00:00
Compare commits
39 Commits
v0.7.0-pre
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9328fa478 | ||
|
|
a14d1e27bb | ||
|
|
0681c71894 | ||
|
|
155c4b9728 | ||
|
|
57ca2823b3 | ||
|
|
620341eeae | ||
|
|
c6c33233c5 | ||
|
|
106b69e5c0 | ||
|
|
6afe0f8c29 | ||
|
|
0b3be1a82c | ||
|
|
8af43e3ac3 | ||
|
|
886f914fb3 | ||
|
|
90365af2f8 | ||
|
|
cbef5ffd89 | ||
|
|
63406b4ba4 | ||
|
|
52db3a766d | ||
|
|
5e80e80387 | ||
|
|
985f65f8fa | ||
|
|
9b9c5fadd5 | ||
|
|
372c67cad4 | ||
|
|
af3864b5de | ||
|
|
1e3791f30a | ||
|
|
9bf626d051 | ||
|
|
6f33d92b2c | ||
|
|
a35af6550f | ||
|
|
d6607e134e | ||
|
|
9024a41723 | ||
|
|
bde056b62e | ||
|
|
ff5ea3c6d7 | ||
|
|
0faaac8fa4 | ||
|
|
c2e62b9122 | ||
|
|
f54b62cda3 | ||
|
|
9521987a09 | ||
|
|
97497457a8 | ||
|
|
c4e6c096dc | ||
|
|
4857f2f803 | ||
|
|
5a907c3415 | ||
|
|
d1d215b82e | ||
|
|
a67a8d0277 |
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -13,5 +13,10 @@
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"lsp": {
|
||||
"enabled": true,
|
||||
"allowed": ["typescript-language-server"],
|
||||
"excluded": ["gopls"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,11 @@ If you encounter issues, check the [troubleshooting guide](https://qwenlm.github
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Connect with Us
|
||||
|
||||
- Discord: https://discord.gg/ycKBjdNd
|
||||
- Dingtalk: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
147
cclsp-integration-plan.md
Normal file
147
cclsp-integration-plan.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Qwen Code CLI LSP 集成实现方案分析
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
本方案旨在将 LSP(Language Server Protocol)能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。
|
||||
|
||||
## 2. 技术方案对比
|
||||
|
||||
### 2.1 Piebald-AI/claude-code-lsps 方案
|
||||
- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由
|
||||
- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装
|
||||
- **安全**: LSP 子进程以用户权限运行,无内置信任门控
|
||||
- **功能覆盖**: 可以暴露完整的 LSP 表面(hover、诊断、代码操作、重命名等)
|
||||
|
||||
### 2.2 原生 LSP 客户端方案(推荐方案)
|
||||
- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接
|
||||
- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置
|
||||
- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示)
|
||||
- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等)
|
||||
|
||||
### 2.3 cclsp + MCP 方案(备选)
|
||||
- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接
|
||||
- **用户配置**: 需要 MCP 配置
|
||||
- **安全**: 通过 MCP 安全控制
|
||||
- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具
|
||||
|
||||
## 3. 原生 LSP 集成详细计划
|
||||
|
||||
### 3.1 方案选择
|
||||
- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验
|
||||
- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接
|
||||
- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略
|
||||
|
||||
### 3.2 实现步骤
|
||||
|
||||
#### 3.2.1 创建原生 LSP 服务
|
||||
在 `packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理:
|
||||
- 工作区语言检测
|
||||
- 自动发现和启动语言服务器
|
||||
- 与现有文档/编辑模型同步
|
||||
- LSP 能力直接暴露给代理
|
||||
|
||||
#### 3.2.2 配置支持
|
||||
- 支持内置预设配置(常见语言服务器)
|
||||
- 支持用户自定义 `.lsp.json` 配置文件
|
||||
- 与 MCP 配置共存,共享信任控制
|
||||
|
||||
#### 3.2.3 集成启动流程
|
||||
- 在 `packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成
|
||||
- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制
|
||||
- 处理沙箱预检和主运行的重复调用问题
|
||||
|
||||
#### 3.2.4 功能标志配置
|
||||
- 在 `packages/cli/src/config/settingsSchema.ts` 中添加新的设置项
|
||||
- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能
|
||||
- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置
|
||||
|
||||
#### 3.2.5 安全控制
|
||||
- 与 MCP 共享相同的安全控制机制
|
||||
- 在信任工作区中自动启用,在非信任工作区中提示用户
|
||||
- 实现路径允许列表和进程启动确认
|
||||
|
||||
#### 3.2.6 错误处理与用户通知
|
||||
- 检测缺失的语言服务器并提供安装命令
|
||||
- 通过现有 MCP 状态 UI 显示错误信息
|
||||
- 实现重试/退避机制,检测沙箱环境并抑制自动启动
|
||||
|
||||
### 3.3 需要确认的不确定项
|
||||
|
||||
1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调
|
||||
|
||||
2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP
|
||||
|
||||
3. **功能开关设计**:开关应该是全局级别的,LSP 和 MCP 可独立启用/禁用
|
||||
|
||||
4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑
|
||||
|
||||
5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步
|
||||
|
||||
6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项
|
||||
|
||||
7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制
|
||||
|
||||
### 3.4 安全考虑
|
||||
|
||||
- 与 MCP 共享相同的安全控制模型
|
||||
- 仅在受信任工作区中启用自动 LSP 功能
|
||||
- 提供用户确认机制用于启动新的 LSP 服务器
|
||||
- 防止路径劫持,使用安全的路径解析
|
||||
|
||||
### 3.5 高级 LSP 功能支持
|
||||
|
||||
- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等
|
||||
- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置
|
||||
- **性能优化**: 优化 LSP 服务器启动时间和内存使用
|
||||
|
||||
### 3.6 用户体验
|
||||
|
||||
- 提供安装提示而非自动安装
|
||||
- 在统一的状态界面显示 LSP 和 MCP 服务器状态
|
||||
- 提供独立开关让用户控制 LSP 和 MCP 功能
|
||||
- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息
|
||||
|
||||
## 4. 实施总结
|
||||
|
||||
### 4.1 已完成的工作
|
||||
1. **NativeLspService 类**:创建了核心服务类,包含语言检测、配置合并、LSP 连接管理等功能
|
||||
2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理
|
||||
3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测
|
||||
4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并
|
||||
5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证
|
||||
6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点
|
||||
|
||||
### 4.2 关键组件
|
||||
|
||||
#### 4.2.1 LspConnectionFactory
|
||||
- 使用 `vscode-jsonrpc` 和 `vscode-languageserver-protocol` 实现 LSP 连接
|
||||
- 支持 stdio 传输方式,可以扩展支持 TCP 传输
|
||||
- 提供连接创建、初始化和关闭的完整生命周期管理
|
||||
|
||||
#### 4.2.2 NativeLspService
|
||||
- **语言检测**:扫描项目文件和配置文件来识别编程语言
|
||||
- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置
|
||||
- **LSP 服务器管理**:启动、停止和状态管理
|
||||
- **安全控制**:与 MCP 共享的信任和确认机制
|
||||
|
||||
#### 4.2.3 配置架构
|
||||
- **内置预设**:为常见语言提供默认 LSP 服务器配置
|
||||
- **用户配置**:支持 `.lsp.json` 文件格式
|
||||
- **Claude 兼容**:可导入 Claude Code 的 LSP 配置
|
||||
|
||||
### 4.3 依赖管理
|
||||
- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信
|
||||
- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递
|
||||
- 使用 `vscode-languageserver-textdocument` 管理文档版本
|
||||
|
||||
### 4.4 安全特性
|
||||
- 工作区信任检查
|
||||
- 用户确认机制(对于非信任工作区)
|
||||
- 命令存在性验证
|
||||
- 路径安全性检查
|
||||
|
||||
## 5. 总结
|
||||
|
||||
原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略,但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。
|
||||
|
||||
该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。
|
||||
@@ -275,6 +275,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
|
||||
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
|
||||
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
|
||||
|
||||
#### mcp
|
||||
|
||||
@@ -480,7 +481,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
|
||||
@@ -11,12 +11,29 @@ This guide shows you how to create, use, and manage Agent Skills in **Qwen Code*
|
||||
## Prerequisites
|
||||
|
||||
- Qwen Code (recent version)
|
||||
- Run with the experimental flag enabled:
|
||||
|
||||
## How to enable
|
||||
|
||||
### Via CLI flag
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills
|
||||
```
|
||||
|
||||
### Via settings.json
|
||||
|
||||
Add to your `~/.qwen/settings.json` or project's `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -1,11 +1,11 @@
|
||||
# JetBrains IDEs
|
||||
|
||||
> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
|
||||
> JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
|
||||
|
||||
### Features
|
||||
|
||||
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
|
||||
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Symbol management**: #-mention files to add them to the conversation context
|
||||
- **Conversation history**: Access to past conversations within the IDE
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
4. The Qwen Code agent should now be available in the AI Assistant panel
|
||||
|
||||

|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -22,13 +22,7 @@
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
|
||||
2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Zed Editor
|
||||
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
|
||||

|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Download and install [Zed Editor](https://zed.dev/)
|
||||
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -39,6 +39,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
@@ -10804,6 +10805,13 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"dev": true,
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -17310,7 +17318,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17947,7 +17955,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -21408,7 +21416,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21420,7 +21428,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
@@ -94,6 +94,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
|
||||
140
packages/cli/LSP_DEBUGGING_GUIDE.md
Normal file
140
packages/cli/LSP_DEBUGGING_GUIDE.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# LSP 调试指南
|
||||
|
||||
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
|
||||
|
||||
## 1. 启用调试模式
|
||||
|
||||
CLI 支持调试模式,可以提供额外的日志信息:
|
||||
|
||||
```bash
|
||||
# 使用 debug 标志运行
|
||||
qwen --debug [你的命令]
|
||||
|
||||
# 或设置环境变量
|
||||
DEBUG=true qwen [你的命令]
|
||||
DEBUG_MODE=true qwen [你的命令]
|
||||
```
|
||||
|
||||
## 2. LSP 配置选项
|
||||
|
||||
LSP 功能通过设置系统配置,包含以下选项:
|
||||
|
||||
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`)
|
||||
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
|
||||
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
|
||||
|
||||
在 settings.json 中的示例配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"enabled": true,
|
||||
"allowed": ["typescript-language-server", "pylsp"],
|
||||
"excluded": ["gopls"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
|
||||
|
||||
## 3. NativeLspService 调试功能
|
||||
|
||||
`NativeLspService` 类包含几个调试功能:
|
||||
|
||||
### 3.1 控制台日志
|
||||
|
||||
服务向控制台输出状态消息:
|
||||
|
||||
- `LSP 服务器 ${name} 启动成功` - 服务器成功启动
|
||||
- `LSP 服务器 ${name} 启动失败` - 服务器启动失败
|
||||
- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现
|
||||
|
||||
### 3.2 错误处理
|
||||
|
||||
服务具有全面的错误处理和详细的错误消息
|
||||
|
||||
### 3.3 状态跟踪
|
||||
|
||||
您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态
|
||||
|
||||
## 4. 调试命令
|
||||
|
||||
```bash
|
||||
# 启用调试运行
|
||||
qwen --debug --prompt "调试 LSP 功能"
|
||||
|
||||
# 检查在您的项目中检测到哪些 LSP 服务器
|
||||
# 系统会自动检测语言和相应的 LSP 服务器
|
||||
```
|
||||
|
||||
## 5. 手动 LSP 服务器配置
|
||||
|
||||
您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。
|
||||
推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移:
|
||||
|
||||
```json
|
||||
{
|
||||
"languageServers": {
|
||||
"pylsp": {
|
||||
"command": "pylsp",
|
||||
"args": [],
|
||||
"languages": ["python"],
|
||||
"transport": "stdio",
|
||||
"settings": {},
|
||||
"workspaceFolder": null,
|
||||
"startupTimeout": 10000,
|
||||
"shutdownTimeout": 3000,
|
||||
"restartOnCrash": true,
|
||||
"maxRestarts": 3,
|
||||
"trustRequired": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
旧格式示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"python": {
|
||||
"command": "pylsp",
|
||||
"args": [],
|
||||
"transport": "stdio",
|
||||
"trustRequired": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. LSP 问题排查
|
||||
|
||||
### 6.1 检查 LSP 服务器是否已安装
|
||||
|
||||
- 对于 TypeScript/JavaScript: `typescript-language-server`
|
||||
- 对于 Python: `pylsp`
|
||||
- 对于 Go: `gopls`
|
||||
|
||||
### 6.2 验证工作区信任
|
||||
|
||||
- LSP 服务器可能需要受信任的工作区才能启动
|
||||
- 检查 `security.folderTrust.enabled` 设置
|
||||
|
||||
### 6.3 查看日志
|
||||
|
||||
- 查找以 `LSP 服务器` 开头的控制台消息
|
||||
- 检查命令存在性和路径安全性问题
|
||||
|
||||
## 7. LSP 服务启动流程
|
||||
|
||||
LSP 服务的启动遵循以下流程:
|
||||
|
||||
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
|
||||
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
|
||||
3. **启动服务器**: `start()` 方法启动所有服务器句柄
|
||||
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
|
||||
|
||||
## 8. 调试技巧
|
||||
|
||||
- 使用 `--debug` 标志查看详细的启动过程
|
||||
- 检查工作区是否受信任(影响 LSP 服务器启动)
|
||||
- 确认 LSP 服务器命令在系统 PATH 中可用
|
||||
- 使用 `getStatus()` 方法监控服务器运行状态
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -20,6 +20,25 @@ import { ExtensionStorage, type Extension } from './extension.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { NativeLspService } from '../services/lsp/NativeLspService.js';
|
||||
|
||||
const createNativeLspServiceInstance = () => ({
|
||||
discoverAndPrepare: vi.fn(),
|
||||
start: vi.fn(),
|
||||
definitions: vi.fn().mockResolvedValue([]),
|
||||
references: vi.fn().mockResolvedValue([]),
|
||||
workspaceSymbols: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
vi.mock('../services/lsp/NativeLspService.js', () => ({
|
||||
NativeLspService: vi.fn().mockImplementation(() => ({
|
||||
discoverAndPrepare: vi.fn(),
|
||||
start: vi.fn(),
|
||||
definitions: vi.fn().mockResolvedValue([]),
|
||||
references: vi.fn().mockResolvedValue([]),
|
||||
workspaceSymbols: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
@@ -27,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
|
||||
}));
|
||||
|
||||
const nativeLspServiceMock = vi.mocked(NativeLspService);
|
||||
const getLastLspInstance = () => {
|
||||
const results = nativeLspServiceMock.mock.results;
|
||||
if (results.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return results[results.length - 1]?.value as ReturnType<
|
||||
typeof createNativeLspServiceInstance
|
||||
>;
|
||||
};
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof import('fs')>();
|
||||
const pathMod = await import('node:path');
|
||||
@@ -516,6 +546,10 @@ describe('loadCliConfig', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
nativeLspServiceMock.mockReset();
|
||||
nativeLspServiceMock.mockImplementation(() =>
|
||||
createNativeLspServiceInstance(),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
});
|
||||
@@ -585,6 +619,63 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getShowMemoryUsage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize native LSP service when enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
lsp: {
|
||||
enabled: true,
|
||||
allowed: ['typescript-language-server'],
|
||||
excluded: ['pylsp'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.isLspEnabled()).toBe(true);
|
||||
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
|
||||
expect(config.getLspExcluded()).toEqual(['pylsp']);
|
||||
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
|
||||
const lspInstance = getLastLspInstance();
|
||||
expect(lspInstance).toBeDefined();
|
||||
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
const options = nativeLspServiceMock.mock.calls[0][5];
|
||||
expect(options?.allowedServers).toEqual(['typescript-language-server']);
|
||||
expect(options?.excludedServers).toEqual(['pylsp']);
|
||||
});
|
||||
|
||||
it('should skip native LSP startup when startLsp option is false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { lsp: { enabled: true } };
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
undefined,
|
||||
{ startLsp: false },
|
||||
);
|
||||
|
||||
expect(config.isLspEnabled()).toBe(true);
|
||||
expect(nativeLspServiceMock).not.toHaveBeenCalled();
|
||||
expect(getLastLspInstance()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
ideContextStore,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type LspClient,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
@@ -48,6 +50,7 @@ import { annotateActiveExtensions } from './extension.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { NativeLspService } from '../services/lsp/NativeLspService.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
@@ -154,6 +157,105 @@ export interface CliArgs {
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
export interface LoadCliConfigOptions {
|
||||
/**
|
||||
* Whether to start the native LSP service during config load.
|
||||
* Disable when doing preflight runs (e.g., sandbox preparation).
|
||||
*/
|
||||
startLsp?: boolean;
|
||||
}
|
||||
|
||||
class NativeLspClient implements LspClient {
|
||||
constructor(private readonly service: NativeLspService) {}
|
||||
|
||||
workspaceSymbols(query: string, limit?: number) {
|
||||
return this.service.workspaceSymbols(query, limit);
|
||||
}
|
||||
|
||||
definitions(
|
||||
location: Parameters<NativeLspService['definitions']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.definitions(location, serverName, limit);
|
||||
}
|
||||
|
||||
references(
|
||||
location: Parameters<NativeLspService['references']>[0],
|
||||
serverName?: string,
|
||||
includeDeclaration?: boolean,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.references(
|
||||
location,
|
||||
serverName,
|
||||
includeDeclaration,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hover information (documentation, type info) for a symbol.
|
||||
*/
|
||||
hover(
|
||||
location: Parameters<NativeLspService['hover']>[0],
|
||||
serverName?: string,
|
||||
) {
|
||||
return this.service.hover(location, serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all symbols in a document.
|
||||
*/
|
||||
documentSymbols(uri: string, serverName?: string, limit?: number) {
|
||||
return this.service.documentSymbols(uri, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find implementations of an interface or abstract method.
|
||||
*/
|
||||
implementations(
|
||||
location: Parameters<NativeLspService['implementations']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.implementations(location, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare call hierarchy item at a position (functions/methods).
|
||||
*/
|
||||
prepareCallHierarchy(
|
||||
location: Parameters<NativeLspService['prepareCallHierarchy']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.prepareCallHierarchy(location, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all functions/methods that call the given function.
|
||||
*/
|
||||
incomingCalls(
|
||||
item: Parameters<NativeLspService['incomingCalls']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.incomingCalls(item, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all functions/methods called by the given function.
|
||||
*/
|
||||
outgoingCalls(
|
||||
item: Parameters<NativeLspService['outgoingCalls']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.outgoingCalls(item, serverName, limit);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
format: string | OutputFormat | undefined,
|
||||
): OutputFormat | undefined {
|
||||
@@ -334,7 +436,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: false,
|
||||
default: settings.tools?.experimental?.skills ?? false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
@@ -689,6 +791,7 @@ export async function loadCliConfig(
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
@@ -765,6 +868,13 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
|
||||
// LSP configuration derived from settings; defaults to disabled for safety.
|
||||
const lspEnabled = settings.lsp?.enabled ?? false;
|
||||
const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed;
|
||||
const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded;
|
||||
const lspLanguageServers = settings.lsp?.languageServers;
|
||||
let lspClient: LspClient | undefined;
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
@@ -874,11 +984,10 @@ export async function loadCliConfig(
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
inputFormat !== InputFormat.STREAM_JSON
|
||||
) {
|
||||
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
|
||||
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
|
||||
const isAcpMode = argv.acp || argv.experimentalAcp;
|
||||
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
@@ -992,7 +1101,7 @@ export async function loadCliConfig(
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
@@ -1082,7 +1191,40 @@ export async function loadCliConfig(
|
||||
// always be true and the settings file can never disable recording.
|
||||
chatRecording:
|
||||
argv.chatRecording ?? settings.general?.chatRecording ?? true,
|
||||
lsp: {
|
||||
enabled: lspEnabled,
|
||||
allowed: lspAllowed,
|
||||
excluded: lspExcluded,
|
||||
},
|
||||
});
|
||||
|
||||
const shouldStartLsp = options.startLsp ?? true;
|
||||
if (shouldStartLsp && lspEnabled) {
|
||||
try {
|
||||
const lspService = new NativeLspService(
|
||||
config,
|
||||
config.getWorkspaceContext(),
|
||||
appEvents,
|
||||
fileService,
|
||||
ideContextStore,
|
||||
{
|
||||
allowedServers: lspAllowed,
|
||||
excludedServers: lspExcluded,
|
||||
requireTrustedWorkspace: folderTrust,
|
||||
inlineServerConfigs: lspLanguageServers,
|
||||
},
|
||||
);
|
||||
|
||||
await lspService.discoverAndPrepare();
|
||||
await lspService.start();
|
||||
lspClient = new NativeLspClient(lspService);
|
||||
config.setLspClient(lspClient);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to initialize native LSP service:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function allowedMcpServers(
|
||||
|
||||
38
packages/cli/src/config/lspSettingsSchema.ts
Normal file
38
packages/cli/src/config/lspSettingsSchema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
export const lspSettingsSchema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'lsp.enabled': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '启用 LSP 语言服务器协议支持'
|
||||
},
|
||||
'lsp.allowed': {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
default: [],
|
||||
description: '允许运行的 LSP 服务器列表'
|
||||
},
|
||||
'lsp.excluded': {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
default: [],
|
||||
description: '禁止运行的 LSP 服务器列表'
|
||||
},
|
||||
'lsp.autoDetect': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '自动检测项目语言并启动相应 LSP 服务器'
|
||||
},
|
||||
'lsp.serverTimeout': {
|
||||
type: 'number',
|
||||
default: 10000,
|
||||
description: 'LSP 服务器启动超时时间(毫秒)'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -160,6 +160,39 @@ 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 {
|
||||
@@ -723,6 +756,9 @@ export function loadSettings(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
|
||||
// Load VS Code settings as an additional source of configuration
|
||||
const vscodeSettings = loadVsCodeSettings(workspaceDir);
|
||||
|
||||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
@@ -827,6 +863,14 @@ export function loadSettings(
|
||||
userSettings = resolveEnvVarsInObject(userResult.settings);
|
||||
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
||||
|
||||
// Merge VS Code settings into workspace settings (VS Code settings take precedence)
|
||||
workspaceSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
workspaceSettings,
|
||||
vscodeSettings,
|
||||
) as Settings;
|
||||
|
||||
// Support legacy theme names
|
||||
if (userSettings.ui?.theme === 'VS') {
|
||||
userSettings.ui.theme = DefaultLight.name;
|
||||
@@ -840,11 +884,13 @@ export function loadSettings(
|
||||
}
|
||||
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
// We also include VS Code settings as they may contain trust-related settings
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
userSettings,
|
||||
vscodeSettings, // Include VS Code settings
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
||||
@@ -858,9 +904,18 @@ export function loadSettings(
|
||||
isTrusted,
|
||||
);
|
||||
|
||||
// Add VS Code settings to the temp merged settings for environment loading
|
||||
// Since loadEnvironment depends on settings, we need to consider VS Code settings as well
|
||||
const tempMergedSettingsWithVsCode = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
tempMergedSettings,
|
||||
vscodeSettings,
|
||||
) as Settings;
|
||||
|
||||
// loadEnviroment depends on settings so we have to create a temp version of
|
||||
// the settings to avoid a cycle
|
||||
loadEnvironment(tempMergedSettings);
|
||||
loadEnvironment(tempMergedSettingsWithVsCode);
|
||||
|
||||
// Create LoadedSettings first
|
||||
|
||||
|
||||
@@ -981,6 +981,27 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
experimental: {
|
||||
type: 'object',
|
||||
label: 'Experimental',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Experimental tool features.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Skills',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1022,6 +1043,59 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
lsp: {
|
||||
type: 'object',
|
||||
label: 'LSP',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Settings for the native Language Server Protocol integration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable LSP',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the native LSP client to connect to language servers discovered in the workspace.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allow LSP Servers',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Optional allowlist of LSP server names. If set, only matching servers will start.',
|
||||
showInDialog: false,
|
||||
},
|
||||
excluded: {
|
||||
type: 'array',
|
||||
label: 'Exclude LSP Servers',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Optional blocklist of LSP server names that should not start.',
|
||||
showInDialog: false,
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -254,6 +254,8 @@ export async function main() {
|
||||
[],
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
argv,
|
||||
undefined,
|
||||
{ startLsp: false },
|
||||
);
|
||||
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
|
||||
@@ -873,11 +873,11 @@ export default {
|
||||
'Session Stats': '会话统计',
|
||||
'Model Usage': '模型使用情况',
|
||||
Reqs: '请求数',
|
||||
'Input Tokens': '输入令牌',
|
||||
'Output Tokens': '输出令牌',
|
||||
'Input Tokens': '输入 token 数',
|
||||
'Output Tokens': '输出 token 数',
|
||||
'Savings Highlight:': '节省亮点:',
|
||||
'of input tokens were served from the cache, reducing costs.':
|
||||
'的输入令牌来自缓存,降低了成本',
|
||||
'从缓存载入 token ,降低了成本',
|
||||
'Tip: For a full token breakdown, run `/stats model`.':
|
||||
'提示:要查看完整的令牌明细,请运行 `/stats model`',
|
||||
'Model Stats For Nerds': '模型统计(技术细节)',
|
||||
|
||||
391
packages/cli/src/services/lsp/LspConnectionFactory.ts
Normal file
391
packages/cli/src/services/lsp/LspConnectionFactory.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import * as cp from 'node:child_process';
|
||||
import * as net from 'node:net';
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class JsonRpcConnection {
|
||||
private buffer = '';
|
||||
private nextId = 1;
|
||||
private disposed = false;
|
||||
private pendingRequests = new Map<string | number, PendingRequest>();
|
||||
private notificationHandlers: Array<(notification: JsonRpcMessage) => void> =
|
||||
[];
|
||||
private requestHandlers: Array<
|
||||
(request: JsonRpcMessage) => Promise<unknown>
|
||||
> = [];
|
||||
|
||||
constructor(
|
||||
private readonly writer: (data: string) => void,
|
||||
private readonly disposer?: () => void,
|
||||
) {}
|
||||
|
||||
listen(readable: NodeJS.ReadableStream): void {
|
||||
readable.on('data', (chunk: Buffer) => this.handleData(chunk));
|
||||
readable.on('error', (error) =>
|
||||
this.disposePending(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
send(message: JsonRpcMessage): void {
|
||||
this.writeMessage(message);
|
||||
}
|
||||
|
||||
onNotification(handler: (notification: JsonRpcMessage) => void): void {
|
||||
this.notificationHandlers.push(handler);
|
||||
}
|
||||
|
||||
onRequest(handler: (request: JsonRpcMessage) => Promise<unknown>): void {
|
||||
this.requestHandlers.push(handler);
|
||||
}
|
||||
|
||||
async initialize(params: unknown): Promise<unknown> {
|
||||
return this.sendRequest('initialize', params);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
try {
|
||||
await this.sendRequest('shutdown', {});
|
||||
} catch (_error) {
|
||||
// Ignore shutdown errors – the server may already be gone.
|
||||
} finally {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
return this.sendRequest(method, params);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.disposePending();
|
||||
this.disposer?.();
|
||||
}
|
||||
|
||||
private sendRequest(method: string, params: unknown): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
const payload: JsonRpcMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
const requestPromise = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`LSP request timeout: ${method}`));
|
||||
}, 15000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
});
|
||||
|
||||
this.writeMessage(payload);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
|
||||
const handler = this.requestHandlers[this.requestHandlers.length - 1];
|
||||
if (!handler) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not supported: ${message.method}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(message);
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: result ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: (error as Error).message ?? 'Internal error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleData(chunk: Buffer): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffer += chunk.toString('utf8');
|
||||
|
||||
while (true) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
|
||||
if (!lengthMatch) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLength = Number(lengthMatch[1]);
|
||||
const messageStart = headerEnd + 4;
|
||||
const messageEnd = messageStart + contentLength;
|
||||
|
||||
if (this.buffer.length < messageEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
const body = this.buffer.slice(messageStart, messageEnd);
|
||||
this.buffer = this.buffer.slice(messageEnd);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(body);
|
||||
this.routeMessage(message);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private routeMessage(message: JsonRpcMessage): void {
|
||||
if (typeof message?.id !== 'undefined' && !message.method) {
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(
|
||||
new Error(message.error.message || 'LSP request failed'),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method && typeof message.id !== 'undefined') {
|
||||
void this.handleServerRequest(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method) {
|
||||
for (const handler of this.notificationHandlers) {
|
||||
try {
|
||||
handler(message);
|
||||
} catch {
|
||||
// ignore handler errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessage(message: JsonRpcMessage): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(message);
|
||||
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
|
||||
this.writer(header + json);
|
||||
}
|
||||
|
||||
private disposePending(error?: Error): void {
|
||||
for (const [, pending] of Array.from(this.pendingRequests)) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error ?? new Error('LSP connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
interface LspConnection {
|
||||
connection: JsonRpcConnection;
|
||||
process?: cp.ChildProcess;
|
||||
socket?: net.Socket;
|
||||
}
|
||||
|
||||
interface SocketConnectionOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface JsonRpcMessage {
|
||||
jsonrpc: string;
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export class LspConnectionFactory {
|
||||
/**
|
||||
* 创建基于 stdio 的 LSP 连接
|
||||
*/
|
||||
static async createStdioConnection(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: cp.SpawnOptions,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnOptions: cp.SpawnOptions = {
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
};
|
||||
const processInstance = cp.spawn(command, args, spawnOptions);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('LSP server spawn timeout'));
|
||||
if (!processInstance.killed) {
|
||||
processInstance.kill();
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
processInstance.once('error', (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
|
||||
});
|
||||
|
||||
processInstance.once('spawn', () => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!processInstance.stdout || !processInstance.stdin) {
|
||||
reject(new Error('LSP server stdio not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = new JsonRpcConnection(
|
||||
(payload) => processInstance.stdin?.write(payload),
|
||||
() => processInstance.stdin?.end(),
|
||||
);
|
||||
|
||||
connection.listen(processInstance.stdout);
|
||||
processInstance.once('exit', () => connection.end());
|
||||
processInstance.once('close', () => connection.end());
|
||||
|
||||
resolve({
|
||||
connection,
|
||||
process: processInstance,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于 TCP 的 LSP 连接
|
||||
*/
|
||||
static async createTcpConnection(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return LspConnectionFactory.createSocketConnection(
|
||||
{ host, port },
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket)
|
||||
*/
|
||||
static async createSocketConnection(
|
||||
options: SocketConnectionOptions,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socketOptions = options.path
|
||||
? { path: options.path }
|
||||
: { host: options.host ?? '127.0.0.1', port: options.port };
|
||||
|
||||
if (!('path' in socketOptions) && !socketOptions.port) {
|
||||
reject(new Error('Socket transport requires port or path'));
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = net.createConnection(socketOptions);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('LSP server connection timeout'));
|
||||
socket.destroy();
|
||||
}, timeoutMs);
|
||||
|
||||
const onError = (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
|
||||
};
|
||||
|
||||
socket.once('error', onError);
|
||||
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timeoutId);
|
||||
socket.off('error', onError);
|
||||
|
||||
const connection = new JsonRpcConnection(
|
||||
(payload) => socket.write(payload),
|
||||
() => socket.destroy(),
|
||||
);
|
||||
connection.listen(socket);
|
||||
socket.once('close', () => connection.end());
|
||||
socket.once('error', () => connection.end());
|
||||
|
||||
resolve({
|
||||
connection,
|
||||
socket,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 LSP 连接
|
||||
*/
|
||||
static async closeConnection(lspConnection: LspConnection): Promise<void> {
|
||||
if (lspConnection.connection) {
|
||||
try {
|
||||
await lspConnection.connection.shutdown();
|
||||
} catch (e) {
|
||||
console.warn('LSP shutdown failed:', e);
|
||||
} finally {
|
||||
lspConnection.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (lspConnection.process && !lspConnection.process.killed) {
|
||||
lspConnection.process.kill();
|
||||
}
|
||||
|
||||
if (lspConnection.socket && !lspConnection.socket.destroyed) {
|
||||
lspConnection.socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
127
packages/cli/src/services/lsp/NativeLspService.test.ts
Normal file
127
packages/cli/src/services/lsp/NativeLspService.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NativeLspService } from './NativeLspService.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import type {
|
||||
Config as CoreConfig,
|
||||
WorkspaceContext,
|
||||
FileDiscoveryService,
|
||||
IdeContextStore,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// 模拟依赖项
|
||||
class MockConfig {
|
||||
rootPath = '/test/workspace';
|
||||
|
||||
isTrustedFolder(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
get(_key: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getProjectRoot(): string {
|
||||
return this.rootPath;
|
||||
}
|
||||
}
|
||||
|
||||
class MockWorkspaceContext {
|
||||
rootPath = '/test/workspace';
|
||||
|
||||
async fileExists(_path: string): Promise<boolean> {
|
||||
return _path.endsWith('.json') || _path.includes('package.json');
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<string> {
|
||||
if (_path.includes('.lsp.json')) {
|
||||
return JSON.stringify({
|
||||
typescript: {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
transport: 'stdio',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
}
|
||||
|
||||
resolvePath(_path: string): string {
|
||||
return this.rootPath + '/' + _path;
|
||||
}
|
||||
|
||||
isPathWithinWorkspace(_path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getDirectories(): string[] {
|
||||
return [this.rootPath];
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileDiscoveryService {
|
||||
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
|
||||
// 模拟发现一些文件
|
||||
return [
|
||||
'/test/workspace/src/index.ts',
|
||||
'/test/workspace/src/utils.ts',
|
||||
'/test/workspace/server.py',
|
||||
'/test/workspace/main.go',
|
||||
];
|
||||
}
|
||||
|
||||
shouldIgnoreFile(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockIdeContextStore {
|
||||
// 模拟 IDE 上下文存储
|
||||
}
|
||||
|
||||
describe('NativeLspService', () => {
|
||||
let lspService: NativeLspService;
|
||||
let mockConfig: MockConfig;
|
||||
let mockWorkspace: MockWorkspaceContext;
|
||||
let mockFileDiscovery: MockFileDiscoveryService;
|
||||
let mockIdeStore: MockIdeContextStore;
|
||||
let eventEmitter: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = new MockConfig();
|
||||
mockWorkspace = new MockWorkspaceContext();
|
||||
mockFileDiscovery = new MockFileDiscoveryService();
|
||||
mockIdeStore = new MockIdeContextStore();
|
||||
eventEmitter = new EventEmitter();
|
||||
|
||||
lspService = new NativeLspService(
|
||||
mockConfig as unknown as CoreConfig,
|
||||
mockWorkspace as unknown as WorkspaceContext,
|
||||
eventEmitter,
|
||||
mockFileDiscovery as unknown as FileDiscoveryService,
|
||||
mockIdeStore as unknown as IdeContextStore,
|
||||
);
|
||||
});
|
||||
|
||||
test('should initialize correctly', () => {
|
||||
expect(lspService).toBeDefined();
|
||||
});
|
||||
|
||||
test('should detect languages from workspace files', async () => {
|
||||
// 这个测试需要修改,因为我们无法直接访问私有方法
|
||||
await lspService.discoverAndPrepare();
|
||||
const status = lspService.getStatus();
|
||||
|
||||
// 检查服务是否已准备就绪
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
|
||||
test('should merge built-in presets with user configs', async () => {
|
||||
await lspService.discoverAndPrepare();
|
||||
|
||||
const status = lspService.getStatus();
|
||||
// 检查服务是否已准备就绪
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// 注意:实际的单元测试需要适当的测试框架配置
|
||||
// 这里只是一个结构示例
|
||||
2357
packages/cli/src/services/lsp/NativeLspService.ts
Normal file
2357
packages/cli/src/services/lsp/NativeLspService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { updateSettingsFilePreservingFormat } from './commentJson.js';
|
||||
import {
|
||||
updateSettingsFilePreservingFormat,
|
||||
applyUpdates,
|
||||
} from './commentJson.js';
|
||||
|
||||
describe('commentJson', () => {
|
||||
let tempDir: string;
|
||||
@@ -180,3 +183,18 @@ describe('commentJson', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyUpdates', () => {
|
||||
it('should apply updates correctly', () => {
|
||||
const original = { a: 1, b: { c: 2 } };
|
||||
const updates = { b: { c: 3 } };
|
||||
const result = applyUpdates(original, updates);
|
||||
expect(result).toEqual({ a: 1, b: { c: 3 } });
|
||||
});
|
||||
it('should apply updates correctly when empty', () => {
|
||||
const original = { a: 1, b: { c: 2 } };
|
||||
const updates = { b: {} };
|
||||
const result = applyUpdates(original, updates);
|
||||
expect(result).toEqual({ a: 1, b: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export function updateSettingsFilePreservingFormat(
|
||||
fs.writeFileSync(filePath, updatedContent, 'utf-8');
|
||||
}
|
||||
|
||||
function applyUpdates(
|
||||
export function applyUpdates(
|
||||
current: Record<string, unknown>,
|
||||
updates: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -50,6 +50,7 @@ function applyUpdates(
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length > 0 &&
|
||||
typeof result[key] === 'object' &&
|
||||
result[key] !== null &&
|
||||
!Array.isArray(result[key])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -61,6 +61,11 @@ import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { WebSearchTool } from '../tools/web-search/index.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js';
|
||||
import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js';
|
||||
import { LspFindReferencesTool } from '../tools/lsp-find-references.js';
|
||||
import { LspTool } from '../tools/lsp.js';
|
||||
import type { LspClient } from '../lsp/types.js';
|
||||
|
||||
// Other modules
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
@@ -287,6 +292,12 @@ export interface ConfigParameters {
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
lsp?: {
|
||||
enabled?: boolean;
|
||||
allowed?: string[];
|
||||
excluded?: string[];
|
||||
};
|
||||
lspClient?: LspClient;
|
||||
userMemory?: string;
|
||||
geminiMdFileCount?: number;
|
||||
approvalMode?: ApprovalMode;
|
||||
@@ -404,7 +415,7 @@ export class Config {
|
||||
private toolRegistry!: ToolRegistry;
|
||||
private promptRegistry!: PromptRegistry;
|
||||
private subagentManager!: SubagentManager;
|
||||
private skillManager!: SkillManager;
|
||||
private skillManager: SkillManager | null = null;
|
||||
private fileSystemService: FileSystemService;
|
||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
|
||||
@@ -429,6 +440,10 @@ export class Config {
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private readonly lspEnabled: boolean;
|
||||
private readonly lspAllowed?: string[];
|
||||
private readonly lspExcluded?: string[];
|
||||
private lspClient?: LspClient;
|
||||
private sessionSubagents: SubagentConfig[];
|
||||
private userMemory: string;
|
||||
private sdkMode: boolean;
|
||||
@@ -534,6 +549,10 @@ export class Config {
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.lspEnabled = params.lsp?.enabled ?? false;
|
||||
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
|
||||
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
|
||||
this.lspClient = params.lspClient;
|
||||
this.sessionSubagents = params.sessionSubagents ?? [];
|
||||
this.sdkMode = params.sdkMode ?? false;
|
||||
this.userMemory = params.userMemory ?? '';
|
||||
@@ -672,8 +691,10 @@ export class Config {
|
||||
}
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.subagentManager = new SubagentManager(this);
|
||||
this.skillManager = new SkillManager(this);
|
||||
await this.skillManager.startWatching();
|
||||
if (this.getExperimentalSkills()) {
|
||||
this.skillManager = new SkillManager(this);
|
||||
await this.skillManager.startWatching();
|
||||
}
|
||||
|
||||
// Load session subagents if they were provided before initialization
|
||||
if (this.sessionSubagents.length > 0) {
|
||||
@@ -1029,6 +1050,32 @@ export class Config {
|
||||
this.mcpServers = { ...this.mcpServers, ...servers };
|
||||
}
|
||||
|
||||
isLspEnabled(): boolean {
|
||||
return this.lspEnabled;
|
||||
}
|
||||
|
||||
getLspAllowed(): string[] | undefined {
|
||||
return this.lspAllowed;
|
||||
}
|
||||
|
||||
getLspExcluded(): string[] | undefined {
|
||||
return this.lspExcluded;
|
||||
}
|
||||
|
||||
getLspClient(): LspClient | undefined {
|
||||
return this.lspClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows wiring an LSP client after Config construction but before initialize().
|
||||
*/
|
||||
setLspClient(client: LspClient | undefined): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot set LSP client after initialization');
|
||||
}
|
||||
this.lspClient = client;
|
||||
}
|
||||
|
||||
getSessionSubagents(): SubagentConfig[] {
|
||||
return this.sessionSubagents;
|
||||
}
|
||||
@@ -1439,7 +1486,7 @@ export class Config {
|
||||
return this.subagentManager;
|
||||
}
|
||||
|
||||
getSkillManager(): SkillManager {
|
||||
getSkillManager(): SkillManager | null {
|
||||
return this.skillManager;
|
||||
}
|
||||
|
||||
@@ -1536,6 +1583,14 @@ export class Config {
|
||||
if (this.getWebSearchConfig()) {
|
||||
registerCoreTool(WebSearchTool, this);
|
||||
}
|
||||
if (this.isLspEnabled() && this.getLspClient()) {
|
||||
// Register the unified LSP tool (recommended)
|
||||
registerCoreTool(LspTool, this);
|
||||
// Keep legacy tools for backward compatibility
|
||||
registerCoreTool(LspGoToDefinitionTool, this);
|
||||
registerCoreTool(LspFindReferencesTool, this);
|
||||
registerCoreTool(LspWorkspaceSymbolTool, this);
|
||||
}
|
||||
|
||||
await registry.discoverAllTools();
|
||||
console.debug('ToolRegistry created', registry.getAllToolNames());
|
||||
|
||||
@@ -270,28 +270,28 @@ export function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
export async function createContentGenerator(
|
||||
config: ContentGeneratorConfig,
|
||||
gcConfig: Config,
|
||||
generatorConfig: ContentGeneratorConfig,
|
||||
config: Config,
|
||||
isInitialAuth?: boolean,
|
||||
): Promise<ContentGenerator> {
|
||||
const validation = validateModelConfig(config, false);
|
||||
const validation = validateModelConfig(generatorConfig, false);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.errors.map((e) => e.message).join('\n'));
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.USE_OPENAI) {
|
||||
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
|
||||
const authType = generatorConfig.authType;
|
||||
if (!authType) {
|
||||
throw new Error('ContentGeneratorConfig must have an authType');
|
||||
}
|
||||
|
||||
let baseGenerator: ContentGenerator;
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
const { createOpenAIContentGenerator } = await import(
|
||||
'./openaiContentGenerator/index.js'
|
||||
);
|
||||
|
||||
// Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag
|
||||
const generator = createOpenAIContentGenerator(config, gcConfig);
|
||||
return new LoggingContentGenerator(generator, gcConfig);
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.QWEN_OAUTH) {
|
||||
// Import required classes dynamically
|
||||
baseGenerator = createOpenAIContentGenerator(generatorConfig, config);
|
||||
} else if (authType === AuthType.QWEN_OAUTH) {
|
||||
const { getQwenOAuthClient: getQwenOauthClient } = await import(
|
||||
'../qwen/qwenOAuth2.js'
|
||||
);
|
||||
@@ -300,44 +300,38 @@ export async function createContentGenerator(
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the Qwen OAuth client (now includes integrated token management)
|
||||
// If this is initial auth, require cached credentials to detect missing credentials
|
||||
const qwenClient = await getQwenOauthClient(
|
||||
gcConfig,
|
||||
config,
|
||||
isInitialAuth ? { requireCachedCredentials: true } : undefined,
|
||||
);
|
||||
|
||||
// Create the content generator with dynamic token management
|
||||
const generator = new QwenContentGenerator(qwenClient, config, gcConfig);
|
||||
return new LoggingContentGenerator(generator, gcConfig);
|
||||
baseGenerator = new QwenContentGenerator(
|
||||
qwenClient,
|
||||
generatorConfig,
|
||||
config,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.USE_ANTHROPIC) {
|
||||
} else if (authType === AuthType.USE_ANTHROPIC) {
|
||||
const { createAnthropicContentGenerator } = await import(
|
||||
'./anthropicContentGenerator/index.js'
|
||||
);
|
||||
|
||||
const generator = createAnthropicContentGenerator(config, gcConfig);
|
||||
return new LoggingContentGenerator(generator, gcConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
config.authType === AuthType.USE_GEMINI ||
|
||||
config.authType === AuthType.USE_VERTEX_AI
|
||||
baseGenerator = createAnthropicContentGenerator(generatorConfig, config);
|
||||
} else if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
const { createGeminiContentGenerator } = await import(
|
||||
'./geminiContentGenerator/index.js'
|
||||
);
|
||||
const generator = createGeminiContentGenerator(config, gcConfig);
|
||||
return new LoggingContentGenerator(generator, gcConfig);
|
||||
baseGenerator = createGeminiContentGenerator(generatorConfig, config);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error creating contentGenerator: Unsupported authType: ${authType}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Error creating contentGenerator: Unsupported authType: ${config.authType}`,
|
||||
);
|
||||
return new LoggingContentGenerator(baseGenerator, config, generatorConfig);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { GenerateContentResponse } from '@google/genai';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { ContentGenerator } from '../contentGenerator.js';
|
||||
import { AuthType } from '../contentGenerator.js';
|
||||
import { LoggingContentGenerator } from './index.js';
|
||||
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
|
||||
import {
|
||||
@@ -50,14 +51,17 @@ const convertGeminiResponseToOpenAISpy = vi
|
||||
choices: [],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
const createConfig = (overrides: Record<string, unknown> = {}): Config =>
|
||||
({
|
||||
getContentGeneratorConfig: () => ({
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
...overrides,
|
||||
}),
|
||||
}) as Config;
|
||||
const createConfig = (overrides: Record<string, unknown> = {}): Config => {
|
||||
const configContent = {
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
getContentGeneratorConfig: () => configContent,
|
||||
getAuthType: () => configContent.authType as AuthType | undefined,
|
||||
} as Config;
|
||||
};
|
||||
|
||||
const createWrappedGenerator = (
|
||||
generateContent: ContentGenerator['generateContent'],
|
||||
@@ -124,13 +128,17 @@ describe('LoggingContentGenerator', () => {
|
||||
),
|
||||
vi.fn(),
|
||||
);
|
||||
const generatorConfig = {
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: 'logs',
|
||||
schemaCompliance: 'openapi_30' as const,
|
||||
};
|
||||
const generator = new LoggingContentGenerator(
|
||||
wrapped,
|
||||
createConfig({
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: 'logs',
|
||||
schemaCompliance: 'openapi_30',
|
||||
}),
|
||||
createConfig(),
|
||||
generatorConfig,
|
||||
);
|
||||
|
||||
const request = {
|
||||
@@ -225,9 +233,15 @@ describe('LoggingContentGenerator', () => {
|
||||
vi.fn().mockRejectedValue(error),
|
||||
vi.fn(),
|
||||
);
|
||||
const generatorConfig = {
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
enableOpenAILogging: true,
|
||||
};
|
||||
const generator = new LoggingContentGenerator(
|
||||
wrapped,
|
||||
createConfig({ enableOpenAILogging: true }),
|
||||
createConfig(),
|
||||
generatorConfig,
|
||||
);
|
||||
|
||||
const request = {
|
||||
@@ -293,9 +307,15 @@ describe('LoggingContentGenerator', () => {
|
||||
})(),
|
||||
),
|
||||
);
|
||||
const generatorConfig = {
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
enableOpenAILogging: true,
|
||||
};
|
||||
const generator = new LoggingContentGenerator(
|
||||
wrapped,
|
||||
createConfig({ enableOpenAILogging: true }),
|
||||
createConfig(),
|
||||
generatorConfig,
|
||||
);
|
||||
|
||||
const request = {
|
||||
@@ -345,9 +365,15 @@ describe('LoggingContentGenerator', () => {
|
||||
})(),
|
||||
),
|
||||
);
|
||||
const generatorConfig = {
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
enableOpenAILogging: true,
|
||||
};
|
||||
const generator = new LoggingContentGenerator(
|
||||
wrapped,
|
||||
createConfig({ enableOpenAILogging: true }),
|
||||
createConfig(),
|
||||
generatorConfig,
|
||||
);
|
||||
|
||||
const request = {
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
logApiRequest,
|
||||
logApiResponse,
|
||||
} from '../../telemetry/loggers.js';
|
||||
import type { ContentGenerator } from '../contentGenerator.js';
|
||||
import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../contentGenerator.js';
|
||||
import { isStructuredError } from '../../utils/quotaErrorDetection.js';
|
||||
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
|
||||
import { OpenAILogger } from '../../utils/openaiLogger.js';
|
||||
@@ -50,9 +53,11 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
constructor(
|
||||
private readonly wrapped: ContentGenerator,
|
||||
private readonly config: Config,
|
||||
generatorConfig: ContentGeneratorConfig,
|
||||
) {
|
||||
const generatorConfig = this.config.getContentGeneratorConfig();
|
||||
if (generatorConfig?.enableOpenAILogging) {
|
||||
// Extract fields needed for initialization from passed config
|
||||
// (config.getContentGeneratorConfig() may not be available yet during refreshAuth)
|
||||
if (generatorConfig.enableOpenAILogging) {
|
||||
this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir);
|
||||
this.schemaCompliance = generatorConfig.schemaCompliance;
|
||||
}
|
||||
@@ -89,7 +94,7 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
model,
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
this.config.getAuthType(),
|
||||
usageMetadata,
|
||||
responseText,
|
||||
),
|
||||
@@ -126,7 +131,7 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
errorMessage,
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
this.config.getAuthType(),
|
||||
errorType,
|
||||
errorStatus,
|
||||
),
|
||||
|
||||
@@ -111,6 +111,7 @@ export * from './skills/index.js';
|
||||
|
||||
// Export prompt logic
|
||||
export * from './prompts/mcp-prompts.js';
|
||||
export * from './lsp/types.js';
|
||||
|
||||
// Export specific tool logic
|
||||
export * from './tools/read-file.js';
|
||||
@@ -125,6 +126,8 @@ export * from './tools/memoryTool.js';
|
||||
export * from './tools/shell.js';
|
||||
export * from './tools/web-search/index.js';
|
||||
export * from './tools/read-many-files.js';
|
||||
export * from './tools/lsp-go-to-definition.js';
|
||||
export * from './tools/lsp-find-references.js';
|
||||
export * from './tools/mcp-client.js';
|
||||
export * from './tools/mcp-client-manager.js';
|
||||
export * from './tools/mcp-tool.js';
|
||||
|
||||
178
packages/core/src/lsp/types.ts
Normal file
178
packages/core/src/lsp/types.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface LspPosition {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface LspRange {
|
||||
start: LspPosition;
|
||||
end: LspPosition;
|
||||
}
|
||||
|
||||
export interface LspLocation {
|
||||
uri: string;
|
||||
range: LspRange;
|
||||
}
|
||||
|
||||
export interface LspLocationWithServer extends LspLocation {
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export interface LspSymbolInformation {
|
||||
name: string;
|
||||
kind?: string;
|
||||
location: LspLocation;
|
||||
containerName?: string;
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export interface LspReference extends LspLocationWithServer {
|
||||
readonly serverName?: string;
|
||||
}
|
||||
|
||||
export interface LspDefinition extends LspLocationWithServer {
|
||||
readonly serverName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover result containing documentation or type information.
|
||||
*/
|
||||
export interface LspHoverResult {
|
||||
/** The hover content as a string (normalized from MarkupContent/MarkedString). */
|
||||
contents: string;
|
||||
/** Optional range that the hover applies to. */
|
||||
range?: LspRange;
|
||||
/** The LSP server that provided this result. */
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call hierarchy item representing a function, method, or callable.
|
||||
*/
|
||||
export interface LspCallHierarchyItem {
|
||||
/** The name of this item. */
|
||||
name: string;
|
||||
/** The kind of this item (function, method, constructor, etc.) as readable string. */
|
||||
kind?: string;
|
||||
/** The raw numeric SymbolKind from LSP, preserved for server communication. */
|
||||
rawKind?: number;
|
||||
/** Additional details like signature or file path. */
|
||||
detail?: string;
|
||||
/** The URI of the document containing this item. */
|
||||
uri: string;
|
||||
/** The full range of this item. */
|
||||
range: LspRange;
|
||||
/** The range that should be selected when navigating to this item. */
|
||||
selectionRange: LspRange;
|
||||
/** Opaque data used by the server for subsequent calls. */
|
||||
data?: unknown;
|
||||
/** The LSP server that provided this item. */
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming call representing a function that calls the target.
|
||||
*/
|
||||
export interface LspCallHierarchyIncomingCall {
|
||||
/** The caller item. */
|
||||
from: LspCallHierarchyItem;
|
||||
/** The ranges where the call occurs within the caller. */
|
||||
fromRanges: LspRange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing call representing a function called by the target.
|
||||
*/
|
||||
export interface LspCallHierarchyOutgoingCall {
|
||||
/** The callee item. */
|
||||
to: LspCallHierarchyItem;
|
||||
/** The ranges where the call occurs within the caller. */
|
||||
fromRanges: LspRange[];
|
||||
}
|
||||
|
||||
export interface LspClient {
|
||||
/**
|
||||
* Search for symbols across the workspace.
|
||||
*/
|
||||
workspaceSymbols(
|
||||
query: string,
|
||||
limit?: number,
|
||||
): Promise<LspSymbolInformation[]>;
|
||||
|
||||
/**
|
||||
* Get hover information (documentation, type info) for a symbol.
|
||||
*/
|
||||
hover(
|
||||
location: LspLocation,
|
||||
serverName?: string,
|
||||
): Promise<LspHoverResult | null>;
|
||||
|
||||
/**
|
||||
* Get all symbols in a document.
|
||||
*/
|
||||
documentSymbols(
|
||||
uri: string,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspSymbolInformation[]>;
|
||||
|
||||
/**
|
||||
* Find where a symbol is defined.
|
||||
*/
|
||||
definitions(
|
||||
location: LspLocation,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspDefinition[]>;
|
||||
|
||||
/**
|
||||
* Find implementations of an interface or abstract method.
|
||||
*/
|
||||
implementations(
|
||||
location: LspLocation,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspDefinition[]>;
|
||||
|
||||
/**
|
||||
* Find all references to a symbol.
|
||||
*/
|
||||
references(
|
||||
location: LspLocation,
|
||||
serverName?: string,
|
||||
includeDeclaration?: boolean,
|
||||
limit?: number,
|
||||
): Promise<LspReference[]>;
|
||||
|
||||
/**
|
||||
* Prepare call hierarchy item at a position (functions/methods).
|
||||
*/
|
||||
prepareCallHierarchy(
|
||||
location: LspLocation,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspCallHierarchyItem[]>;
|
||||
|
||||
/**
|
||||
* Find all functions/methods that call the given function.
|
||||
*/
|
||||
incomingCalls(
|
||||
item: LspCallHierarchyItem,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspCallHierarchyIncomingCall[]>;
|
||||
|
||||
/**
|
||||
* Find all functions/methods called by the given function.
|
||||
*/
|
||||
outgoingCalls(
|
||||
item: LspCallHierarchyItem,
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
): Promise<LspCallHierarchyOutgoingCall[]>;
|
||||
}
|
||||
@@ -235,6 +235,7 @@ export class SkillManager {
|
||||
}
|
||||
|
||||
this.watchStarted = true;
|
||||
await this.ensureUserSkillsDir();
|
||||
await this.refreshCache();
|
||||
this.updateWatchersFromCache();
|
||||
}
|
||||
@@ -486,29 +487,14 @@ export class SkillManager {
|
||||
}
|
||||
|
||||
private updateWatchersFromCache(): void {
|
||||
const desiredPaths = new Set<string>();
|
||||
|
||||
for (const level of ['project', 'user'] as const) {
|
||||
const baseDir = this.getSkillsBaseDir(level);
|
||||
const parentDir = path.dirname(baseDir);
|
||||
if (fsSync.existsSync(parentDir)) {
|
||||
desiredPaths.add(parentDir);
|
||||
}
|
||||
if (fsSync.existsSync(baseDir)) {
|
||||
desiredPaths.add(baseDir);
|
||||
}
|
||||
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
for (const skill of levelSkills) {
|
||||
const skillDir = path.dirname(skill.filePath);
|
||||
if (fsSync.existsSync(skillDir)) {
|
||||
desiredPaths.add(skillDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
const watchTargets = new Set<string>(
|
||||
(['project', 'user'] as const)
|
||||
.map((level) => this.getSkillsBaseDir(level))
|
||||
.filter((baseDir) => fsSync.existsSync(baseDir)),
|
||||
);
|
||||
|
||||
for (const existingPath of this.watchers.keys()) {
|
||||
if (!desiredPaths.has(existingPath)) {
|
||||
if (!watchTargets.has(existingPath)) {
|
||||
void this.watchers
|
||||
.get(existingPath)
|
||||
?.close()
|
||||
@@ -522,7 +508,7 @@ export class SkillManager {
|
||||
}
|
||||
}
|
||||
|
||||
for (const watchPath of desiredPaths) {
|
||||
for (const watchPath of watchTargets) {
|
||||
if (this.watchers.has(watchPath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -557,4 +543,16 @@ export class SkillManager {
|
||||
void this.refreshCache().then(() => this.updateWatchersFromCache());
|
||||
}, 150);
|
||||
}
|
||||
|
||||
private async ensureUserSkillsDir(): Promise<void> {
|
||||
const baseDir = this.getSkillsBaseDir('user');
|
||||
try {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to create user skills directory at ${baseDir}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
308
packages/core/src/tools/lsp-find-references.ts
Normal file
308
packages/core/src/tools/lsp-find-references.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
308
packages/core/src/tools/lsp-go-to-definition.ts
Normal file
308
packages/core/src/tools/lsp-go-to-definition.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
180
packages/core/src/tools/lsp-workspace-symbol.ts
Normal file
180
packages/core/src/tools/lsp-workspace-symbol.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolDisplayNames, ToolNames } from './tool-names.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { LspSymbolInformation } from '../lsp/types.js';
|
||||
|
||||
export interface LspWorkspaceSymbolParams {
|
||||
/**
|
||||
* Query string to search symbols (e.g., function or class name).
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* Maximum number of results to return.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class LspWorkspaceSymbolInvocation extends BaseToolInvocation<
|
||||
LspWorkspaceSymbolParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
params: LspWorkspaceSymbolParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `LSP workspace symbol search(按名称找定义/实现/引用) for "${this.params.query}"`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<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);
|
||||
}
|
||||
}
|
||||
1220
packages/core/src/tools/lsp.test.ts
Normal file
1220
packages/core/src/tools/lsp.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
960
packages/core/src/tools/lsp.ts
Normal file
960
packages/core/src/tools/lsp.ts
Normal file
@@ -0,0 +1,960 @@
|
||||
/**
|
||||
* @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 {
|
||||
LspCallHierarchyIncomingCall,
|
||||
LspCallHierarchyItem,
|
||||
LspCallHierarchyOutgoingCall,
|
||||
LspClient,
|
||||
LspDefinition,
|
||||
LspLocation,
|
||||
LspRange,
|
||||
LspReference,
|
||||
LspSymbolInformation,
|
||||
} from '../lsp/types.js';
|
||||
|
||||
/**
|
||||
* Supported LSP operations.
|
||||
*/
|
||||
export type LspOperation =
|
||||
| 'goToDefinition'
|
||||
| 'findReferences'
|
||||
| 'hover'
|
||||
| 'documentSymbol'
|
||||
| 'workspaceSymbol'
|
||||
| 'goToImplementation'
|
||||
| 'prepareCallHierarchy'
|
||||
| 'incomingCalls'
|
||||
| 'outgoingCalls';
|
||||
|
||||
/**
|
||||
* Parameters for the unified LSP tool.
|
||||
*/
|
||||
export interface LspToolParams {
|
||||
/** Operation to perform. */
|
||||
operation: LspOperation;
|
||||
/** File path (absolute or workspace-relative). */
|
||||
filePath?: 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 reference results. */
|
||||
includeDeclaration?: boolean;
|
||||
/** Query string for workspace symbol search. */
|
||||
query?: string;
|
||||
/** Call hierarchy item from a previous call hierarchy operation. */
|
||||
callHierarchyItem?: LspCallHierarchyItem;
|
||||
/** Optional server name override. */
|
||||
serverName?: string;
|
||||
/** Optional maximum number of results. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type ResolvedTarget =
|
||||
| {
|
||||
location: LspLocation;
|
||||
description: string;
|
||||
}
|
||||
| { error: string };
|
||||
|
||||
/** Operations that require filePath and line. */
|
||||
const LOCATION_REQUIRED_OPERATIONS = new Set<LspOperation>([
|
||||
'goToDefinition',
|
||||
'findReferences',
|
||||
'hover',
|
||||
'goToImplementation',
|
||||
'prepareCallHierarchy',
|
||||
]);
|
||||
|
||||
/** Operations that only require filePath. */
|
||||
const FILE_REQUIRED_OPERATIONS = new Set<LspOperation>(['documentSymbol']);
|
||||
|
||||
/** Operations that require query. */
|
||||
const QUERY_REQUIRED_OPERATIONS = new Set<LspOperation>(['workspaceSymbol']);
|
||||
|
||||
/** Operations that require callHierarchyItem. */
|
||||
const ITEM_REQUIRED_OPERATIONS = new Set<LspOperation>([
|
||||
'incomingCalls',
|
||||
'outgoingCalls',
|
||||
]);
|
||||
|
||||
class LspToolInvocation extends BaseToolInvocation<LspToolParams, ToolResult> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
params: LspToolParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const operationLabel = this.getOperationLabel();
|
||||
if (this.params.operation === 'workspaceSymbol') {
|
||||
return `LSP ${operationLabel} for "${this.params.query ?? ''}"`;
|
||||
}
|
||||
if (this.params.operation === 'documentSymbol') {
|
||||
return this.params.filePath
|
||||
? `LSP ${operationLabel} for ${this.params.filePath}`
|
||||
: `LSP ${operationLabel}`;
|
||||
}
|
||||
if (
|
||||
this.params.operation === 'incomingCalls' ||
|
||||
this.params.operation === 'outgoingCalls'
|
||||
) {
|
||||
return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`;
|
||||
}
|
||||
if (this.params.filePath && this.params.line !== undefined) {
|
||||
return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`;
|
||||
}
|
||||
if (this.params.filePath) {
|
||||
return `LSP ${operationLabel} for ${this.params.filePath}`;
|
||||
}
|
||||
return `LSP ${operationLabel}`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const client = this.config.getLspClient();
|
||||
if (!client || !this.config.isLspEnabled()) {
|
||||
const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
switch (this.params.operation) {
|
||||
case 'goToDefinition':
|
||||
return this.executeDefinitions(client);
|
||||
case 'findReferences':
|
||||
return this.executeReferences(client);
|
||||
case 'hover':
|
||||
return this.executeHover(client);
|
||||
case 'documentSymbol':
|
||||
return this.executeDocumentSymbols(client);
|
||||
case 'workspaceSymbol':
|
||||
return this.executeWorkspaceSymbols(client);
|
||||
case 'goToImplementation':
|
||||
return this.executeImplementations(client);
|
||||
case 'prepareCallHierarchy':
|
||||
return this.executePrepareCallHierarchy(client);
|
||||
case 'incomingCalls':
|
||||
return this.executeIncomingCalls(client);
|
||||
case 'outgoingCalls':
|
||||
return this.executeOutgoingCalls(client);
|
||||
default: {
|
||||
const message = `Unsupported LSP operation: ${this.params.operation}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeDefinitions(client: LspClient): Promise<ToolResult> {
|
||||
const target = this.resolveLocationTarget();
|
||||
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,
|
||||
this.params.serverName,
|
||||
limit,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = `LSP go-to-definition failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
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.formatLocationWithServer(definition, workspaceRoot)}`,
|
||||
);
|
||||
|
||||
const heading = `Definitions for ${target.description}:`;
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n'),
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeImplementations(client: LspClient): Promise<ToolResult> {
|
||||
const target = this.resolveLocationTarget();
|
||||
if ('error' in target) {
|
||||
return { llmContent: target.error, returnDisplay: target.error };
|
||||
}
|
||||
|
||||
const limit = this.params.limit ?? 20;
|
||||
let implementations: LspDefinition[] = [];
|
||||
try {
|
||||
implementations = await client.implementations(
|
||||
target.location,
|
||||
this.params.serverName,
|
||||
limit,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = `LSP go-to-implementation failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!implementations.length) {
|
||||
const message = `No implementations found for ${target.description}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const lines = implementations
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(implementation, index) =>
|
||||
`${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`,
|
||||
);
|
||||
|
||||
const heading = `Implementations for ${target.description}:`;
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n'),
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeReferences(client: LspClient): Promise<ToolResult> {
|
||||
const target = this.resolveLocationTarget();
|
||||
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,
|
||||
this.params.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.formatLocationWithServer(reference, workspaceRoot)}`,
|
||||
);
|
||||
|
||||
const heading = `References for ${target.description}:`;
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n'),
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeHover(client: LspClient): Promise<ToolResult> {
|
||||
const target = this.resolveLocationTarget();
|
||||
if ('error' in target) {
|
||||
return { llmContent: target.error, returnDisplay: target.error };
|
||||
}
|
||||
|
||||
let hoverText = '';
|
||||
try {
|
||||
const result = await client.hover(
|
||||
target.location,
|
||||
this.params.serverName,
|
||||
);
|
||||
if (result) {
|
||||
hoverText = result.contents ?? '';
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `LSP hover failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!hoverText || hoverText.trim().length === 0) {
|
||||
const message = `No hover information found for ${target.description}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const heading = `Hover for ${target.description}:`;
|
||||
const content = hoverText.trim();
|
||||
return {
|
||||
llmContent: `${heading}\n${content}`,
|
||||
returnDisplay: content,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeDocumentSymbols(client: LspClient): Promise<ToolResult> {
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const filePath = this.params.filePath ?? '';
|
||||
const uri = this.resolveUri(filePath, workspaceRoot);
|
||||
if (!uri) {
|
||||
const message = 'A valid filePath is required for document symbols.';
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const limit = this.params.limit ?? 50;
|
||||
let symbols: LspSymbolInformation[] = [];
|
||||
try {
|
||||
symbols = await client.documentSymbols(
|
||||
uri,
|
||||
this.params.serverName,
|
||||
limit,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = `LSP document symbols failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!symbols.length) {
|
||||
const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
|
||||
const message = `No document symbols found for ${fileLabel}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const lines = symbols.slice(0, limit).map((symbol, index) => {
|
||||
const location = this.formatLocationWithoutServer(
|
||||
symbol.location,
|
||||
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 fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
|
||||
const heading = `Document symbols for ${fileLabel}:`;
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n'),
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeWorkspaceSymbols(
|
||||
client: LspClient,
|
||||
): Promise<ToolResult> {
|
||||
const limit = this.params.limit ?? 20;
|
||||
const query = this.params.query ?? '';
|
||||
let symbols: LspSymbolInformation[] = [];
|
||||
try {
|
||||
symbols = await client.workspaceSymbols(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 "${query}".`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const lines = symbols.slice(0, limit).map((symbol, index) => {
|
||||
const location = this.formatLocationWithoutServer(
|
||||
symbol.location,
|
||||
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 "${query}":`;
|
||||
|
||||
// Also fetch references for the top match to provide additional context.
|
||||
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.formatLocationWithoutServer(
|
||||
ref,
|
||||
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 async executePrepareCallHierarchy(
|
||||
client: LspClient,
|
||||
): Promise<ToolResult> {
|
||||
const target = this.resolveLocationTarget();
|
||||
if ('error' in target) {
|
||||
return { llmContent: target.error, returnDisplay: target.error };
|
||||
}
|
||||
|
||||
const limit = this.params.limit ?? 20;
|
||||
let items: LspCallHierarchyItem[] = [];
|
||||
try {
|
||||
items = await client.prepareCallHierarchy(
|
||||
target.location,
|
||||
this.params.serverName,
|
||||
limit,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = `LSP call hierarchy prepare failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
const message = `No call hierarchy items found for ${target.description}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const slicedItems = items.slice(0, limit);
|
||||
const lines = slicedItems.map((item, index) =>
|
||||
this.formatCallHierarchyItemLine(item, index, workspaceRoot),
|
||||
);
|
||||
|
||||
const heading = `Call hierarchy items for ${target.description}:`;
|
||||
const jsonSection = this.formatJsonSection(
|
||||
'Call hierarchy items (JSON)',
|
||||
slicedItems,
|
||||
);
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n') + jsonSection,
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeIncomingCalls(client: LspClient): Promise<ToolResult> {
|
||||
const item = this.params.callHierarchyItem;
|
||||
if (!item) {
|
||||
const message = 'callHierarchyItem is required for incomingCalls.';
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const limit = this.params.limit ?? 20;
|
||||
const serverName = this.params.serverName ?? item.serverName;
|
||||
let calls: LspCallHierarchyIncomingCall[] = [];
|
||||
try {
|
||||
calls = await client.incomingCalls(item, serverName, limit);
|
||||
} catch (error) {
|
||||
const message = `LSP incoming calls failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!calls.length) {
|
||||
const message = `No incoming calls found for ${this.describeCallHierarchyItemFull(
|
||||
item,
|
||||
)}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const slicedCalls = calls.slice(0, limit);
|
||||
const lines = slicedCalls.map((call, index) => {
|
||||
const targetItem = call.from;
|
||||
const location = this.formatLocationWithServer(
|
||||
{
|
||||
uri: targetItem.uri,
|
||||
range: targetItem.selectionRange,
|
||||
serverName: targetItem.serverName,
|
||||
},
|
||||
workspaceRoot,
|
||||
);
|
||||
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
|
||||
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
|
||||
const rangeSuffix = this.formatCallRanges(call.fromRanges);
|
||||
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
|
||||
});
|
||||
|
||||
const heading = `Incoming calls for ${this.describeCallHierarchyItemFull(
|
||||
item,
|
||||
)}:`;
|
||||
const jsonSection = this.formatJsonSection(
|
||||
'Incoming calls (JSON)',
|
||||
slicedCalls,
|
||||
);
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n') + jsonSection,
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeOutgoingCalls(client: LspClient): Promise<ToolResult> {
|
||||
const item = this.params.callHierarchyItem;
|
||||
if (!item) {
|
||||
const message = 'callHierarchyItem is required for outgoingCalls.';
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const limit = this.params.limit ?? 20;
|
||||
const serverName = this.params.serverName ?? item.serverName;
|
||||
let calls: LspCallHierarchyOutgoingCall[] = [];
|
||||
try {
|
||||
calls = await client.outgoingCalls(item, serverName, limit);
|
||||
} catch (error) {
|
||||
const message = `LSP outgoing calls failed: ${
|
||||
(error as Error)?.message || String(error)
|
||||
}`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
if (!calls.length) {
|
||||
const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull(
|
||||
item,
|
||||
)}.`;
|
||||
return { llmContent: message, returnDisplay: message };
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const slicedCalls = calls.slice(0, limit);
|
||||
const lines = slicedCalls.map((call, index) => {
|
||||
const targetItem = call.to;
|
||||
const location = this.formatLocationWithServer(
|
||||
{
|
||||
uri: targetItem.uri,
|
||||
range: targetItem.selectionRange,
|
||||
serverName: targetItem.serverName,
|
||||
},
|
||||
workspaceRoot,
|
||||
);
|
||||
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
|
||||
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
|
||||
const rangeSuffix = this.formatCallRanges(call.fromRanges);
|
||||
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
|
||||
});
|
||||
|
||||
const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull(
|
||||
item,
|
||||
)}:`;
|
||||
const jsonSection = this.formatJsonSection(
|
||||
'Outgoing calls (JSON)',
|
||||
slicedCalls,
|
||||
);
|
||||
return {
|
||||
llmContent: [heading, ...lines].join('\n') + jsonSection,
|
||||
returnDisplay: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveLocationTarget(): ResolvedTarget {
|
||||
const filePath = this.params.filePath;
|
||||
if (!filePath) {
|
||||
return {
|
||||
error: 'filePath is required for this operation.',
|
||||
};
|
||||
}
|
||||
if (typeof this.params.line !== 'number') {
|
||||
return {
|
||||
error: 'line is required for this operation.',
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const uri = this.resolveUri(filePath, workspaceRoot);
|
||||
if (!uri) {
|
||||
return {
|
||||
error: 'A valid filePath is required when specifying a line/character.',
|
||||
};
|
||||
}
|
||||
|
||||
const position = {
|
||||
line: Math.max(0, Math.floor(this.params.line - 1)),
|
||||
character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)),
|
||||
};
|
||||
const location: LspLocation = {
|
||||
uri,
|
||||
range: { start: position, end: position },
|
||||
};
|
||||
const description = this.formatLocationWithServer(
|
||||
{ ...location, serverName: this.params.serverName },
|
||||
workspaceRoot,
|
||||
);
|
||||
return {
|
||||
location,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveUri(filePath: string, workspaceRoot: string): string | null {
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
if (filePath.startsWith('file://') || filePath.includes('://')) {
|
||||
return filePath;
|
||||
}
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(workspaceRoot, filePath);
|
||||
return pathToFileURL(absolutePath).toString();
|
||||
}
|
||||
|
||||
private formatLocationWithServer(
|
||||
location: 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}`;
|
||||
}
|
||||
|
||||
private formatLocationWithoutServer(
|
||||
location: LspLocation,
|
||||
workspaceRoot: string,
|
||||
): string {
|
||||
const { uri, range } = 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}`;
|
||||
}
|
||||
|
||||
private formatCallHierarchyItemLine(
|
||||
item: LspCallHierarchyItem,
|
||||
index: number,
|
||||
workspaceRoot: string,
|
||||
): string {
|
||||
const location = this.formatLocationWithServer(
|
||||
{
|
||||
uri: item.uri,
|
||||
range: item.selectionRange,
|
||||
serverName: item.serverName,
|
||||
},
|
||||
workspaceRoot,
|
||||
);
|
||||
const kind = item.kind ? ` (${item.kind})` : '';
|
||||
const detail = item.detail ? ` ${item.detail}` : '';
|
||||
return `${index + 1}. ${item.name}${kind}${detail} - ${location}`;
|
||||
}
|
||||
|
||||
private formatCallRanges(ranges: LspRange[]): string {
|
||||
if (!ranges.length) {
|
||||
return '';
|
||||
}
|
||||
const formatted = ranges.map((range) => this.formatPosition(range.start));
|
||||
const maxShown = 3;
|
||||
const shown = formatted.slice(0, maxShown);
|
||||
const extra =
|
||||
formatted.length > maxShown
|
||||
? `, +${formatted.length - maxShown} more`
|
||||
: '';
|
||||
return ` (calls at ${shown.join(', ')}${extra})`;
|
||||
}
|
||||
|
||||
private formatPosition(position: LspRange['start']): string {
|
||||
return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
private formatUriForDisplay(uri: string, workspaceRoot: string): string {
|
||||
let filePath = uri;
|
||||
if (uri.startsWith('file://')) {
|
||||
filePath = fileURLToPath(uri);
|
||||
}
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return path.relative(workspaceRoot, filePath) || '.';
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private formatJsonSection(label: string, data: unknown): string {
|
||||
return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`;
|
||||
}
|
||||
|
||||
private describeCallHierarchyItemShort(): string {
|
||||
const item = this.params.callHierarchyItem;
|
||||
if (!item) {
|
||||
return 'call hierarchy item';
|
||||
}
|
||||
return item.name || 'call hierarchy item';
|
||||
}
|
||||
|
||||
private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string {
|
||||
const workspaceRoot = this.config.getProjectRoot();
|
||||
const location = this.formatLocationWithServer(
|
||||
{
|
||||
uri: item.uri,
|
||||
range: item.selectionRange,
|
||||
serverName: item.serverName,
|
||||
},
|
||||
workspaceRoot,
|
||||
);
|
||||
return `${item.name} at ${location}`;
|
||||
}
|
||||
|
||||
private getOperationLabel(): string {
|
||||
switch (this.params.operation) {
|
||||
case 'goToDefinition':
|
||||
return 'go-to-definition';
|
||||
case 'findReferences':
|
||||
return 'find-references';
|
||||
case 'hover':
|
||||
return 'hover';
|
||||
case 'documentSymbol':
|
||||
return 'document symbols';
|
||||
case 'workspaceSymbol':
|
||||
return 'workspace symbol search';
|
||||
case 'goToImplementation':
|
||||
return 'go-to-implementation';
|
||||
case 'prepareCallHierarchy':
|
||||
return 'prepare call hierarchy';
|
||||
case 'incomingCalls':
|
||||
return 'incoming calls';
|
||||
case 'outgoingCalls':
|
||||
return 'outgoing calls';
|
||||
default:
|
||||
return this.params.operation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified LSP tool that supports multiple operations:
|
||||
* - goToDefinition: Find where a symbol is defined
|
||||
* - findReferences: Find all references to a symbol
|
||||
* - hover: Get hover information (documentation, type info)
|
||||
* - documentSymbol: Get all symbols in a document
|
||||
* - workspaceSymbol: Search for symbols across the workspace
|
||||
* - goToImplementation: Find implementations of an interface or abstract method
|
||||
* - prepareCallHierarchy: Get call hierarchy item at a position
|
||||
* - incomingCalls: Find all functions that call the given function
|
||||
* - outgoingCalls: Find all functions called by the given function
|
||||
*/
|
||||
export class LspTool extends BaseDeclarativeTool<LspToolParams, ToolResult> {
|
||||
static readonly Name = ToolNames.LSP;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
LspTool.Name,
|
||||
ToolDisplayNames.LSP,
|
||||
'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.',
|
||||
Kind.Other,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'LSP operation to execute.',
|
||||
enum: [
|
||||
'goToDefinition',
|
||||
'findReferences',
|
||||
'hover',
|
||||
'documentSymbol',
|
||||
'workspaceSymbol',
|
||||
'goToImplementation',
|
||||
'prepareCallHierarchy',
|
||||
'incomingCalls',
|
||||
'outgoingCalls',
|
||||
],
|
||||
},
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'File path (absolute or workspace-relative).',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Symbol query for workspace symbol search.',
|
||||
},
|
||||
callHierarchyItem: {
|
||||
$ref: '#/definitions/LspCallHierarchyItem',
|
||||
description: 'Call hierarchy item for incoming/outgoing calls.',
|
||||
},
|
||||
serverName: {
|
||||
type: 'string',
|
||||
description: 'Optional LSP server name to target.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Optional maximum number of results to return.',
|
||||
},
|
||||
},
|
||||
required: ['operation'],
|
||||
definitions: {
|
||||
LspPosition: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
line: { type: 'number' },
|
||||
character: { type: 'number' },
|
||||
},
|
||||
required: ['line', 'character'],
|
||||
},
|
||||
LspRange: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
start: { $ref: '#/definitions/LspPosition' },
|
||||
end: { $ref: '#/definitions/LspPosition' },
|
||||
},
|
||||
required: ['start', 'end'],
|
||||
},
|
||||
LspCallHierarchyItem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
kind: { type: 'string' },
|
||||
rawKind: { type: 'number' },
|
||||
detail: { type: 'string' },
|
||||
uri: { type: 'string' },
|
||||
range: { $ref: '#/definitions/LspRange' },
|
||||
selectionRange: { $ref: '#/definitions/LspRange' },
|
||||
data: {},
|
||||
serverName: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'uri', 'range', 'selectionRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
params: LspToolParams,
|
||||
): string | null {
|
||||
const operation = params.operation;
|
||||
|
||||
if (LOCATION_REQUIRED_OPERATIONS.has(operation)) {
|
||||
if (!params.filePath || params.filePath.trim() === '') {
|
||||
return `filePath is required for ${operation}.`;
|
||||
}
|
||||
if (typeof params.line !== 'number') {
|
||||
return `line is required for ${operation}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (FILE_REQUIRED_OPERATIONS.has(operation)) {
|
||||
if (!params.filePath || params.filePath.trim() === '') {
|
||||
return `filePath is required for ${operation}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (QUERY_REQUIRED_OPERATIONS.has(operation)) {
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
return `query is required for ${operation}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ITEM_REQUIRED_OPERATIONS.has(operation)) {
|
||||
if (!params.callHierarchyItem) {
|
||||
return `callHierarchyItem is required for ${operation}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.line !== undefined && params.line < 1) {
|
||||
return 'line must be a positive number.';
|
||||
}
|
||||
if (params.character !== undefined && params.character < 1) {
|
||||
return 'character must be a positive number.';
|
||||
}
|
||||
if (params.limit !== undefined && params.limit <= 0) {
|
||||
return 'limit must be a positive number.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: LspToolParams,
|
||||
): ToolInvocation<LspToolParams, ToolResult> {
|
||||
return new LspToolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
|
||||
this.skillManager = config.getSkillManager();
|
||||
this.skillManager = config.getSkillManager()!;
|
||||
this.skillManager.addChangeListener(() => {
|
||||
void this.refreshSkills();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -48,6 +53,11 @@ export const ToolDisplayNames = {
|
||||
WEB_FETCH: 'WebFetch',
|
||||
WEB_SEARCH: 'WebSearch',
|
||||
LS: 'ListFiles',
|
||||
LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol',
|
||||
LSP_GO_TO_DEFINITION: 'LspGoToDefinition',
|
||||
LSP_FIND_REFERENCES: 'LspFindReferences',
|
||||
/** Unified LSP tool display name. */
|
||||
LSP: 'Lsp',
|
||||
} as const;
|
||||
|
||||
// Migration from old tool names to new tool names
|
||||
@@ -56,6 +66,8 @@ export const ToolDisplayNames = {
|
||||
export const ToolNamesMigration = {
|
||||
search_file_content: ToolNames.GREP, // Legacy name from grep tool
|
||||
replace: ToolNames.EDIT, // Legacy name from edit tool
|
||||
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
|
||||
find_references: ToolNames.LSP_FIND_REFERENCES,
|
||||
} as const;
|
||||
|
||||
// Migration from old tool display names to new tool display names
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
255
packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md
Normal file
255
packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 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 逻辑,减少首次调用延迟
|
||||
@@ -1,6 +1,11 @@
|
||||
# Qwen Code Companion
|
||||
|
||||
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately.
|
||||
[](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
|
||||
[](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)
|
||||
|
||||
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive chat interface. This extension bundles everything you need — no additional installation required.
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -11,7 +16,7 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua
|
||||
|
||||
## Features
|
||||
|
||||
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
|
||||
- **Native IDE experience**: Dedicated Qwen Code Chat panel accessed via the Qwen icon in the editor title bar
|
||||
- **Native diffing**: Review, edit, and accept changes in VS Code's diff view
|
||||
- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made
|
||||
- **File management**: @-mention files or attach files and images using the system file picker
|
||||
@@ -20,73 +25,46 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua
|
||||
|
||||
## Requirements
|
||||
|
||||
- Visual Studio Code 1.85.0 or newer
|
||||
- Visual Studio Code 1.85.0 or newer (also works with Cursor, Windsurf, and other VS Code-based editors)
|
||||
|
||||
## Installation
|
||||
## Quick Start
|
||||
|
||||
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
|
||||
1. **Install** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) or [Open VSX Registry](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion)
|
||||
|
||||
2. Two ways to use
|
||||
- Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).
|
||||
- Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI).
|
||||
2. **Open the Chat panel** using one of these methods:
|
||||
- Click the **Qwen icon** in the top-right corner of the editor
|
||||
- Run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`)
|
||||
|
||||
## Development and Debugging
|
||||
3. **Start chatting** — Ask Qwen to help with coding tasks, explain code, fix bugs, or write new features
|
||||
|
||||
To debug and develop this extension locally:
|
||||
## Commands
|
||||
|
||||
1. **Clone the repository**
|
||||
| Command | Description |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `Qwen Code: Open` | Open the Qwen Code Chat panel |
|
||||
| `Qwen Code: Run` | Launch a classic terminal session with the bundled CLI |
|
||||
| `Qwen Code: Accept Current Diff` | Accept the currently displayed diff |
|
||||
| `Qwen Code: Close Diff Editor` | Close/reject the current diff |
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
```
|
||||
## Feedback & Issues
|
||||
|
||||
2. **Install dependencies**
|
||||
- 🐛 [Report bugs](https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&labels=bug,vscode-ide-companion)
|
||||
- 💡 [Request features](https://github.com/QwenLM/qwen-code/issues/new?template=feature_request.yml&labels=enhancement,vscode-ide-companion)
|
||||
- 📖 [Documentation](https://qwenlm.github.io/qwen-code-docs/)
|
||||
- 📋 [Changelog](https://github.com/QwenLM/qwen-code/releases)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or if using pnpm
|
||||
pnpm install
|
||||
```
|
||||
## Contributing
|
||||
|
||||
3. **Start debugging**
|
||||
We welcome contributions! See our [Contributing Guide](https://github.com/QwenLM/qwen-code/blob/main/CONTRIBUTING.md) for details on:
|
||||
|
||||
```bash
|
||||
code . # Open the project root in VS Code
|
||||
```
|
||||
- Open the `packages/vscode-ide-companion/src/extension.ts` file
|
||||
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
|
||||
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
|
||||
- Press `F5` to launch Extension Development Host
|
||||
|
||||
4. **Make changes and reload**
|
||||
- Edit the source code in the original VS Code window
|
||||
- To see your changes, reload the Extension Development Host window by:
|
||||
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
|
||||
- Or clicking the "Reload" button in the debug toolbar
|
||||
|
||||
5. **View logs and debug output**
|
||||
- Open the Debug Console in the original VS Code window to see extension logs
|
||||
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
|
||||
|
||||
## Build for Production
|
||||
|
||||
To build the extension for distribution:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
# or
|
||||
pnpm run compile
|
||||
```
|
||||
|
||||
To package the extension as a VSIX file:
|
||||
|
||||
```bash
|
||||
npx vsce package
|
||||
# or
|
||||
pnpm vsce package
|
||||
```
|
||||
- Setting up the development environment
|
||||
- Building and debugging the extension locally
|
||||
- Submitting pull requests
|
||||
|
||||
## Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0](https://github.com/QwenLM/qwen-code/blob/main/LICENSE)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -314,34 +314,32 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
'cli.js',
|
||||
).fsPath;
|
||||
const execPath = process.execPath;
|
||||
const lowerExecPath = execPath.toLowerCase();
|
||||
const needsElectronRunAsNode =
|
||||
lowerExecPath.includes('code') ||
|
||||
lowerExecPath.includes('electron');
|
||||
|
||||
let qwenCmd: string;
|
||||
const terminalOptions: vscode.TerminalOptions = {
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
location,
|
||||
};
|
||||
|
||||
let qwenCmd: string;
|
||||
|
||||
if (isWindows) {
|
||||
// Use system Node via cmd.exe; avoid PowerShell parsing issues
|
||||
// On Windows, try multiple strategies to find a Node.js runtime:
|
||||
// 1. Check if VSCode ships a standalone node.exe alongside Code.exe
|
||||
// 2. Check VSCode's internal Node.js in resources directory
|
||||
// 3. Fall back to using Code.exe with ELECTRON_RUN_AS_NODE=1
|
||||
const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
||||
const cliQuoted = quoteCmd(cliEntry);
|
||||
// TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node
|
||||
qwenCmd = `node ${cliQuoted}`;
|
||||
terminalOptions.shellPath = process.env.ComSpec;
|
||||
} else {
|
||||
// macOS/Linux: All VSCode-like IDEs (VSCode, Cursor, Windsurf, etc.)
|
||||
// are Electron-based, so we always need ELECTRON_RUN_AS_NODE=1
|
||||
// to run Node.js scripts using the IDE's bundled runtime.
|
||||
const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`;
|
||||
const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`;
|
||||
if (needsElectronRunAsNode) {
|
||||
// macOS Electron helper needs ELECTRON_RUN_AS_NODE=1;
|
||||
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
|
||||
} else {
|
||||
qwenCmd = baseCmd;
|
||||
}
|
||||
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
|
||||
}
|
||||
|
||||
const terminal = vscode.window.createTerminal(terminalOptions);
|
||||
|
||||
Reference in New Issue
Block a user