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

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

View file

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

View file

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

View file

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

View file

@ -1,39 +0,0 @@
import type { JSONSchema7 } from 'json-schema';
export const lspSettingsSchema: JSONSchema7 = {
type: 'object',
properties: {
'lsp.enabled': {
type: 'boolean',
default: false,
description:
'启用 LSP 语言服务器协议支持(实验性功能)。必须通过 --experimental-lsp 命令行参数显式开启。'
},
'lsp.allowed': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '允许运行的 LSP 服务器列表'
},
'lsp.excluded': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '禁止运行的 LSP 服务器列表'
},
'lsp.autoDetect': {
type: 'boolean',
default: true,
description: '自动检测项目语言并启动相应 LSP 服务器'
},
'lsp.serverTimeout': {
type: 'number',
default: 10000,
description: 'LSP 服务器启动超时时间(毫秒)'
}
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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