mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
wip(cli): support lsp
This commit is contained in:
parent
105ad743fa
commit
a67a8d0277
21 changed files with 3035 additions and 3 deletions
|
|
@ -21,6 +21,23 @@ 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()
|
||||
|
|
@ -518,6 +535,16 @@ 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');
|
||||
});
|
||||
|
|
@ -587,6 +614,61 @@ 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);
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ 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';
|
||||
|
|
@ -42,6 +44,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';
|
||||
|
|
@ -147,6 +150,44 @@ 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 {
|
||||
|
|
@ -655,6 +696,7 @@ export async function loadCliConfig(
|
|||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
|
|
@ -731,6 +773,12 @@ 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;
|
||||
|
|
@ -934,7 +982,7 @@ export async function loadCliConfig(
|
|||
}
|
||||
}
|
||||
|
||||
return new Config({
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
|
|
@ -1037,7 +1085,39 @@ 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(
|
||||
|
|
|
|||
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,34 @@ 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 {
|
||||
|
|
@ -632,6 +660,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,
|
||||
|
|
@ -736,6 +767,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;
|
||||
|
|
@ -749,11 +788,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;
|
||||
|
|
@ -767,9 +808,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
|
||||
|
||||
|
|
|
|||
|
|
@ -1007,6 +1007,47 @@ 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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue