Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
654769a282 chore(release): v0.6.0-nightly.20260104.105ad743 2026-01-04 00:15:07 +00:00
25 changed files with 16 additions and 3071 deletions

View File

@@ -13,10 +13,5 @@
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"vitest.disableWorkspaceWarning": true,
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server"],
"excluded": ["gopls"]
}
"vitest.disableWorkspaceWarning": true
}

View File

@@ -1,147 +0,0 @@
# Qwen Code CLI LSP 集成实现方案分析
## 1. 项目概述
本方案旨在将 LSPLanguage Server Protocol能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。
## 2. 技术方案对比
### 2.1 Piebald-AI/claude-code-lsps 方案
- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由
- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装
- **安全**: LSP 子进程以用户权限运行,无内置信任门控
- **功能覆盖**: 可以暴露完整的 LSP 表面hover、诊断、代码操作、重命名等
### 2.2 原生 LSP 客户端方案(推荐方案)
- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接
- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置
- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示)
- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等)
### 2.3 cclsp + MCP 方案(备选)
- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接
- **用户配置**: 需要 MCP 配置
- **安全**: 通过 MCP 安全控制
- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具
## 3. 原生 LSP 集成详细计划
### 3.1 方案选择
- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验
- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接
- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略
### 3.2 实现步骤
#### 3.2.1 创建原生 LSP 服务
`packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理:
- 工作区语言检测
- 自动发现和启动语言服务器
- 与现有文档/编辑模型同步
- LSP 能力直接暴露给代理
#### 3.2.2 配置支持
- 支持内置预设配置(常见语言服务器)
- 支持用户自定义 `.lsp.json` 配置文件
- 与 MCP 配置共存,共享信任控制
#### 3.2.3 集成启动流程
-`packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成
- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制
- 处理沙箱预检和主运行的重复调用问题
#### 3.2.4 功能标志配置
-`packages/cli/src/config/settingsSchema.ts` 中添加新的设置项
- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能
- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置
#### 3.2.5 安全控制
- 与 MCP 共享相同的安全控制机制
- 在信任工作区中自动启用,在非信任工作区中提示用户
- 实现路径允许列表和进程启动确认
#### 3.2.6 错误处理与用户通知
- 检测缺失的语言服务器并提供安装命令
- 通过现有 MCP 状态 UI 显示错误信息
- 实现重试/退避机制,检测沙箱环境并抑制自动启动
### 3.3 需要确认的不确定项
1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调
2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP
3. **功能开关设计**开关应该是全局级别的LSP 和 MCP 可独立启用/禁用
4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑
5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步
6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项
7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制
### 3.4 安全考虑
- 与 MCP 共享相同的安全控制模型
- 仅在受信任工作区中启用自动 LSP 功能
- 提供用户确认机制用于启动新的 LSP 服务器
- 防止路径劫持,使用安全的路径解析
### 3.5 高级 LSP 功能支持
- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等
- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置
- **性能优化**: 优化 LSP 服务器启动时间和内存使用
### 3.6 用户体验
- 提供安装提示而非自动安装
- 在统一的状态界面显示 LSP 和 MCP 服务器状态
- 提供独立开关让用户控制 LSP 和 MCP 功能
- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息
## 4. 实施总结
### 4.1 已完成的工作
1. **NativeLspService 类**创建了核心服务类包含语言检测、配置合并、LSP 连接管理等功能
2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理
3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测
4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并
5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证
6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点
### 4.2 关键组件
#### 4.2.1 LspConnectionFactory
- 使用 `vscode-jsonrpc``vscode-languageserver-protocol` 实现 LSP 连接
- 支持 stdio 传输方式,可以扩展支持 TCP 传输
- 提供连接创建、初始化和关闭的完整生命周期管理
#### 4.2.2 NativeLspService
- **语言检测**:扫描项目文件和配置文件来识别编程语言
- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置
- **LSP 服务器管理**:启动、停止和状态管理
- **安全控制**:与 MCP 共享的信任和确认机制
#### 4.2.3 配置架构
- **内置预设**:为常见语言提供默认 LSP 服务器配置
- **用户配置**:支持 `.lsp.json` 文件格式
- **Claude 兼容**:可导入 Claude Code 的 LSP 配置
### 4.3 依赖管理
- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信
- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递
- 使用 `vscode-languageserver-textdocument` 管理文档版本
### 4.4 安全特性
- 工作区信任检查
- 用户确认机制(对于非信任工作区)
- 命令存在性验证
- 路径安全性检查
## 5. 总结
原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。
该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"workspaces": [
"packages/*"
],
@@ -39,7 +39,6 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",
@@ -10808,13 +10807,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -17324,7 +17316,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17961,7 +17953,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21421,7 +21413,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21433,7 +21425,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"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.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0-nightly.20260104.105ad743"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -94,7 +94,6 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",

View File

@@ -1,107 +0,0 @@
# LSP 调试指南
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
## 1. 启用调试模式
CLI 支持调试模式,可以提供额外的日志信息:
```bash
# 使用 debug 标志运行
qwen --debug [你的命令]
# 或设置环境变量
DEBUG=true qwen [你的命令]
DEBUG_MODE=true qwen [你的命令]
```
## 2. LSP 配置选项
LSP 功能通过设置系统配置,包含以下选项:
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
在 settings.json 中的示例配置:
```json
{
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server", "pylsp"],
"excluded": ["gopls"]
}
}
```
## 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
{
"python": {
"command": "pylsp",
"args": [],
"transport": "stdio",
"trustRequired": true
}
}
```
## 6. LSP 问题排查
### 6.1 检查 LSP 服务器是否已安装
- 对于 TypeScript/JavaScript: `typescript-language-server`
- 对于 Python: `pylsp`
- 对于 Go: `gopls`
### 6.2 验证工作区信任
- LSP 服务器可能需要受信任的工作区才能启动
- 检查 `security.folderTrust.enabled` 设置
### 6.3 查看日志
- 查找以 `LSP 服务器` 开头的控制台消息
- 检查命令存在性和路径安全性问题
## 7. LSP 服务启动流程
LSP 服务的启动遵循以下流程:
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
3. **启动服务器**: `start()` 方法启动所有服务器句柄
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
## 8. 调试技巧
- 使用 `--debug` 标志查看详细的启动过程
- 检查工作区是否受信任(影响 LSP 服务器启动)
- 确认 LSP 服务器命令在系统 PATH 中可用
- 使用 `getStatus()` 方法监控服务器运行状态

View File

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

View File

@@ -21,23 +21,6 @@ import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
const mockDiscoverAndPrepare = vi.fn();
const mockStartLsp = vi.fn();
const mockDefinitions = vi.fn().mockResolvedValue([]);
const mockReferences = vi.fn().mockResolvedValue([]);
const mockWorkspaceSymbols = vi.fn().mockResolvedValue([]);
const nativeLspServiceMock = vi.fn().mockImplementation(() => ({
discoverAndPrepare: mockDiscoverAndPrepare,
start: mockStartLsp,
definitions: mockDefinitions,
references: mockReferences,
workspaceSymbols: mockWorkspaceSymbols,
}));
vi.mock('../services/lsp/NativeLspService.js', () => ({
NativeLspService: nativeLspServiceMock,
}));
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
.fn()
@@ -535,16 +518,6 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
mockDiscoverAndPrepare.mockReset();
mockStartLsp.mockReset();
mockWorkspaceSymbols.mockReset();
mockWorkspaceSymbols.mockResolvedValue([]);
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(() => ({
discoverAndPrepare: mockDiscoverAndPrepare,
start: mockStartLsp,
workspaceSymbols: mockWorkspaceSymbols,
}));
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@@ -614,61 +587,6 @@ describe('loadCliConfig', () => {
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
lsp: {
enabled: true,
allowed: ['typescript-language-server'],
excluded: ['pylsp'],
},
};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.isLspEnabled()).toBe(true);
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
expect(config.getLspExcluded()).toEqual(['pylsp']);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
expect(mockDiscoverAndPrepare).toHaveBeenCalledTimes(1);
expect(mockStartLsp).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(mockDiscoverAndPrepare).not.toHaveBeenCalled();
});
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);

View File

@@ -23,11 +23,9 @@ import {
InputFormat,
OutputFormat,
SessionService,
ideContextStore,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
type LspClient,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
@@ -44,7 +42,6 @@ import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -150,44 +147,6 @@ export interface CliArgs {
channel: string | undefined;
}
export interface LoadCliConfigOptions {
/**
* Whether to start the native LSP service during config load.
* Disable when doing preflight runs (e.g., sandbox preparation).
*/
startLsp?: boolean;
}
class NativeLspClient implements LspClient {
constructor(private readonly service: NativeLspService) {}
workspaceSymbols(query: string, limit?: number) {
return this.service.workspaceSymbols(query, limit);
}
definitions(
location: Parameters<NativeLspService['definitions']>[0],
serverName?: string,
limit?: number,
) {
return this.service.definitions(location, serverName, limit);
}
references(
location: Parameters<NativeLspService['references']>[0],
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
) {
return this.service.references(
location,
serverName,
includeDeclaration,
limit,
);
}
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
@@ -696,7 +655,6 @@ export async function loadCliConfig(
extensionEnablementManager: ExtensionEnablementManager,
argv: CliArgs,
cwd: string = process.cwd(),
options: LoadCliConfigOptions = {},
): Promise<Config> {
const debugMode = isDebugMode(argv);
@@ -773,12 +731,6 @@ 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;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@@ -982,7 +934,7 @@ export async function loadCliConfig(
}
}
const config = new Config({
return new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@@ -1085,39 +1037,7 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
allowed: lspAllowed,
excluded: lspExcluded,
},
});
const shouldStartLsp = options.startLsp ?? true;
if (shouldStartLsp && lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
allowedServers: lspAllowed,
excludedServers: lspExcluded,
requireTrustedWorkspace: folderTrust,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function allowedMcpServers(

View File

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

View File

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

View File

@@ -1007,47 +1007,6 @@ 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,
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',

View File

@@ -248,8 +248,6 @@ export async function main() {
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
argv,
undefined,
{ startLsp: false },
);
if (

View File

@@ -1,361 +0,0 @@
import * as cp from 'node:child_process';
import * as net from 'node:net';
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: NodeJS.Timeout;
}
class JsonRpcConnection {
private buffer = '';
private nextId = 1;
private disposed = false;
private pendingRequests = new Map<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 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 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,
): 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();
}
}, 10000);
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,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
const timeoutId = setTimeout(() => {
reject(new Error('LSP server connection timeout'));
socket.destroy();
}, 10000);
const onError = (error: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
};
socket.once('error', onError);
socket.on('connect', () => {
clearTimeout(timeoutId);
socket.off('error', onError);
const connection = new JsonRpcConnection(
(payload) => socket.write(payload),
() => socket.destroy(),
);
connection.listen(socket);
socket.once('close', () => connection.end());
socket.once('error', () => connection.end());
resolve({
connection,
socket,
});
});
});
}
/**
* 关闭 LSP 连接
*/
static async closeConnection(lspConnection: LspConnection): Promise<void> {
if (lspConnection.connection) {
try {
await lspConnection.connection.shutdown();
} catch (e) {
console.warn('LSP shutdown failed:', e);
} finally {
lspConnection.connection.end();
}
}
if (lspConnection.process && !lspConnection.process.killed) {
lspConnection.process.kill();
}
if (lspConnection.socket && !lspConnection.socket.destroyed) {
lspConnection.socket.destroy();
}
}
}

View File

@@ -1,131 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { NativeLspService } from './NativeLspService.js';
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
IdeContextStore,
} from '@qwen-code/qwen-code-core';
import { EventEmitter } from 'events';
// 模拟依赖项
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: Record<string, 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 CoreConfig,
mockWorkspace as WorkspaceContext,
eventEmitter,
mockFileDiscovery as FileDiscoveryService,
mockIdeStore as IdeContextStore,
);
});
test('should initialize correctly', () => {
expect(lspService).toBeDefined();
});
test('should detect languages from workspace files', async () => {
// 这个测试需要修改,因为我们无法直接访问私有方法
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
test('should merge built-in presets with user configs', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
});
// 注意:实际的单元测试需要适当的测试框架配置
// 这里只是一个结构示例

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.6.0-nightly.20260104.105ad743",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -61,10 +61,6 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js';
import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js';
import { LspFindReferencesTool } from '../tools/lsp-find-references.js';
import type { LspClient } from '../lsp/types.js';
// Other modules
import { ideContextStore } from '../ide/ideContext.js';
@@ -285,12 +281,6 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
lsp?: {
enabled?: boolean;
allowed?: string[];
excluded?: string[];
};
lspClient?: LspClient;
userMemory?: string;
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
@@ -423,10 +413,6 @@ export class Config {
private readonly toolCallCommand: string | undefined;
private readonly mcpServerCommand: string | undefined;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly lspEnabled: boolean;
private readonly lspAllowed?: string[];
private readonly lspExcluded?: string[];
private lspClient?: LspClient;
private sessionSubagents: SubagentConfig[];
private userMemory: string;
private sdkMode: boolean;
@@ -535,10 +521,6 @@ export class Config {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.lspEnabled = params.lsp?.enabled ?? false;
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
this.lspClient = params.lspClient;
this.sessionSubagents = params.sessionSubagents ?? [];
this.sdkMode = params.sdkMode ?? false;
this.userMemory = params.userMemory ?? '';
@@ -914,32 +896,6 @@ export class Config {
this.mcpServers = { ...this.mcpServers, ...servers };
}
isLspEnabled(): boolean {
return this.lspEnabled;
}
getLspAllowed(): string[] | undefined {
return this.lspAllowed;
}
getLspExcluded(): string[] | undefined {
return this.lspExcluded;
}
getLspClient(): LspClient | undefined {
return this.lspClient;
}
/**
* Allows wiring an LSP client after Config construction but before initialize().
*/
setLspClient(client: LspClient | undefined): void {
if (this.initialized) {
throw new Error('Cannot set LSP client after initialization');
}
this.lspClient = client;
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}
@@ -1447,11 +1403,6 @@ export class Config {
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this);
}
if (this.isLspEnabled() && this.getLspClient()) {
registerCoreTool(LspGoToDefinitionTool, this);
registerCoreTool(LspFindReferencesTool, this);
registerCoreTool(LspWorkspaceSymbolTool, this);
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());

View File

@@ -85,7 +85,6 @@ export * from './skills/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';
export * from './lsp/types.js';
// Export specific tool logic
export * from './tools/read-file.js';
@@ -100,8 +99,6 @@ export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/lsp-go-to-definition.js';
export * from './tools/lsp-find-references.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-client-manager.js';
export * from './tools/mcp-tool.js';

View File

@@ -1,54 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface LspPosition {
line: number;
character: number;
}
export interface LspRange {
start: LspPosition;
end: LspPosition;
}
export interface LspLocation {
uri: string;
range: LspRange;
}
export interface LspLocationWithServer extends LspLocation {
serverName?: string;
}
export interface LspSymbolInformation {
name: string;
kind?: string;
location: LspLocation;
containerName?: string;
serverName?: string;
}
export type LspReference = LspLocationWithServer;
export type LspDefinition = LspLocationWithServer;
export interface LspClient {
workspaceSymbols(
query: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
definitions(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
references(
location: LspLocation,
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
): Promise<LspReference[]>;
}

View File

@@ -1,309 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type {
LspClient,
LspLocation,
LspReference,
} from '../lsp/types.js';
export interface LspFindReferencesParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Whether to include the declaration in results (default: false).
*/
includeDeclaration?: boolean;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspFindReferencesInvocation extends BaseToolInvocation<
LspFindReferencesParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspFindReferencesParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP find-references查引用 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP find-references查引用';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP find-references is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 50;
let references: LspReference[] = [];
try {
references = await client.references(
target.location,
target.serverName,
this.params.includeDeclaration ?? false,
limit,
);
} catch (error) {
const message = `LSP find-references failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!references.length) {
const message = `No references found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = references.slice(0, limit).map((reference, index) => {
return `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`;
});
const heading = `References for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use find-references.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspReference | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspFindReferencesTool extends BaseDeclarativeTool<
LspFindReferencesParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_FIND_REFERENCES;
constructor(private readonly config: Config) {
super(
LspFindReferencesTool.Name,
ToolDisplayNames.LSP_FIND_REFERENCES,
'Use LSP find-references for a symbol or a specific file location查引用优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
includeDeclaration: {
type: 'boolean',
description:
'Include the declaration itself when looking up references.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspFindReferencesParams,
): ToolInvocation<LspFindReferencesParams, ToolResult> {
return new LspFindReferencesInvocation(this.config, params);
}
}

View File

@@ -1,309 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type {
LspClient,
LspDefinition,
LspLocation,
} from '../lsp/types.js';
export interface LspGoToDefinitionParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspGoToDefinitionInvocation extends BaseToolInvocation<
LspGoToDefinitionParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspGoToDefinitionParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP go-to-definition跳转定义 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP go-to-definition跳转定义';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP go-to-definition is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let definitions: LspDefinition[] = [];
try {
definitions = await client.definitions(
target.location,
target.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-definition failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
// Fallback to the resolved symbol location if the server does not return definitions.
if (!definitions.length && target.fromSymbol) {
definitions = [
{
...target.location,
serverName: target.serverName,
},
];
}
if (!definitions.length) {
const message = `No definitions found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = definitions.slice(0, limit).map((definition, index) => {
return `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`;
});
const heading = `Definitions for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspDefinition | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspGoToDefinitionTool extends BaseDeclarativeTool<
LspGoToDefinitionParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_GO_TO_DEFINITION;
constructor(private readonly config: Config) {
super(
LspGoToDefinitionTool.Name,
ToolDisplayNames.LSP_GO_TO_DEFINITION,
'Use LSP go-to-definition for a symbol or a specific file location跳转定义优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspGoToDefinitionParams,
): ToolInvocation<LspGoToDefinitionParams, ToolResult> {
return new LspGoToDefinitionInvocation(this.config, params);
}
}

View File

@@ -1,180 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspSymbolInformation } from '../lsp/types.js';
export interface LspWorkspaceSymbolParams {
/**
* Query string to search symbols (e.g., function or class name).
*/
query: string;
/**
* Maximum number of results to return.
*/
limit?: number;
}
class LspWorkspaceSymbolInvocation extends BaseToolInvocation<
LspWorkspaceSymbolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspWorkspaceSymbolParams,
) {
super(params);
}
getDescription(): string {
return `LSP workspace symbol search按名称找定义/实现/引用) for "${this.params.query}"`;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP workspace symbol search is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.workspaceSymbols(this.params.query, limit);
} catch (error) {
const message = `LSP workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const message = `No symbols found for query "${this.params.query}".`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocation(symbol, workspaceRoot);
const serverSuffix = symbol.serverName
? ` [${symbol.serverName}]`
: '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const heading = `Found ${Math.min(symbols.length, limit)} of ${
symbols.length
} symbols for query "${this.params.query}":`;
let referenceSection = '';
const topSymbol = symbols[0];
if (topSymbol) {
try {
const referenceLimit = Math.min(20, Math.max(limit, 5));
const references = await client.references(
topSymbol.location,
topSymbol.serverName,
false,
referenceLimit,
);
if (references.length > 0) {
const refLines = references.map((ref, index) => {
const location = this.formatLocation(
{ location: ref, name: '', kind: undefined },
workspaceRoot,
);
const serverSuffix = ref.serverName
? ` [${ref.serverName}]`
: '';
return `${index + 1}. ${location}${serverSuffix}`;
});
referenceSection = [
'',
`References for top match (${topSymbol.name}):`,
...refLines,
].join('\n');
}
} catch (error) {
referenceSection = `\nReferences lookup failed: ${
(error as Error)?.message || String(error)
}`;
}
}
const llmParts = referenceSection
? [heading, ...lines, referenceSection]
: [heading, ...lines];
const displayParts = referenceSection
? [...lines, referenceSection]
: [...lines];
return {
llmContent: llmParts.join('\n'),
returnDisplay: displayParts.join('\n'),
};
}
private formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) {
const { uri, range } = symbol.location;
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const line = (range.start.line ?? 0) + 1;
const character = (range.start.character ?? 0) + 1;
return `${filePath}:${line}:${character}`;
}
}
export class LspWorkspaceSymbolTool extends BaseDeclarativeTool<
LspWorkspaceSymbolParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL;
constructor(private readonly config: Config) {
super(
LspWorkspaceSymbolTool.Name,
ToolDisplayNames.LSP_WORKSPACE_SYMBOL,
'Search workspace symbols via LSP查找定义/实现/引用,按名称定位符号,优先于 grep。',
Kind.Other,
{
type: 'object',
properties: {
query: {
type: 'string',
description:
'Symbol name query, e.g., function/class/variable name to search.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
required: ['query'],
},
false,
false,
);
}
protected createInvocation(
params: LspWorkspaceSymbolParams,
): ToolInvocation<LspWorkspaceSymbolParams, ToolResult> {
return new LspWorkspaceSymbolInvocation(this.config, params);
}
}

View File

@@ -25,9 +25,6 @@ export const ToolNames = {
WEB_FETCH: 'web_fetch',
WEB_SEARCH: 'web_search',
LS: 'list_directory',
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
} as const;
/**
@@ -51,9 +48,6 @@ export const ToolDisplayNames = {
WEB_FETCH: 'WebFetch',
WEB_SEARCH: 'WebSearch',
LS: 'ListFiles',
LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol',
LSP_GO_TO_DEFINITION: 'LspGoToDefinition',
LSP_FIND_REFERENCES: 'LspFindReferences',
} as const;
// Migration from old tool names to new tool names
@@ -62,8 +56,6 @@ export const ToolDisplayNames = {
export const ToolNamesMigration = {
search_file_content: ToolNames.GREP, // Legacy name from grep tool
replace: ToolNames.EDIT, // Legacy name from edit tool
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
find_references: ToolNames.LSP_FIND_REFERENCES,
} as const;
// Migration from old tool display names to new tool display names

View File

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

View File

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