Download src/services/lsp/LSPServerManager.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 13.4 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPServerManager.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPServerManager.ts
-
curl -L -o LSPServerManager.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPServerManager.ts
13.4 kB
| import * as path from 'path' | |
| import { pathToFileURL } from 'url' | |
| import { logForDebugging } from '../../utils/debug.js' | |
| import { errorMessage } from '../../utils/errors.js' | |
| import { logError } from '../../utils/log.js' | |
| import { getAllLspServers } from './config.js' | |
| import { | |
| createLSPServerInstance, | |
| type LSPServerInstance, | |
| } from './LSPServerInstance.js' | |
| import type { ScopedLspServerConfig } from './types.js' | |
| /** | |
| * LSP Server Manager interface returned by createLSPServerManager. | |
| * Manages multiple LSP server instances and routes requests based on file extensions. | |
| */ | |
| export type LSPServerManager = { | |
| /** Initialize the manager by loading all configured LSP servers */ | |
| initialize(): Promise<void> | |
| /** Shutdown all running servers and clear state */ | |
| shutdown(): Promise<void> | |
| /** Get the LSP server instance for a given file path */ | |
| getServerForFile(filePath: string): LSPServerInstance | undefined | |
| /** Ensure the appropriate LSP server is started for the given file */ | |
| ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined> | |
| /** Send a request to the appropriate LSP server for the given file */ | |
| sendRequest<T>( | |
| filePath: string, | |
| method: string, | |
| params: unknown, | |
| ): Promise<T | undefined> | |
| /** Get all running server instances */ | |
| getAllServers(): Map<string, LSPServerInstance> | |
| /** Synchronize file open to LSP server (sends didOpen notification) */ | |
| openFile(filePath: string, content: string): Promise<void> | |
| /** Synchronize file change to LSP server (sends didChange notification) */ | |
| changeFile(filePath: string, content: string): Promise<void> | |
| /** Synchronize file save to LSP server (sends didSave notification) */ | |
| saveFile(filePath: string): Promise<void> | |
| /** Synchronize file close to LSP server (sends didClose notification) */ | |
| closeFile(filePath: string): Promise<void> | |
| /** Check if a file is already open on a compatible LSP server */ | |
| isFileOpen(filePath: string): boolean | |
| } | |
| /** | |
| * Creates an LSP server manager instance. | |
| * | |
| * Manages multiple LSP server instances and routes requests based on file extensions. | |
| * Uses factory function pattern with closures for state encapsulation (avoiding classes). | |
| * | |
| * @returns LSP server manager instance | |
| * | |
| * @example | |
| * const manager = createLSPServerManager() | |
| * await manager.initialize() | |
| * const result = await manager.sendRequest('/path/to/file.ts', 'textDocument/definition', params) | |
| * await manager.shutdown() | |
| */ | |
| export function createLSPServerManager(): LSPServerManager { | |
| // Private state managed via closures | |
| const servers: Map<string, LSPServerInstance> = new Map() | |
| const extensionMap: Map<string, string[]> = new Map() | |
| // Track which files have been opened on which servers (URI -> server name) | |
| const openedFiles: Map<string, string> = new Map() | |
| /** | |
| * Initialize the manager by loading all configured LSP servers. | |
| * | |
| * @throws {Error} If configuration loading fails | |
| */ | |
| async function initialize(): Promise<void> { | |
| let serverConfigs: Record<string, ScopedLspServerConfig> | |
| try { | |
| const result = await getAllLspServers() | |
| serverConfigs = result.servers | |
| logForDebugging( | |
| `[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`, | |
| ) | |
| } catch (error) { | |
| const err = error as Error | |
| logError( | |
| new Error(`Failed to load LSP server configuration: ${err.message}`), | |
| ) | |
| throw error | |
| } | |
| // Build extension → server mapping | |
| for (const [serverName, config] of Object.entries(serverConfigs)) { | |
| try { | |
| // Validate config before using it | |
| if (!config.command) { | |
| throw new Error( | |
| `Server ${serverName} missing required 'command' field`, | |
| ) | |
| } | |
| if ( | |
| !config.extensionToLanguage || | |
| Object.keys(config.extensionToLanguage).length === 0 | |
| ) { | |
| throw new Error( | |
| `Server ${serverName} missing required 'extensionToLanguage' field`, | |
| ) | |
| } | |
| // Map file extensions to this server (derive from extensionToLanguage) | |
| const fileExtensions = Object.keys(config.extensionToLanguage) | |
| for (const ext of fileExtensions) { | |
| const normalized = ext.toLowerCase() | |
| if (!extensionMap.has(normalized)) { | |
| extensionMap.set(normalized, []) | |
| } | |
| const serverList = extensionMap.get(normalized) | |
| if (serverList) { | |
| serverList.push(serverName) | |
| } | |
| } | |
| // Create server instance | |
| const instance = createLSPServerInstance(serverName, config) | |
| servers.set(serverName, instance) | |
| // Register handler for workspace/configuration requests from the server | |
| // Some servers (like TypeScript) send these even when we say we don't support them | |
| instance.onRequest( | |
| 'workspace/configuration', | |
| (params: { items: Array<{ section?: string }> }) => { | |
| logForDebugging( | |
| `LSP: Received workspace/configuration request from ${serverName}`, | |
| ) | |
| // Return empty/null config for each requested item | |
| // This satisfies the protocol without providing actual configuration | |
| return params.items.map(() => null) | |
| }, | |
| ) | |
| } catch (error) { | |
| const err = error as Error | |
| logError( | |
| new Error( | |
| `Failed to initialize LSP server ${serverName}: ${err.message}`, | |
| ), | |
| ) | |
| // Continue with other servers - don't fail entire initialization | |
| } | |
| } | |
| logForDebugging(`LSP manager initialized with ${servers.size} servers`) | |
| } | |
| /** | |
| * Shutdown all running servers and clear state. | |
| * Only servers in 'running' state are explicitly stopped; | |
| * servers in other states are cleared without shutdown. | |
| * | |
| * @throws {Error} If one or more servers fail to stop | |
| */ | |
| async function shutdown(): Promise<void> { | |
| const toStop = Array.from(servers.entries()).filter( | |
| ([, s]) => s.state === 'running' || s.state === 'error', | |
| ) | |
| const results = await Promise.allSettled( | |
| toStop.map(([, server]) => server.stop()), | |
| ) | |
| servers.clear() | |
| extensionMap.clear() | |
| openedFiles.clear() | |
| const errors = results | |
| .map((r, i) => | |
| r.status === 'rejected' | |
| ? `${toStop[i]![0]}: ${errorMessage(r.reason)}` | |
| : null, | |
| ) | |
| .filter((e): e is string => e !== null) | |
| if (errors.length > 0) { | |
| const err = new Error( | |
| `Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`, | |
| ) | |
| logError(err) | |
| throw err | |
| } | |
| } | |
| /** | |
| * Get the LSP server instance for a given file path. | |
| * If multiple servers handle the same extension, returns the first registered server. | |
| * Returns undefined if no server handles this file type. | |
| */ | |
| function getServerForFile(filePath: string): LSPServerInstance | undefined { | |
| const ext = path.extname(filePath).toLowerCase() | |
| const serverNames = extensionMap.get(ext) | |
| if (!serverNames || serverNames.length === 0) { | |
| return undefined | |
| } | |
| // Use first server (can add priority later) | |
| const serverName = serverNames[0] | |
| if (!serverName) { | |
| return undefined | |
| } | |
| return servers.get(serverName) | |
| } | |
| /** | |
| * Ensure the appropriate LSP server is started for the given file. | |
| * Returns undefined if no server handles this file type. | |
| * | |
| * @throws {Error} If server fails to start | |
| */ | |
| async function ensureServerStarted( | |
| filePath: string, | |
| ): Promise<LSPServerInstance | undefined> { | |
| const server = getServerForFile(filePath) | |
| if (!server) return undefined | |
| if (server.state === 'stopped' || server.state === 'error') { | |
| try { | |
| await server.start() | |
| } catch (error) { | |
| const err = error as Error | |
| logError( | |
| new Error( | |
| `Failed to start LSP server for file ${filePath}: ${err.message}`, | |
| ), | |
| ) | |
| throw error | |
| } | |
| } | |
| return server | |
| } | |
| /** | |
| * Send a request to the appropriate LSP server for the given file. | |
| * Returns undefined if no server handles this file type. | |
| * | |
| * @throws {Error} If server fails to start or request fails | |
| */ | |
| async function sendRequest<T>( | |
| filePath: string, | |
| method: string, | |
| params: unknown, | |
| ): Promise<T | undefined> { | |
| const server = await ensureServerStarted(filePath) | |
| if (!server) return undefined | |
| try { | |
| return await server.sendRequest<T>(method, params) | |
| } catch (error) { | |
| const err = error as Error | |
| logError( | |
| new Error( | |
| `LSP request failed for file ${filePath}, method '${method}': ${err.message}`, | |
| ), | |
| ) | |
| throw error | |
| } | |
| } | |
| // Return public interface | |
| function getAllServers(): Map<string, LSPServerInstance> { | |
| return servers | |
| } | |
| async function openFile(filePath: string, content: string): Promise<void> { | |
| const server = await ensureServerStarted(filePath) | |
| if (!server) return | |
| const fileUri = pathToFileURL(path.resolve(filePath)).href | |
| // Skip if already opened on this server | |
| if (openedFiles.get(fileUri) === server.name) { | |
| logForDebugging( | |
| `LSP: File already open, skipping didOpen for ${filePath}`, | |
| ) | |
| return | |
| } | |
| // Get language ID from server's extensionToLanguage mapping | |
| const ext = path.extname(filePath).toLowerCase() | |
| const languageId = server.config.extensionToLanguage[ext] || 'plaintext' | |
| try { | |
| await server.sendNotification('textDocument/didOpen', { | |
| textDocument: { | |
| uri: fileUri, | |
| languageId, | |
| version: 1, | |
| text: content, | |
| }, | |
| }) | |
| // Track that this file is now open on this server | |
| openedFiles.set(fileUri, server.name) | |
| logForDebugging( | |
| `LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`, | |
| ) | |
| } catch (error) { | |
| const err = new Error( | |
| `Failed to sync file open ${filePath}: ${errorMessage(error)}`, | |
| ) | |
| logError(err) | |
| // Re-throw to propagate error to caller | |
| throw err | |
| } | |
| } | |
| async function changeFile(filePath: string, content: string): Promise<void> { | |
| const server = getServerForFile(filePath) | |
| if (!server || server.state !== 'running') { | |
| return openFile(filePath, content) | |
| } | |
| const fileUri = pathToFileURL(path.resolve(filePath)).href | |
| // If file hasn't been opened on this server yet, open it first | |
| // LSP servers require didOpen before didChange | |
| if (openedFiles.get(fileUri) !== server.name) { | |
| return openFile(filePath, content) | |
| } | |
| try { | |
| await server.sendNotification('textDocument/didChange', { | |
| textDocument: { | |
| uri: fileUri, | |
| version: 1, | |
| }, | |
| contentChanges: [{ text: content }], | |
| }) | |
| logForDebugging(`LSP: Sent didChange for ${filePath}`) | |
| } catch (error) { | |
| const err = new Error( | |
| `Failed to sync file change ${filePath}: ${errorMessage(error)}`, | |
| ) | |
| logError(err) | |
| // Re-throw to propagate error to caller | |
| throw err | |
| } | |
| } | |
| /** | |
| * Save a file in LSP servers (sends didSave notification) | |
| * Called after file is written to disk to trigger diagnostics | |
| */ | |
| async function saveFile(filePath: string): Promise<void> { | |
| const server = getServerForFile(filePath) | |
| if (!server || server.state !== 'running') return | |
| try { | |
| await server.sendNotification('textDocument/didSave', { | |
| textDocument: { | |
| uri: pathToFileURL(path.resolve(filePath)).href, | |
| }, | |
| }) | |
| logForDebugging(`LSP: Sent didSave for ${filePath}`) | |
| } catch (error) { | |
| const err = new Error( | |
| `Failed to sync file save ${filePath}: ${errorMessage(error)}`, | |
| ) | |
| logError(err) | |
| // Re-throw to propagate error to caller | |
| throw err | |
| } | |
| } | |
| /** | |
| * Close a file in LSP servers (sends didClose notification) | |
| * | |
| * NOTE: Currently available but not yet integrated with compact flow. | |
| * TODO: Integrate with compact - call closeFile() when compact removes files from context | |
| * This will notify LSP servers that files are no longer in active use. | |
| */ | |
| async function closeFile(filePath: string): Promise<void> { | |
| const server = getServerForFile(filePath) | |
| if (!server || server.state !== 'running') return | |
| const fileUri = pathToFileURL(path.resolve(filePath)).href | |
| try { | |
| await server.sendNotification('textDocument/didClose', { | |
| textDocument: { | |
| uri: fileUri, | |
| }, | |
| }) | |
| // Remove from tracking so file can be reopened later | |
| openedFiles.delete(fileUri) | |
| logForDebugging(`LSP: Sent didClose for ${filePath}`) | |
| } catch (error) { | |
| const err = new Error( | |
| `Failed to sync file close ${filePath}: ${errorMessage(error)}`, | |
| ) | |
| logError(err) | |
| // Re-throw to propagate error to caller | |
| throw err | |
| } | |
| } | |
| function isFileOpen(filePath: string): boolean { | |
| const fileUri = pathToFileURL(path.resolve(filePath)).href | |
| return openedFiles.has(fileUri) | |
| } | |
| return { | |
| initialize, | |
| shutdown, | |
| getServerForFile, | |
| ensureServerStarted, | |
| sendRequest, | |
| getAllServers, | |
| openFile, | |
| changeFile, | |
| saveFile, | |
| closeFile, | |
| isFileOpen, | |
| } | |
| } | |