diff --git a/docs/users/features/lsp.md b/docs/users/features/lsp.md index c0ed7da9a..2af14ed01 100644 --- a/docs/users/features/lsp.md +++ b/docs/users/features/lsp.md @@ -4,7 +4,7 @@ Qwen Code provides native Language Server Protocol (LSP) support, enabling advan ## Overview -LSP support in Qwen Code works by connecting to language servers that understand your code. When you work with TypeScript, Python, Go, or other supported languages, Qwen Code can automatically start the appropriate language server and use it to: +LSP support in Qwen Code works by connecting to language servers that understand your code. Once you configure servers via `.lsp.json` (or extensions), Qwen Code can start them and use them to: - Navigate to symbol definitions - Find all references to a symbol @@ -21,7 +21,7 @@ LSP is an experimental feature in Qwen Code. To enable it, use the `--experiment qwen --experimental-lsp ``` -For most common languages, Qwen Code will automatically detect and start the appropriate language server if it's installed on your system. +LSP servers are configuration-driven. You must define them in `.lsp.json` (or via extensions) for Qwen Code to start them. ### Prerequisites @@ -33,6 +33,8 @@ You need to have the language server for your programming language installed: | Python | pylsp | `pip install python-lsp-server` | | Go | gopls | `go install golang.org/x/tools/gopls@latest` | | Rust | rust-analyzer | [Installation guide](https://rust-analyzer.github.io/manual.html#installation) | +| C/C++ | clangd | Install LLVM/clangd via your package manager | +| Java | jdtls | Install JDTLS and a JDK | ## Configuration @@ -57,30 +59,71 @@ You can configure language servers using a `.lsp.json` file in your project root } ``` +### C/C++ (clangd) configuration + +Dependencies: + +- clangd (LLVM) must be installed and available in PATH. +- A compile database (`compile_commands.json`) or `compile_flags.txt` is required for accurate results. + +Example: + +```json +{ + "cpp": { + "command": "clangd", + "args": [ + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--completion-style=detailed" + ] + } +} +``` + +### Java (jdtls) configuration + +Dependencies: + +- JDK installed and available in PATH (`java`). +- JDTLS installed and available in PATH (`jdtls`). + +Example: + +```json +{ + "java": { + "command": "jdtls", + "args": ["-configuration", ".jdtls-config", "-data", ".jdtls-workspace"] + } +} +``` + ### Configuration Options #### Required Fields -| Option | Type | Description | -| --------------------- | ------ | ------------------------------------------------- | -| `command` | string | Command to start the LSP server (must be in PATH) | -| `extensionToLanguage` | object | Maps file extensions to language identifiers | +| Option | Type | Description | +| --------- | ------ | ------------------------------------------------- | +| `command` | string | Command to start the LSP server (must be in PATH) | #### Optional Fields -| Option | Type | Default | Description | -| ----------------------- | -------- | --------- | ------------------------------------------------------ | -| `args` | string[] | `[]` | Command line arguments | -| `transport` | string | `"stdio"` | Transport type: `stdio` or `socket` | -| `env` | object | - | Environment variables | -| `initializationOptions` | object | - | LSP initialization options | -| `settings` | object | - | Server settings via `workspace/didChangeConfiguration` | -| `workspaceFolder` | string | - | Override workspace folder | -| `startupTimeout` | number | `10000` | Startup timeout in milliseconds | -| `shutdownTimeout` | number | `5000` | Shutdown timeout in milliseconds | -| `restartOnCrash` | boolean | `false` | Auto-restart on crash | -| `maxRestarts` | number | `3` | Maximum restart attempts | -| `trustRequired` | boolean | `true` | Require trusted workspace | +| Option | Type | Default | Description | +| ----------------------- | -------- | --------- | ------------------------------------------------------- | +| `args` | string[] | `[]` | Command line arguments | +| `transport` | string | `"stdio"` | Transport type: `stdio`, `tcp`, or `socket` | +| `env` | object | - | Environment variables | +| `initializationOptions` | object | - | LSP initialization options | +| `settings` | object | - | Server settings via `workspace/didChangeConfiguration` | +| `extensionToLanguage` | object | - | Maps file extensions to language identifiers | +| `workspaceFolder` | string | - | Override workspace folder (must be within project root) | +| `startupTimeout` | number | `10000` | Startup timeout in milliseconds | +| `shutdownTimeout` | number | `5000` | Shutdown timeout in milliseconds | +| `restartOnCrash` | boolean | `false` | Auto-restart on crash | +| `maxRestarts` | number | `3` | Maximum restart attempts | +| `trustRequired` | boolean | `true` | Require trusted workspace | ### TCP/Socket Transport @@ -269,7 +312,7 @@ LSP servers are only started in trusted workspaces by default. This is because l ### Trust Controls -- **Trusted Workspace**: LSP servers start automatically +- **Trusted Workspace**: LSP servers start if configured - **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` is set in the server configuration To mark a workspace as trusted, use the `/trust` command or configure trusted folders in settings. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2a97f731b..8a498b912 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,7 +127,6 @@ export * from './ide/types.js'; export * from './lsp/constants.js'; export * from './lsp/LspConfigLoader.js'; export * from './lsp/LspConnectionFactory.js'; -export * from './lsp/LspLanguageDetector.js'; export * from './lsp/LspResponseNormalizer.js'; export * from './lsp/LspServerManager.js'; export * from './lsp/NativeLspClient.js'; diff --git a/packages/core/src/lsp/LspConfigLoader.test.ts b/packages/core/src/lsp/LspConfigLoader.test.ts index 9f0ee8548..46b221878 100644 --- a/packages/core/src/lsp/LspConfigLoader.test.ts +++ b/packages/core/src/lsp/LspConfigLoader.test.ts @@ -9,6 +9,104 @@ import mock from 'mock-fs'; import { LspConfigLoader } from './LspConfigLoader.js'; import type { Extension } from '../extension/extensionManager.js'; +describe('LspConfigLoader config-driven behavior', () => { + const workspaceRoot = '/workspace'; + + it('does not generate any presets when no user or extension config provided', () => { + const loader = new LspConfigLoader(workspaceRoot); + // Even if languages are detected, no built-in presets should be generated + const configs = loader.mergeConfigs(['java', 'cpp', 'typescript'], [], []); + + expect(configs).toHaveLength(0); + }); + + it('respects user-provided configs via .lsp.json', () => { + const loader = new LspConfigLoader(workspaceRoot); + const userConfigs = [ + { + name: 'jdtls', + languages: ['java'], + command: 'jdtls', + args: [], + transport: 'stdio' as const, + initializationOptions: {}, + rootUri: 'file:///workspace', + workspaceFolder: workspaceRoot, + trustRequired: true, + }, + ]; + + const configs = loader.mergeConfigs(['java'], [], userConfigs); + + expect(configs).toHaveLength(1); + expect(configs[0]?.name).toBe('jdtls'); + expect(configs[0]?.languages).toEqual(['java']); + }); + + it('respects extension-provided configs', () => { + const loader = new LspConfigLoader(workspaceRoot); + const extensionConfigs = [ + { + name: 'clangd', + languages: ['cpp', 'c'], + command: 'clangd', + args: ['--background-index'], + transport: 'stdio' as const, + initializationOptions: {}, + rootUri: 'file:///workspace', + workspaceFolder: workspaceRoot, + trustRequired: true, + }, + ]; + + const configs = loader.mergeConfigs(['cpp'], extensionConfigs, []); + + expect(configs).toHaveLength(1); + expect(configs[0]?.name).toBe('clangd'); + expect(configs[0]?.command).toBe('clangd'); + }); + + it('user configs override extension configs with same name', () => { + const loader = new LspConfigLoader(workspaceRoot); + const extensionConfigs = [ + { + name: 'jdtls', + languages: ['java'], + command: 'jdtls', + args: [], + transport: 'stdio' as const, + initializationOptions: {}, + rootUri: 'file:///workspace', + workspaceFolder: workspaceRoot, + trustRequired: true, + }, + ]; + const userConfigs = [ + { + name: 'jdtls', + languages: ['java'], + command: '/custom/path/jdtls', + args: ['--custom-flag'], + transport: 'stdio' as const, + initializationOptions: {}, + rootUri: 'file:///workspace', + workspaceFolder: workspaceRoot, + trustRequired: true, + }, + ]; + + const configs = loader.mergeConfigs( + ['java'], + extensionConfigs, + userConfigs, + ); + + expect(configs).toHaveLength(1); + expect(configs[0]?.command).toBe('/custom/path/jdtls'); + expect(configs[0]?.args).toEqual(['--custom-flag']); + }); +}); + describe('LspConfigLoader extension configs', () => { const workspaceRoot = '/workspace'; const extensionPath = '/extensions/ts-plugin'; diff --git a/packages/core/src/lsp/LspConfigLoader.ts b/packages/core/src/lsp/LspConfigLoader.ts index 61ffad8b5..0a3b384c7 100644 --- a/packages/core/src/lsp/LspConfigLoader.ts +++ b/packages/core/src/lsp/LspConfigLoader.ts @@ -106,18 +106,17 @@ export class LspConfigLoader { } /** - * Merge configs: built-in presets + extension configs + user configs + * Merge configs: extension configs + user configs + * Note: Built-in presets are disabled. LSP servers must be explicitly configured + * by the user via .lsp.json or through extensions. */ mergeConfigs( - detectedLanguages: string[], + _detectedLanguages: string[], extensionConfigs: LspServerConfig[], userConfigs: LspServerConfig[], ): LspServerConfig[] { - // Built-in preset configurations - const presets = this.getBuiltInPresets(detectedLanguages); - // Merge configs, user configs take priority - const mergedConfigs = [...presets]; + const mergedConfigs: LspServerConfig[] = []; const applyConfigs = (configs: LspServerConfig[]) => { for (const config of configs) { @@ -161,71 +160,6 @@ export class LspConfigLoader { 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; - } - /** * Parse configuration source and extract server configs. * Expects basic format keyed by language identifier. diff --git a/packages/core/src/lsp/LspLanguageDetector.ts b/packages/core/src/lsp/LspLanguageDetector.ts deleted file mode 100644 index 9c3f96e73..000000000 --- a/packages/core/src/lsp/LspLanguageDetector.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * 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 { FileDiscoveryService } from '../services/fileDiscoveryService.js'; -import type { WorkspaceContext } from '../utils/workspaceContext.js'; - -/** - * Extension to language ID mapping - */ -const DEFAULT_EXTENSION_TO_LANGUAGE: Record = { - 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 = { - '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 = {}, - ): Promise { - const extensionMap = this.getExtensionToLanguageMap(extensionOverrides); - const extensions = Object.keys(extensionMap); - const patterns = - extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*']; - - const files = new Set(); - 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(); - 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 { - const markers = new Set(); - - 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 | null { - return extensionMap[ext] || null; - } - - /** - * Get extension to language mapping with overrides applied - */ - private getExtensionToLanguageMap( - extensionOverrides: Record = {}, - ): Record { - 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; - } -} diff --git a/packages/core/src/lsp/LspResponseNormalizer.ts b/packages/core/src/lsp/LspResponseNormalizer.ts index 9a9a478c0..a890c32bc 100644 --- a/packages/core/src/lsp/LspResponseNormalizer.ts +++ b/packages/core/src/lsp/LspResponseNormalizer.ts @@ -522,12 +522,21 @@ export class LspResponseNormalizer { itemObj['range'] ?? undefined) as { start?: unknown; end?: unknown } | undefined; - if (!locationObj['uri'] || !range?.start || !range?.end) { + // Only require uri; range is optional per LSP 3.17 WorkspaceSymbol spec + // where location may be { uri } without a range. + if (!locationObj['uri']) { return null; } - const start = range.start as { line?: number; character?: number }; - const end = range.end as { line?: number; character?: number }; + // LSP 3.17 WorkspaceSymbol format may have location with only uri (no range). + // Servers like jdtls use this format, requiring a workspaceSymbol/resolve call + // for the full range. Default to file start when range is absent. + const start = (range?.start as + | { line?: number; character?: number } + | undefined) ?? { line: 0, character: 0 }; + const end = (range?.end as + | { line?: number; character?: number } + | undefined) ?? { line: 0, character: 0 }; return { name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, diff --git a/packages/core/src/lsp/LspServerManager.ts b/packages/core/src/lsp/LspServerManager.ts index d38b23851..544dcd6ef 100644 --- a/packages/core/src/lsp/LspServerManager.ts +++ b/packages/core/src/lsp/LspServerManager.ts @@ -94,20 +94,24 @@ export class LspServerManager { /** * 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. + * + * @param handle - The LSP server handle + * @param force - Force re-warmup even if already warmed up + * @returns The URI of the file opened during warmup, or undefined if no file was opened */ async warmupTypescriptServer( handle: LspServerHandle, force = false, - ): Promise { + ): Promise { if (!handle.connection || !this.isTypescriptServer(handle)) { - return; + return undefined; } if (handle.warmedUp && !force) { - return; + return undefined; } const tsFile = this.findFirstTypescriptFile(); if (!tsFile) { - return; + return undefined; } const uri = pathToFileURL(tsFile).toString(); @@ -138,9 +142,11 @@ export class LspServerManager { ); // Only mark as warmed up after successful completion handle.warmedUp = true; + return uri; } catch (error) { // Do not set warmedUp to true on failure, allowing retry debugLogger.warn('TypeScript server warm-up failed:', error); + return undefined; } } @@ -559,40 +565,22 @@ export class LspServerManager { }); } - // 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) { - debugLogger.warn('TypeScript LSP warm-up failed:', error); - } - } + // Note: TypeScript server warm-up is handled by warmupTypescriptServer() + // which is called before every LSP request. This avoids duplicate + // textDocument/didOpen notifications that aren't tracked in openedDocuments. } /** - * Check if command exists + * Check if command exists by spawning it with --version. + * Only returns false when the spawn itself fails (e.g. ENOENT). + * A timeout means the process started successfully (command exists) + * but didn't exit in time — common for servers like jdtls that + * don't support --version and start their full runtime instead. + * + * @param command - The command to check + * @param env - Optional environment variables + * @param cwd - Optional working directory + * @returns true if the command can be spawned, false if not found */ private async commandExists( command: string, @@ -616,16 +604,20 @@ export class LspServerManager { 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 + settled = true; + // 127 typically indicates command not found in shell + resolve(code !== 127); }); - // Set timeout to avoid long waits + // If the process is still running after the timeout, it means the + // command was found and started — it just didn't finish in time. + // This is expected for servers like jdtls that don't support --version. setTimeout(() => { - settled = true; - child.kill(); - resolve(false); + if (!settled) { + settled = true; + child.kill(); + resolve(true); + } }, DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS); }); } diff --git a/packages/core/src/lsp/NativeLspService.test.ts b/packages/core/src/lsp/NativeLspService.test.ts index 218f2e3c7..6daad8039 100644 --- a/packages/core/src/lsp/NativeLspService.test.ts +++ b/packages/core/src/lsp/NativeLspService.test.ts @@ -4,13 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, beforeEach, expect, test } from 'vitest'; +import { describe, beforeEach, expect, test, vi } from 'vitest'; import { NativeLspService } from './NativeLspService.js'; import { EventEmitter } from 'events'; import type { Config as CoreConfig } from '../config/config.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import type { IdeContextStore } from '../ide/ideContext.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; // 模拟依赖项 class MockConfig { @@ -110,8 +114,29 @@ describe('NativeLspService', () => { expect(lspService).toBeDefined(); }); - test('should detect languages from workspace files', async () => { - // 这个测试需要修改,因为我们无法直接访问私有方法 + test('discoverAndPrepare should not invoke language detection', async () => { + const service = new NativeLspService( + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, + eventEmitter, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, + ); + + const detectLanguages = vi.fn(async () => { + throw new Error('detectLanguages should not be called'); + }); + ( + service as unknown as { + languageDetector: { detectLanguages: () => Promise }; + } + ).languageDetector = { detectLanguages }; + + await expect(service.discoverAndPrepare()).resolves.toBeUndefined(); + expect(detectLanguages).not.toHaveBeenCalled(); + }); + + test('should prepare configs without language detection', async () => { await lspService.discoverAndPrepare(); const status = lspService.getStatus(); @@ -119,14 +144,959 @@ describe('NativeLspService', () => { expect(status).toBeDefined(); }); - test('should merge built-in presets with user configs', async () => { - await lspService.discoverAndPrepare(); + test('should open document before hover requests', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-test-')); + const filePath = path.join(tempDir, 'main.cpp'); + fs.writeFileSync(filePath, 'int main(){return 0;}\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); - const status = lspService.getStatus(); - // 检查服务是否已准备就绪 - expect(status).toBeDefined(); + const events: string[] = []; + const connection = { + listen: vi.fn(), + send: vi.fn((message: { method?: string }) => { + events.push(`send:${message.method ?? 'unknown'}`); + }), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + events.push(`request:${method}`); + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + }; + + (lspService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise1 = lspService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise1; + + expect(connection.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'textDocument/didOpen', + params: { + textDocument: expect.objectContaining({ + uri, + languageId: 'cpp', + }), + }, + }), + ); + expect(connection.request).toHaveBeenCalledWith( + 'textDocument/hover', + expect.any(Object), + ); + expect(events[0]).toBe('send:textDocument/didOpen'); + + const promise2 = lspService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise2; + + expect(connection.send).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should open a workspace file before workspace symbol search', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-symbol-')); + const workspaceFile = path.join(tempDir, 'src', 'main.cpp'); + fs.mkdirSync(path.dirname(workspaceFile), { recursive: true }); + fs.writeFileSync(workspaceFile, 'int main(){return 0;}\n', 'utf-8'); + const workspaceUri = pathToFileURL(workspaceFile).toString(); + + const events: string[] = []; + let opened = false; + const connection = { + listen: vi.fn(), + send: vi.fn((message: { method?: string }) => { + events.push(`send:${message.method ?? 'unknown'}`); + if (message.method === 'textDocument/didOpen') { + opened = true; + } + }), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + events.push(`request:${method}`); + if (method === 'workspace/symbol') { + return opened + ? [ + { + name: 'Calculator', + kind: 5, + location: { + uri: workspaceUri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }, + }, + ] + : []; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => false, + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + const tempDiscovery = new MockFileDiscoveryService(); + const tempIdeStore = new MockIdeContextStore(); + const tempEmitter = new EventEmitter(); + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + tempEmitter, + tempDiscovery as unknown as FileDiscoveryService, + tempIdeStore as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = tempService.workspaceSymbols('Calculator'); + await vi.runAllTimersAsync(); + const results = await promise; + + expect(connection.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'textDocument/didOpen', + }), + ); + expect(events[0]).toBe('send:textDocument/didOpen'); + expect(results.length).toBe(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should retry workspace symbols after warmup when initial result is empty', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-symbol-retry-')); + const workspaceFile = path.join(tempDir, 'src', 'main.cpp'); + fs.mkdirSync(path.dirname(workspaceFile), { recursive: true }); + fs.writeFileSync(workspaceFile, 'int main(){return 0;}\n', 'utf-8'); + const workspaceUri = pathToFileURL(workspaceFile).toString(); + + const events: string[] = []; + let opened = false; + let symbolCalls = 0; + const connection = { + listen: vi.fn(), + send: vi.fn((message: { method?: string }) => { + events.push(`send:${message.method ?? 'unknown'}`); + if (message.method === 'textDocument/didOpen') { + opened = true; + } + }), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + events.push(`request:${method}`); + if (method === 'workspace/symbol') { + symbolCalls += 1; + if (!opened) { + return []; + } + if (symbolCalls === 1) { + return []; + } + return [ + { + name: 'Calculator', + kind: 5, + location: { + uri: workspaceUri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }, + }, + ]; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => false, + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + const tempDiscovery = new MockFileDiscoveryService(); + const tempIdeStore = new MockIdeContextStore(); + const tempEmitter = new EventEmitter(); + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + tempEmitter, + tempDiscovery as unknown as FileDiscoveryService, + tempIdeStore as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = tempService.workspaceSymbols('Calculator'); + await vi.runAllTimersAsync(); + const results = await promise; + + expect(symbolCalls).toBe(2); + expect(results.length).toBe(1); + expect(events[0]).toBe('send:textDocument/didOpen'); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should not retry workspace symbols when no warmup file is available', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-symbol-empty-')); + + let symbolCalls = 0; + const connection = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + if (method === 'workspace/symbol') { + symbolCalls += 1; + return []; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => false, + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + const tempDiscovery = new MockFileDiscoveryService(); + const tempIdeStore = new MockIdeContextStore(); + const tempEmitter = new EventEmitter(); + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + tempEmitter, + tempDiscovery as unknown as FileDiscoveryService, + tempIdeStore as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = tempService.workspaceSymbols('Calculator'); + await vi.runAllTimersAsync(); + await promise; + + expect(symbolCalls).toBe(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should reopen documents after connection changes', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-reopen-')); + const filePath = path.join(tempDir, 'main.cpp'); + fs.writeFileSync(filePath, 'int main(){return 0;}\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + const connection1 = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async () => null), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + const connection2 = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async () => null), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection: connection1, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + const tempDiscovery = new MockFileDiscoveryService(); + const tempIdeStore = new MockIdeContextStore(); + const tempEmitter = new EventEmitter(); + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + tempEmitter, + tempDiscovery as unknown as FileDiscoveryService, + tempIdeStore as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise1 = tempService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise1; + + expect(connection1.send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'textDocument/didOpen' }), + ); + + handle.connection = connection2; + + const promise2 = tempService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise2; + + expect(connection2.send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'textDocument/didOpen' }), + ); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + test('should delay after fresh document open then send request', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-delay-')); + const filePath = path.join(tempDir, 'main.cpp'); + fs.writeFileSync(filePath, 'int main(){return 0;}\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + const timeline: Array<{ event: string; time: number }> = []; + const connection = { + listen: vi.fn(), + send: vi.fn((message: { method?: string }) => { + if (message.method === 'textDocument/didOpen') { + timeline.push({ event: 'didOpen', time: Date.now() }); + } + }), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + if (method === 'textDocument/definition') { + timeline.push({ event: 'definition', time: Date.now() }); + return [ + { + uri, + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 8 }, + }, + }, + ]; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + }; + + (lspService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = lspService.definitions({ + uri, + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 4 }, + }, + }); + await vi.runAllTimersAsync(); + const results = await promise; + + // Verify didOpen fires before the definition request + expect(timeline.length).toBe(2); + expect(timeline[0]!.event).toBe('didOpen'); + expect(timeline[1]!.event).toBe('definition'); + // The delay should have elapsed between the two events (200ms) + expect(timeline[1]!.time - timeline[0]!.time).toBeGreaterThanOrEqual(200); + expect(results.length).toBe(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should skip delay when document is already open', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-nodelay-')); + const filePath = path.join(tempDir, 'main.cpp'); + fs.writeFileSync(filePath, 'int main(){return 0;}\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + let didOpenCount = 0; + const connection = { + listen: vi.fn(), + send: vi.fn((message: { method?: string }) => { + if (message.method === 'textDocument/didOpen') { + didOpenCount += 1; + } + }), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async () => null), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'clangd', + languages: ['cpp'], + command: 'clangd', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['clangd', handle]]), + warmupTypescriptServer: vi.fn(), + }; + + (lspService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + // First hover opens the document + const promise1 = lspService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise1; + expect(didOpenCount).toBe(1); + + // Second hover should not re-open or delay + const startTime = Date.now(); + const promise2 = lspService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise2; + const elapsed = Date.now() - startTime; + + expect(didOpenCount).toBe(1); + // No delay should have been triggered (well under 200ms with fake timers) + expect(elapsed).toBeLessThan(200); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should not send duplicate didOpen for warmup-opened URI on subsequent requests', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-warmup-track-')); + const queryFilePath = path.join(tempDir, 'main.cpp'); + const warmupFilePath = path.join(tempDir, 'index.ts'); + fs.writeFileSync(queryFilePath, 'int main(){return 0;}\n', 'utf-8'); + fs.writeFileSync(warmupFilePath, 'export const x = 1;\n', 'utf-8'); + const queryUri = pathToFileURL(queryFilePath).toString(); + const warmupUri = pathToFileURL(warmupFilePath).toString(); + + const didOpenUris: string[] = []; + const connection = { + listen: vi.fn(), + send: vi.fn( + (message: { + method?: string; + params?: { textDocument?: { uri?: string } }; + }) => { + if (message.method === 'textDocument/didOpen') { + didOpenUris.push(message.params?.textDocument?.uri ?? ''); + } + }, + ), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async () => null), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'typescript', + languages: ['typescript'], + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + // First call: warmup returns warmupUri (different from queryUri) + const serverManager = { + getHandles: () => new Map([['typescript', handle]]), + warmupTypescriptServer: vi.fn(async () => warmupUri), + }; + + (lspService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + // First request: opens queryUri via ensureDocumentOpen, warmup returns warmupUri + const promise1 = lspService.hover({ + uri: queryUri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise1; + + // queryUri should have been opened via ensureDocumentOpen + expect(didOpenUris).toContain(queryUri); + const countAfterFirst = didOpenUris.length; + + // Second request: for warmupUri which was already tracked from warmup + const promise2 = lspService.hover({ + uri: warmupUri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise2; + + // warmupUri should NOT have been opened again via ensureDocumentOpen + // because it was tracked from the warmup in the first call + expect(didOpenUris.length).toBe(countAfterFirst); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should retry document operations for slow servers after fresh didOpen', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-retry-doc-')); + const filePath = path.join(tempDir, 'Main.java'); + fs.writeFileSync(filePath, 'public class Main { }\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + let requestCount = 0; + const connection = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + if (method === 'textDocument/documentSymbol') { + requestCount += 1; + // First call returns empty (server still indexing), second returns data + if (requestCount === 1) { + return []; + } + return [ + { + name: 'Main', + kind: 5, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 21 }, + }, + selectionRange: { + start: { line: 0, character: 13 }, + end: { line: 0, character: 17 }, + }, + }, + ]; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'jdtls', + languages: ['java'], + command: 'jdtls', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['jdtls', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => false, + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + new EventEmitter(), + new MockFileDiscoveryService() as unknown as FileDiscoveryService, + new MockIdeContextStore() as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = tempService.documentSymbols(uri); + await vi.runAllTimersAsync(); + const results = await promise; + + // Should have retried: 2 requests total + expect(requestCount).toBe(2); + expect(results.length).toBe(1); + expect(results[0]?.name).toBe('Main'); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should NOT retry document operations for TypeScript servers', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-no-retry-ts-')); + const filePath = path.join(tempDir, 'index.ts'); + fs.writeFileSync(filePath, 'export const x = 1;\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + let requestCount = 0; + const connection = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + if (method === 'textDocument/documentSymbol') { + requestCount += 1; + return []; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'typescript-language-server', + languages: ['typescript'], + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['typescript', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => true, + }; + + (lspService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + const promise = lspService.documentSymbols(uri); + await vi.runAllTimersAsync(); + await promise; + + // Should NOT have retried: only 1 request + expect(requestCount).toBe(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should NOT retry when document was already open', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'lsp-no-retry-open-'), + ); + const filePath = path.join(tempDir, 'Main.java'); + fs.writeFileSync(filePath, 'public class Main { }\n', 'utf-8'); + const uri = pathToFileURL(filePath).toString(); + + let requestCount = 0; + const connection = { + listen: vi.fn(), + send: vi.fn(), + onNotification: vi.fn(), + onRequest: vi.fn(), + request: vi.fn(async (method: string) => { + if ( + method === 'textDocument/hover' || + method === 'textDocument/documentSymbol' + ) { + requestCount += 1; + return null; + } + return null; + }), + initialize: vi.fn(async () => ({})), + shutdown: vi.fn(async () => {}), + end: vi.fn(), + }; + + const handle = { + config: { + name: 'jdtls', + languages: ['java'], + command: 'jdtls', + args: [], + transport: 'stdio', + }, + status: 'READY', + connection, + }; + + const serverManager = { + getHandles: () => new Map([['jdtls', handle]]), + warmupTypescriptServer: vi.fn(), + isTypescriptServer: () => false, + }; + + const tempConfig = new MockConfig(); + tempConfig.rootPath = tempDir; + const tempWorkspace = new MockWorkspaceContext(); + tempWorkspace.rootPath = tempDir; + + const tempService = new NativeLspService( + tempConfig as unknown as CoreConfig, + tempWorkspace as unknown as WorkspaceContext, + new EventEmitter(), + new MockFileDiscoveryService() as unknown as FileDiscoveryService, + new MockIdeContextStore() as unknown as IdeContextStore, + { workspaceRoot: tempDir }, + ); + + (tempService as unknown as { serverManager: unknown }).serverManager = + serverManager; + + vi.useFakeTimers(); + try { + // First call opens the document (retry is allowed on this call) + const promise1 = tempService.hover({ + uri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }); + await vi.runAllTimersAsync(); + await promise1; + + requestCount = 0; + + // Second call - document already open, should NOT retry even though empty + const promise2 = tempService.documentSymbols(uri); + await vi.runAllTimersAsync(); + await promise2; + + expect(requestCount).toBe(1); + } finally { + vi.useRealTimers(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); }); - -// 注意:实际的单元测试需要适当的测试框架配置 -// 这里只是一个结构示例 diff --git a/packages/core/src/lsp/NativeLspService.ts b/packages/core/src/lsp/NativeLspService.ts index df969cf2a..1ef901e77 100644 --- a/packages/core/src/lsp/NativeLspService.ts +++ b/packages/core/src/lsp/NativeLspService.ts @@ -27,8 +27,12 @@ import type { LspWorkspaceEdit, } from './types.js'; import type { EventEmitter } from 'events'; +import { + DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS, + DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS, + DEFAULT_LSP_WORKSPACE_SYMBOL_WARMUP_DELAY_MS, +} from './constants.js'; import { LspConfigLoader } from './LspConfigLoader.js'; -import { LspLanguageDetector } from './LspLanguageDetector.js'; import { LspResponseNormalizer } from './LspResponseNormalizer.js'; import { LspServerManager } from './LspServerManager.js'; import type { @@ -38,12 +42,36 @@ import type { NativeLspServiceOptions, } from './types.js'; import * as path from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import * as fs from 'node:fs'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { globSync } from 'glob'; const debugLogger = createDebugLogger('LSP'); +/** + * Mapping from LSP language identifiers to file extensions, only for cases + * where the language ID does NOT match the file extension directly. + * Languages whose ID is already a valid extension (e.g. "cpp", "java", "go") + * are handled by the fallback in getWorkspaceSymbolExtensions(). + */ +const LANGUAGE_ID_TO_EXTENSIONS: Record = { + typescript: ['ts', 'tsx'], + typescriptreact: ['tsx'], + javascript: ['js', 'jsx'], + javascriptreact: ['jsx'], + python: ['py'], + csharp: ['cs'], + ruby: ['rb'], +}; + +const DEFAULT_EXCLUDE_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', +]; + export class NativeLspService { private config: CoreConfig; private workspaceContext: WorkspaceContext; @@ -52,8 +80,9 @@ export class NativeLspService { private workspaceRoot: string; private configLoader: LspConfigLoader; private serverManager: LspServerManager; - private languageDetector: LspLanguageDetector; private normalizer: LspResponseNormalizer; + private openedDocuments = new Map>(); + private lastConnections = new Map(); constructor( config: CoreConfig, @@ -71,10 +100,6 @@ export class NativeLspService { options.workspaceRoot ?? (config as { getProjectRoot: () => string }).getProjectRoot(); this.configLoader = new LspConfigLoader(this.workspaceRoot); - this.languageDetector = new LspLanguageDetector( - this.workspaceContext, - this.fileDiscoveryService, - ); this.normalizer = new LspResponseNormalizer(); this.serverManager = new LspServerManager( this.config, @@ -102,22 +127,14 @@ export class NativeLspService { return; } - // Detect languages in workspace + // Load LSP configs const userConfigs = await this.configLoader.loadUserConfigs(); const extensionConfigs = await this.configLoader.loadExtensionConfigs( this.getActiveExtensions(), ); - const extensionOverrides = - this.configLoader.collectExtensionToLanguageOverrides([ - ...extensionConfigs, - ...userConfigs, - ]); - const detectedLanguages = - await this.languageDetector.detectLanguages(extensionOverrides); - - // Merge configs: built-in presets + extension LSP configs + user .lsp.json + // Merge configs: extension LSP configs + user .lsp.json const serverConfigs = this.configLoader.mergeConfigs( - detectedLanguages, + [], extensionConfigs, userConfigs, ); @@ -177,6 +194,264 @@ export class NativeLspService { ); } + /** + * Ensure a document is open on the given LSP server. Sends textDocument/didOpen + * if not already tracked, then waits for the server to process the file before + * returning. This delay prevents empty results when the server hasn't analyzed + * the file yet. + * + * @param serverName - The name of the LSP server + * @param handle - The server handle with an active connection + * @param uri - The document URI to open + * @returns true if a new didOpen was sent; false if already open or failed + */ + private async ensureDocumentOpen( + serverName: string, + handle: LspServerHandle & { connection: LspConnectionInterface }, + uri: string, + ): Promise { + const lastConnection = this.lastConnections.get(serverName); + if (lastConnection && lastConnection !== handle.connection) { + this.openedDocuments.delete(serverName); + } + this.lastConnections.set(serverName, handle.connection); + + if (!uri.startsWith('file://')) { + return false; + } + const openedForServer = this.openedDocuments.get(serverName); + if (openedForServer?.has(uri)) { + return false; + } + + let filePath: string; + try { + filePath = fileURLToPath(uri); + } catch (error) { + debugLogger.warn(`Failed to resolve file path for ${uri}:`, error); + return false; + } + + let text: string; + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch (error) { + debugLogger.warn( + `Failed to read file for LSP didOpen: ${filePath}`, + error, + ); + return false; + } + + const languageId = this.resolveLanguageId(filePath, handle) ?? 'plaintext'; + + handle.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + + const nextOpened = openedForServer ?? new Set(); + nextOpened.add(uri); + this.openedDocuments.set(serverName, nextOpened); + + // Wait for the LSP server to process the newly opened document. + // Without this delay, requests sent immediately after didOpen may return + // empty results because the server hasn't finished analyzing the file. + await this.delay(DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS); + + return true; + } + + /** + * Register a URI that was opened externally (e.g. by warmupTypescriptServer) + * so that ensureDocumentOpen does not send a duplicate textDocument/didOpen. + * + * @param serverName - The name of the LSP server + * @param uri - The document URI to track as already opened + */ + private trackExternallyOpenedDocument(serverName: string, uri: string): void { + const openedForServer = + this.openedDocuments.get(serverName) ?? new Set(); + openedForServer.add(uri); + this.openedDocuments.set(serverName, openedForServer); + } + + private resolveLanguageId( + filePath: string, + handle: LspServerHandle, + ): string | undefined { + const ext = path.extname(filePath).slice(1).toLowerCase(); + if (ext && handle.config.extensionToLanguage) { + const mapping = handle.config.extensionToLanguage; + return mapping[ext] ?? mapping['.' + ext]; + } + if (handle.config.languages && handle.config.languages.length > 0) { + return handle.config.languages[0]; + } + return ext || undefined; + } + + private async warmupWorkspaceSymbols( + serverName: string, + handle: LspServerHandle, + ): Promise { + if (!handle.connection) { + return false; + } + const openedForServer = this.openedDocuments.get(serverName); + if (openedForServer && openedForServer.size > 0) { + return true; + } + + const filePath = this.findWorkspaceFileForServer(handle); + if (!filePath) { + return false; + } + + const uri = pathToFileURL(filePath).toString(); + const didOpen = await this.ensureDocumentOpen( + serverName, + handle as LspServerHandle & { connection: LspConnectionInterface }, + uri, + ); + if (!didOpen) { + return false; + } + await this.delay(DEFAULT_LSP_WORKSPACE_SYMBOL_WARMUP_DELAY_MS); + return true; + } + + /** + * Find the first source file in the workspace that matches the server's + * language extensions. Used to open a file for workspace symbol warmup. + * + * @param handle - The LSP server handle to determine target extensions + * @returns Absolute path of the first matching file, or undefined + */ + private findWorkspaceFileForServer( + handle: LspServerHandle, + ): string | undefined { + const extensions = this.getWorkspaceSymbolExtensions(handle); + if (extensions.length === 0) { + return undefined; + } + // Brace expansion requires at least 2 items; use plain glob for a single ext + const extGlob = + extensions.length === 1 ? extensions[0]! : `{${extensions.join(',')}}`; + const pattern = `**/*.${extGlob}`; + const roots = this.workspaceContext.getDirectories(); + + for (const root of roots) { + try { + // Use maxDepth to avoid scanning deeply nested directories; + // we only need one file to trigger server indexing. + const matches = globSync(pattern, { + cwd: root, + ignore: DEFAULT_EXCLUDE_PATTERNS, + absolute: true, + nodir: true, + maxDepth: 5, + }); + for (const match of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(match)) { + continue; + } + return match; + } + } catch (_error) { + // ignore glob errors + } + } + + return undefined; + } + + /** + * Determine file extensions this server can handle, used to find a workspace + * file to open for warmup. Resolution order: + * 1. Keys from config.extensionToLanguage (explicit user/extension mapping) + * 2. Derived from config.languages via LANGUAGE_ID_TO_EXTENSIONS, falling + * back to treating the language ID itself as a file extension + */ + private getWorkspaceSymbolExtensions(handle: LspServerHandle): string[] { + const extensions = new Set(); + + // Prefer explicit extension-to-language mapping from server config + const extMapping = handle.config.extensionToLanguage; + if (extMapping) { + for (const key of Object.keys(extMapping)) { + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (normalized) { + extensions.add(normalized.toLowerCase()); + } + } + } + + // Fall back to deriving extensions from language identifiers + if (extensions.size === 0) { + for (const language of handle.config.languages) { + const mapped = LANGUAGE_ID_TO_EXTENSIONS[language]; + if (mapped) { + for (const ext of mapped) { + extensions.add(ext); + } + } else { + // For languages like "cpp", "java", "go", "rust" etc., + // the language ID itself is a valid file extension + extensions.add(language.toLowerCase()); + } + } + } + + return Array.from(extensions); + } + + /** + * Run TypeScript server warmup and track the opened URI to prevent + * duplicate didOpen notifications. + * + * @param serverName - The name of the LSP server + * @param handle - The server handle + * @param force - Force re-warmup even if already warmed up + */ + private async warmupAndTrack( + serverName: string, + handle: LspServerHandle, + force = false, + ): Promise { + const warmupUri = await this.serverManager.warmupTypescriptServer( + handle, + force, + ); + if (warmupUri) { + this.trackExternallyOpenedDocument(serverName, warmupUri); + } + } + + /** + * Whether we should retry a document-level operation that returned empty + * results. We retry when a textDocument/didOpen was just sent (the server + * may still be indexing) AND the server is not a fast TypeScript server. + */ + private shouldRetryAfterOpen( + justOpened: boolean, + handle: LspServerHandle, + ): boolean { + return justOpened && !this.serverManager.isTypescriptServer(handle); + } + + private async delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + /** * Workspace symbol search across all ready LSP servers. */ @@ -193,15 +468,29 @@ export class NativeLspService { continue; } try { - await this.serverManager.warmupTypescriptServer(handle); + await this.warmupAndTrack(serverName, handle); + const warmedUp = this.serverManager.isTypescriptServer(handle) + ? false + : await this.warmupWorkspaceSymbols(serverName, handle); let response = await handle.connection.request('workspace/symbol', { query, }); + if ( + !this.serverManager.isTypescriptServer(handle) && + Array.isArray(response) && + response.length === 0 && + warmedUp + ) { + await this.delay(DEFAULT_LSP_WORKSPACE_SYMBOL_WARMUP_DELAY_MS); + response = await handle.connection.request('workspace/symbol', { + query, + }); + } if ( this.serverManager.isTypescriptServer(handle) && this.isNoProjectErrorResponse(response) ) { - await this.serverManager.warmupTypescriptServer(handle, true); + await this.warmupAndTrack(serverName, handle, true); response = await handle.connection.request('workspace/symbol', { query, }); @@ -241,17 +530,36 @@ export class NativeLspService { limit = 50, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { + textDocument: { uri: location.uri }, + position: location.range.start, + }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request( - 'textDocument/definition', - { - textDocument: { uri: location.uri }, - position: location.range.start, - }, + const justOpened = await this.ensureDocumentOpen( + name, + handle, + location.uri, ); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( + 'textDocument/definition', + requestParams, + ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/definition', + requestParams, + ); + } + const candidates = Array.isArray(response) ? response : response @@ -291,18 +599,37 @@ export class NativeLspService { limit = 200, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { + textDocument: { uri: location.uri }, + position: location.range.start, + context: { includeDeclaration }, + }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request( - 'textDocument/references', - { - textDocument: { uri: location.uri }, - position: location.range.start, - context: { includeDeclaration }, - }, + const justOpened = await this.ensureDocumentOpen( + name, + handle, + location.uri, ); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( + 'textDocument/references', + requestParams, + ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/references', + requestParams, + ); + } + if (!Array.isArray(response)) { continue; } @@ -338,14 +665,36 @@ export class NativeLspService { serverName?: string, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { + textDocument: { uri: location.uri }, + position: location.range.start, + }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request('textDocument/hover', { - textDocument: { uri: location.uri }, - position: location.range.start, - }); + const justOpened = await this.ensureDocumentOpen( + name, + handle, + location.uri, + ); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( + 'textDocument/hover', + requestParams, + ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/hover', + requestParams, + ); + } + const normalized = this.normalizer.normalizeHoverResult(response, name); if (normalized) { return normalized; @@ -367,16 +716,29 @@ export class NativeLspService { limit = 200, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { textDocument: { uri } }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request( + const justOpened = await this.ensureDocumentOpen(name, handle, uri); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( 'textDocument/documentSymbol', - { - textDocument: { uri }, - }, + requestParams, ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/documentSymbol', + requestParams, + ); + } + if (!Array.isArray(response)) { continue; } @@ -430,17 +792,36 @@ export class NativeLspService { limit = 50, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { + textDocument: { uri: location.uri }, + position: location.range.start, + }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request( - 'textDocument/implementation', - { - textDocument: { uri: location.uri }, - position: location.range.start, - }, + const justOpened = await this.ensureDocumentOpen( + name, + handle, + location.uri, ); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( + 'textDocument/implementation', + requestParams, + ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/implementation', + requestParams, + ); + } + const candidates = Array.isArray(response) ? response : response @@ -482,17 +863,36 @@ export class NativeLspService { limit = 50, ): Promise { const handles = this.getReadyHandles(serverName); + const requestParams = { + textDocument: { uri: location.uri }, + position: location.range.start, + }; for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); - const response = await handle.connection.request( - 'textDocument/prepareCallHierarchy', - { - textDocument: { uri: location.uri }, - position: location.range.start, - }, + const justOpened = await this.ensureDocumentOpen( + name, + handle, + location.uri, ); + await this.warmupAndTrack(name, handle); + + let response = await handle.connection.request( + 'textDocument/prepareCallHierarchy', + requestParams, + ); + + if ( + this.isEmptyResponse(response) && + this.shouldRetryAfterOpen(justOpened, handle) + ) { + await this.delay(DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS); + response = await handle.connection.request( + 'textDocument/prepareCallHierarchy', + requestParams, + ); + } + const candidates = Array.isArray(response) ? response : response @@ -538,7 +938,7 @@ export class NativeLspService { for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); + await this.warmupAndTrack(name, handle); const response = await handle.connection.request( 'callHierarchy/incomingCalls', { @@ -585,7 +985,7 @@ export class NativeLspService { for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); + await this.warmupAndTrack(name, handle); const response = await handle.connection.request( 'callHierarchy/outgoingCalls', { @@ -631,7 +1031,8 @@ export class NativeLspService { for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); + await this.ensureDocumentOpen(name, handle, uri); + await this.warmupAndTrack(name, handle); // Request pull diagnostics if the server supports it const response = await handle.connection.request( @@ -681,7 +1082,7 @@ export class NativeLspService { for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); + await this.warmupAndTrack(name, handle); // Request workspace diagnostics if supported const response = await handle.connection.request( @@ -735,7 +1136,8 @@ export class NativeLspService { for (const [name, handle] of handles) { try { - await this.serverManager.warmupTypescriptServer(handle); + await this.ensureDocumentOpen(name, handle, uri); + await this.warmupAndTrack(name, handle); // Convert context diagnostics to LSP format const lspDiagnostics = context.diagnostics.map((d: LspDiagnostic) => @@ -879,6 +1281,20 @@ export class NativeLspService { fs.writeFileSync(filePath, lines.join('\n'), 'utf-8'); } + /** + * Check if an LSP response represents an empty/null result, used to decide + * whether a retry is worthwhile after a freshly opened document. + */ + private isEmptyResponse(response: unknown): boolean { + if (response === null || response === undefined) { + return true; + } + if (Array.isArray(response) && response.length === 0) { + return true; + } + return false; + } + private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; diff --git a/packages/core/src/lsp/__e2e__/lsp-e2e-test.ts b/packages/core/src/lsp/__e2e__/lsp-e2e-test.ts new file mode 100644 index 000000000..5a4149c9e --- /dev/null +++ b/packages/core/src/lsp/__e2e__/lsp-e2e-test.ts @@ -0,0 +1,687 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ +/** + * LSP End-to-End Test Script + * + * Directly instantiates NativeLspService against real LSP servers + * (typescript-language-server, clangd, jdtls) to verify all 12 LSP methods + * return correct results after the ensureDocumentOpen delay fix. + * + * Key design decisions: + * - Uses per-method cursor positions (different LSP methods need different + * positions, e.g. implementations requires an interface, call hierarchy + * requires a function with both callers and callees). + * - Warms up the server by calling documentSymbols first (opens the file), + * then waits for the server to index before testing timing-sensitive + * methods like hover and definitions. + * + * Usage: npx tsx packages/core/src/lsp/__e2e__/lsp-e2e-test.ts + */ + +import { NativeLspService } from '../NativeLspService.js'; +import { EventEmitter } from 'events'; +import { pathToFileURL } from 'url'; +import * as path from 'path'; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ +const green = (s: string) => `\x1b[32m${s}\x1b[0m`; +const red = (s: string) => `\x1b[31m${s}\x1b[0m`; +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; +const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; + +interface TestResult { + method: string; + language: string; + passed: boolean; + detail: string; +} + +const results: TestResult[] = []; + +function record( + method: string, + language: string, + passed: boolean, + detail: string, +): void { + results.push({ method, language, passed, detail }); + const icon = passed ? green('PASS') : red('FAIL'); + console.log(` [${icon}] ${language}/${method}: ${detail}`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Build an LSP location object from file path + 0-indexed line/char. */ +function loc(filePath: string, line: number, char: number) { + return { + uri: pathToFileURL(filePath).toString(), + range: { + start: { line, character: char }, + end: { line, character: char }, + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* Per-method cursor position config */ +/* ------------------------------------------------------------------ */ +interface MethodPositions { + /** File + position for hover (on a type name or variable) */ + hover: { file: string; line: number; char: number }; + /** File + position for go-to-definition (on a function/method call) */ + definitions: { file: string; line: number; char: number }; + /** File + position for find-references (on a function/method name) */ + references: { file: string; line: number; char: number }; + /** File for documentSymbols (any file with multiple symbols) */ + documentSymbolsFile: string; + /** Query string for workspaceSymbols */ + symbolQuery: string; + /** File + position for implementations (on an interface/base class) */ + implementations: { file: string; line: number; char: number }; + /** File + position for call hierarchy (on a function that has callers AND callees) */ + callHierarchy: { file: string; line: number; char: number }; + /** File for diagnostics / codeActions */ + diagnosticsFile: string; +} + +interface LanguageTestConfig { + langName: string; + workspaceRoot: string; + positions: MethodPositions; + /** Extra wait time (ms) after opening a file for server to index. */ + indexWaitMs: number; + /** + * Methods where empty results are acceptable due to known server + * limitations (e.g. clangd doesn't implement callHierarchy/outgoingCalls). + * These methods will pass with a "Server limitation" note instead of failing. + */ + serverLimitedMethods?: Set; +} + +/* ------------------------------------------------------------------ */ +/* Service factory (lightweight mocks for config/workspace) */ +/* ------------------------------------------------------------------ */ +function createService(workspaceRoot: string): NativeLspService { + const config = { + isTrustedFolder: () => true, + getProjectRoot: () => workspaceRoot, + get: () => undefined, + getActiveExtensions: () => [], + }; + const workspaceContext = { + getDirectories: () => [workspaceRoot], + isPathWithinWorkspace: () => true, + fileExists: async () => false, + readFile: async () => '{}', + resolvePath: (p: string) => path.resolve(workspaceRoot, p), + }; + const fileDiscovery = { + discoverFiles: async () => [], + shouldIgnoreFile: () => false, + }; + + return new NativeLspService( + config as any, + workspaceContext as any, + new EventEmitter(), + fileDiscovery as any, + {} as any, + { workspaceRoot, requireTrustedWorkspace: false }, + ); +} + +/* ------------------------------------------------------------------ */ +/* Per-language test runner */ +/* ------------------------------------------------------------------ */ +async function testLanguage(cfg: LanguageTestConfig): Promise { + const { + langName, + workspaceRoot, + positions, + indexWaitMs, + serverLimitedMethods, + } = cfg; + const isServerLimited = (method: string) => + serverLimitedMethods?.has(method) ?? false; + + console.log(bold(`\n=============== ${langName} ===============`)); + console.log(` workspace : ${workspaceRoot}`); + + const service = createService(workspaceRoot); + + try { + /* ---------- startup ---------- */ + console.log(` Discovering and starting LSP server...`); + await service.discoverAndPrepare(); + await service.start(); + + const status = service.getStatus(); + const serverStatuses = Array.from(status.entries()); + if (serverStatuses.length === 0) { + record('startup', langName, false, 'No servers discovered'); + return; + } + let anyReady = false; + for (const [name, s] of serverStatuses) { + console.log(` Server "${name}": ${s}`); + if (s === 'READY') anyReady = true; + } + if (!anyReady) { + record('startup', langName, false, 'No server reached READY'); + return; + } + record('startup', langName, true, 'Server ready'); + + /* ---------- warmup: open main files via documentSymbols ---------- */ + // This triggers ensureDocumentOpen for each file, so the server starts + // indexing. We then wait for full indexing before timing-sensitive tests. + const filesToWarmUp = new Set(); + filesToWarmUp.add(positions.hover.file); + filesToWarmUp.add(positions.definitions.file); + filesToWarmUp.add(positions.references.file); + filesToWarmUp.add(positions.documentSymbolsFile); + filesToWarmUp.add(positions.implementations.file); + filesToWarmUp.add(positions.callHierarchy.file); + filesToWarmUp.add(positions.diagnosticsFile); + + console.log(` Warming up ${filesToWarmUp.size} file(s)...`); + for (const file of filesToWarmUp) { + const fileUri = pathToFileURL(file).toString(); + try { + await service.documentSymbols(fileUri); + } catch { + // Ignore errors during warmup; files will be retried in actual tests + } + } + + console.log(` Waiting ${indexWaitMs}ms for server to index...`); + await sleep(indexWaitMs); + + /* ---------- 1. hover ---------- */ + try { + const hoverLoc = loc( + positions.hover.file, + positions.hover.line, + positions.hover.char, + ); + const hover = await service.hover(hoverLoc); + if (hover?.contents) { + record( + 'hover', + langName, + true, + `"${hover.contents.substring(0, 100)}"`, + ); + } else { + record('hover', langName, false, 'Empty/null result'); + } + } catch (e: any) { + record('hover', langName, false, `Error: ${e.message}`); + } + + /* ---------- 2. definitions ---------- */ + try { + const defLoc = loc( + positions.definitions.file, + positions.definitions.line, + positions.definitions.char, + ); + const defs = await service.definitions(defLoc); + record( + 'definitions', + langName, + defs.length > 0, + defs.length > 0 ? `${defs.length} def(s)` : 'Empty result', + ); + } catch (e: any) { + record('definitions', langName, false, `Error: ${e.message}`); + } + + /* ---------- 3. references ---------- */ + try { + const refLoc = loc( + positions.references.file, + positions.references.line, + positions.references.char, + ); + const refs = await service.references(refLoc, undefined, true); + record( + 'references', + langName, + refs.length > 0, + refs.length > 0 ? `${refs.length} ref(s)` : 'Empty result', + ); + } catch (e: any) { + record('references', langName, false, `Error: ${e.message}`); + } + + /* ---------- 4. documentSymbols ---------- */ + try { + const docSymUri = pathToFileURL(positions.documentSymbolsFile).toString(); + const symbols = await service.documentSymbols(docSymUri); + if (symbols.length > 0) { + const names = symbols + .slice(0, 5) + .map((s) => s.name) + .join(', '); + record( + 'documentSymbols', + langName, + true, + `${symbols.length} symbol(s): ${names}`, + ); + } else { + record('documentSymbols', langName, false, 'Empty result'); + } + } catch (e: any) { + record('documentSymbols', langName, false, `Error: ${e.message}`); + } + + /* ---------- 5. workspaceSymbols ---------- */ + try { + const wsSymbols = await service.workspaceSymbols(positions.symbolQuery); + if (wsSymbols.length > 0) { + const names = wsSymbols + .slice(0, 5) + .map((s) => s.name) + .join(', '); + record( + 'workspaceSymbols', + langName, + true, + `${wsSymbols.length} symbol(s): ${names}`, + ); + } else { + record('workspaceSymbols', langName, false, 'Empty result'); + } + } catch (e: any) { + record('workspaceSymbols', langName, false, `Error: ${e.message}`); + } + + /* ---------- 6. implementations ---------- */ + try { + const implLoc = loc( + positions.implementations.file, + positions.implementations.line, + positions.implementations.char, + ); + const impls = await service.implementations(implLoc); + record( + 'implementations', + langName, + impls.length > 0, + impls.length > 0 ? `${impls.length} impl(s)` : 'Empty result', + ); + } catch (e: any) { + record('implementations', langName, false, `Error: ${e.message}`); + } + + /* ---------- 7/8/9. call hierarchy ---------- */ + try { + const callLoc = loc( + positions.callHierarchy.file, + positions.callHierarchy.line, + positions.callHierarchy.char, + ); + const callItems = await service.prepareCallHierarchy(callLoc); + if (callItems.length > 0) { + record( + 'prepareCallHierarchy', + langName, + true, + `${callItems.length} item(s): ${callItems[0]!.name}`, + ); + + try { + const incoming = await service.incomingCalls(callItems[0]!); + record( + 'incomingCalls', + langName, + incoming.length > 0, + incoming.length > 0 + ? `${incoming.length} caller(s)` + : 'Empty (no callers found)', + ); + } catch (e: any) { + record('incomingCalls', langName, false, `Error: ${e.message}`); + } + + try { + const outgoing = await service.outgoingCalls(callItems[0]!); + if (outgoing.length > 0) { + record( + 'outgoingCalls', + langName, + true, + `${outgoing.length} callee(s)`, + ); + } else if (isServerLimited('outgoingCalls')) { + record( + 'outgoingCalls', + langName, + true, + 'Empty (server does not implement this method)', + ); + } else { + record( + 'outgoingCalls', + langName, + false, + 'Empty (no callees found)', + ); + } + } catch (e: any) { + record('outgoingCalls', langName, false, `Error: ${e.message}`); + } + } else { + record('prepareCallHierarchy', langName, false, 'Empty result'); + record('incomingCalls', langName, false, 'Skipped'); + record('outgoingCalls', langName, false, 'Skipped'); + } + } catch (e: any) { + record('prepareCallHierarchy', langName, false, `Error: ${e.message}`); + record('incomingCalls', langName, false, 'Skipped'); + record('outgoingCalls', langName, false, 'Skipped'); + } + + /* ---------- 10. diagnostics ---------- */ + try { + const diagUri = pathToFileURL(positions.diagnosticsFile).toString(); + const diags = await service.diagnostics(diagUri); + // 0 diagnostics is fine for clean code + record('diagnostics', langName, true, `${diags.length} diagnostic(s)`); + } catch (e: any) { + record('diagnostics', langName, false, `Error: ${e.message}`); + } + + /* ---------- 11. codeActions ---------- */ + try { + const caUri = pathToFileURL(positions.diagnosticsFile).toString(); + const actions = await service.codeActions( + caUri, + { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, + { diagnostics: [], triggerKind: 'invoked' as const }, + ); + // 0 actions is fine when there are no diagnostics + record('codeActions', langName, true, `${actions.length} action(s)`); + } catch (e: any) { + record('codeActions', langName, false, `Error: ${e.message}`); + } + + /* ---------- 12. workspaceDiagnostics ---------- */ + try { + const wsDiags = await service.workspaceDiagnostics(); + record( + 'workspaceDiagnostics', + langName, + true, + `${wsDiags.length} file(s) with diagnostics`, + ); + } catch (e: any) { + record('workspaceDiagnostics', langName, false, `Error: ${e.message}`); + } + + await service.stop(); + } catch (e: any) { + console.log(red(` Fatal error: ${e.message}`)); + console.log(e.stack); + try { + await service.stop(); + } catch { + // Best-effort cleanup; ignore errors during shutdown + } + } +} + +/* ------------------------------------------------------------------ */ +/* Language configs */ +/* ------------------------------------------------------------------ */ + +const TS_ROOT = '/tmp/lsp-e2e-test/ts-project'; +const CPP_ROOT = '/tmp/lsp-e2e-test/cpp-project'; +const JAVA_ROOT = '/tmp/lsp-e2e-test/java-project'; + +/** + * TypeScript positions (all in index.ts / math.ts): + * + * index.ts: + * L0: import { createCalculator, Calculator } from './math.js'; + * L1: (empty) + * L2: const calc: Calculator = createCalculator(); + * L3: console.log(calc.add(1, 2)); + * L4: console.log(calc.subtract(5, 3)); + * + * math.ts: + * L0: export interface Calculator { + * L5: export class SimpleCalculator implements Calculator { + * L15: export function createCalculator(): Calculator { + */ +const tsConfig: LanguageTestConfig = { + langName: 'TypeScript', + workspaceRoot: TS_ROOT, + indexWaitMs: 3000, + positions: { + // hover on `createCalculator` call: L2 char 27 + hover: { file: `${TS_ROOT}/src/index.ts`, line: 2, char: 27 }, + // definitions on `createCalculator` call → math.ts definition + definitions: { file: `${TS_ROOT}/src/index.ts`, line: 2, char: 27 }, + // references on `Calculator` → found in both files + references: { file: `${TS_ROOT}/src/index.ts`, line: 2, char: 12 }, + // documentSymbols on math.ts (has interface, class, function) + documentSymbolsFile: `${TS_ROOT}/src/math.ts`, + symbolQuery: 'Calculator', + // implementations on `Calculator` interface → SimpleCalculator + implementations: { file: `${TS_ROOT}/src/math.ts`, line: 0, char: 17 }, + // call hierarchy on `createCalculator` (called by index.ts, calls SimpleCalculator) + callHierarchy: { file: `${TS_ROOT}/src/math.ts`, line: 15, char: 16 }, + diagnosticsFile: `${TS_ROOT}/src/index.ts`, + }, +}; + +/** + * C++ positions (main.cpp / calculator.h / calculator.cpp): + * + * main.cpp: + * L0: #include "calculator.h" + * L1: #include + * L2: (empty) + * L3: int addValues(Calculator& calc, int a, int b) { + * L4: return calc.add(a, b); + * L5: } + * ... + * L11: int computeSum(Calculator& calc) { + * L12: return addValues(calc, 1, 2) + subtractValues(calc, 5, 3); + * L13: } + * ... + * L15: int main() { + * L16: Calculator calc; + * L17: int result = computeSum(calc); + * L18: std::cout << result << std::endl; + * ... + * + * calculator.h: + * L0: #pragma once + * L1: (empty) + * L2: class Calculator { + * L3: public: + * L4: int add(int a, int b); + * L5: int subtract(int a, int b); + * ... + * L9: class AdvancedCalculator : public Calculator { + * + * calculator.cpp: + * L0: #include "calculator.h" + * L1: (empty) + * L2: int Calculator::add(int a, int b) { + */ +const cppConfig: LanguageTestConfig = { + langName: 'C++', + workspaceRoot: CPP_ROOT, + indexWaitMs: 5000, + // clangd v19.x does not implement callHierarchy/outgoingCalls (returns -32601) + serverLimitedMethods: new Set(['outgoingCalls']), + positions: { + // hover on `Calculator` type at main.cpp L16:4 → class info + hover: { file: `${CPP_ROOT}/src/main.cpp`, line: 16, char: 4 }, + // definitions on `computeSum` call at main.cpp L17:17 → L11 definition + definitions: { file: `${CPP_ROOT}/src/main.cpp`, line: 17, char: 17 }, + // references on `add` method at calculator.h L4:8 → all usages + references: { file: `${CPP_ROOT}/src/calculator.h`, line: 4, char: 8 }, + // documentSymbols on main.cpp → addValues, subtractValues, computeSum, main + documentSymbolsFile: `${CPP_ROOT}/src/main.cpp`, + symbolQuery: 'Calculator', + // implementations on `Calculator` class at calculator.h L2:6 + // → should find AdvancedCalculator (derived class) + implementations: { file: `${CPP_ROOT}/src/calculator.h`, line: 2, char: 6 }, + // call hierarchy on `computeSum` at main.cpp L11:4 + // → incomingCalls: main; outgoingCalls: addValues, subtractValues + callHierarchy: { file: `${CPP_ROOT}/src/main.cpp`, line: 11, char: 4 }, + diagnosticsFile: `${CPP_ROOT}/src/main.cpp`, + }, +}; + +/** + * Java positions (Main.java / Calculator.java / SimpleCalculator.java): + * + * Main.java: + * L0: package com.test; + * L1: (empty) + * L2: public class Main { + * L3: public static int computeSum(Calculator calc) { + * L4: return calc.add(1, 2) + calc.subtract(5, 3); + * L5: } + * L6: (empty) + * L7: public static void main(String[] args) { + * L8: Calculator calc = new SimpleCalculator(); + * L9: int result = computeSum(calc); + * L10: System.out.println(result); + * L11: } + * L12: } + * + * Calculator.java: + * L0: package com.test; + * L1: (empty) + * L2: public interface Calculator { + * L3: int add(int a, int b); + * + * SimpleCalculator.java: + * L2: public class SimpleCalculator implements Calculator { + * L4: public int add(int a, int b) { + */ +const javaConfig: LanguageTestConfig = { + langName: 'Java', + workspaceRoot: JAVA_ROOT, + indexWaitMs: 20000, + positions: { + // hover on `Calculator` type at Main.java L8:8 → interface info + hover: { + file: `${JAVA_ROOT}/src/main/java/com/test/Main.java`, + line: 8, + char: 8, + }, + // definitions on `computeSum` call at Main.java L9:21 → L3 definition + definitions: { + file: `${JAVA_ROOT}/src/main/java/com/test/Main.java`, + line: 9, + char: 21, + }, + // references on `add` at Calculator.java L3:8 → all usages + references: { + file: `${JAVA_ROOT}/src/main/java/com/test/Calculator.java`, + line: 3, + char: 8, + }, + // documentSymbols on Main.java → Main class, computeSum, main + documentSymbolsFile: `${JAVA_ROOT}/src/main/java/com/test/Main.java`, + symbolQuery: 'Calculator', + // implementations on `Calculator` interface at Calculator.java L2:17 + implementations: { + file: `${JAVA_ROOT}/src/main/java/com/test/Calculator.java`, + line: 2, + char: 17, + }, + // call hierarchy on `computeSum` at Main.java L3:22 + // → incomingCalls: main; outgoingCalls: add, subtract + callHierarchy: { + file: `${JAVA_ROOT}/src/main/java/com/test/Main.java`, + line: 3, + char: 22, + }, + diagnosticsFile: `${JAVA_ROOT}/src/main/java/com/test/Main.java`, + }, +}; + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ +async function main(): Promise { + console.log(bold('LSP End-to-End Test Suite')); + console.log( + 'Verifying all 12 LSP methods with real servers (TS / C++ / Java)\n', + ); + + await testLanguage(tsConfig); + await testLanguage(cppConfig); + await testLanguage(javaConfig); + + /* ---------- Summary ---------- */ + console.log(bold('\n================== Summary ==================')); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log( + `Total: ${results.length} | ${green(`Passed: ${passed}`)} | ${red(`Failed: ${failed}`)}`, + ); + + console.log(bold('\nPer Language:')); + for (const lang of ['TypeScript', 'C++', 'Java']) { + const lr = results.filter((r) => r.language === lang); + const lp = lr.filter((r) => r.passed).length; + const icon = + lp === lr.length ? green('ALL PASS') : yellow(`${lp}/${lr.length}`); + console.log(` ${lang}: ${icon}`); + } + + console.log(bold('\nPer Method:')); + const methods = [ + 'startup', + 'hover', + 'definitions', + 'references', + 'documentSymbols', + 'workspaceSymbols', + 'implementations', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + 'diagnostics', + 'codeActions', + 'workspaceDiagnostics', + ]; + for (const m of methods) { + const mr = results.filter((r) => r.method === m); + const langs = mr + .map((r) => (r.passed ? green(r.language) : red(r.language))) + .join(' | '); + console.log(` ${m}: ${langs}`); + } + + if (failed > 0) { + console.log(yellow('\nFailed tests:')); + for (const r of results.filter((rr) => !rr.passed)) { + console.log(red(` ${r.language}/${r.method}: ${r.detail}`)); + } + } + + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/packages/core/src/lsp/constants.ts b/packages/core/src/lsp/constants.ts index 04fa4bb31..aa70435a0 100644 --- a/packages/core/src/lsp/constants.ts +++ b/packages/core/src/lsp/constants.ts @@ -19,9 +19,25 @@ 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 delay after opening a document to allow the LSP server to process it */ +export const DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS = 200; + /** Default timeout for command existence check in milliseconds */ export const DEFAULT_LSP_COMMAND_CHECK_TIMEOUT_MS = 2000; +/** Default delay for workspace symbol warmup after opening a file, in milliseconds */ +export const DEFAULT_LSP_WORKSPACE_SYMBOL_WARMUP_DELAY_MS = 1500; + +/** + * Default delay before retrying a document-level operation (definitions, + * references, hover, documentSymbols, etc.) when the first attempt returns + * empty results right after we sent textDocument/didOpen. + * + * Slow servers like jdtls (Java) and clangd (C++) need significantly more + * time than the initial 200ms didOpen delay to build their AST / index. + */ +export const DEFAULT_LSP_DOCUMENT_RETRY_DELAY_MS = 2000; + // ============================================================================ // Retry Constants // ============================================================================ diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 58ff1f235..9068d3f1a 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -139,7 +139,7 @@ describe('SessionService', () => { readdirSyncSpy.mockReturnValue([ `${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -171,7 +171,7 @@ describe('SessionService', () => { readdirSyncSpy.mockReturnValue([ `${sessionIdA}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockReturnValue({ mtimeMs: now, @@ -195,7 +195,7 @@ describe('SessionService', () => { readdirSyncSpy.mockReturnValue([ `${sessionIdA}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -215,7 +215,7 @@ describe('SessionService', () => { `${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`, `${sessionIdC}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -258,7 +258,7 @@ describe('SessionService', () => { `${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`, `${sessionIdC}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -284,7 +284,7 @@ describe('SessionService', () => { it('should skip files from different projects', async () => { readdirSyncSpy.mockReturnValue([ `${sessionIdA}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -313,7 +313,7 @@ describe('SessionService', () => { 'not-a-uuid.jsonl', // invalid pattern 'readme.txt', // not jsonl '.hidden.jsonl', // hidden file - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -559,7 +559,7 @@ describe('SessionService', () => { readdirSyncSpy.mockReturnValue([ `${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`, - ] as unknown as Array>); + ] as unknown as fs.Dirent[]); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 7036936e2..e020f15da 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -866,14 +866,18 @@ export function execCommand( reject(error); } else { resolve({ - stdout: stdout ?? '', - stderr: stderr ?? '', + stdout: String(stdout ?? ''), + stderr: String(stderr ?? ''), code: typeof error.code === 'number' ? error.code : 1, }); } return; } - resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: 0 }); + resolve({ + stdout: String(stdout ?? ''), + stderr: String(stderr ?? ''), + code: 0, + }); }, ); child.on('error', reject);