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 /** Shutdown all running servers and clear state */ shutdown(): Promise /** 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 /** Send a request to the appropriate LSP server for the given file */ sendRequest( filePath: string, method: string, params: unknown, ): Promise /** Get all running server instances */ getAllServers(): Map /** Synchronize file open to LSP server (sends didOpen notification) */ openFile(filePath: string, content: string): Promise /** Synchronize file change to LSP server (sends didChange notification) */ changeFile(filePath: string, content: string): Promise /** Synchronize file save to LSP server (sends didSave notification) */ saveFile(filePath: string): Promise /** Synchronize file close to LSP server (sends didClose notification) */ closeFile(filePath: string): Promise /** 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 = new Map() const extensionMap: Map = new Map() // Track which files have been opened on which servers (URI -> server name) const openedFiles: Map = new Map() /** * Initialize the manager by loading all configured LSP servers. * * @throws {Error} If configuration loading fails */ async function initialize(): Promise { let serverConfigs: Record 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 { 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 { 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( filePath: string, method: string, params: unknown, ): Promise { const server = await ensureServerStarted(filePath) if (!server) return undefined try { return await server.sendRequest(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 { return servers } async function openFile(filePath: string, content: string): Promise { 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 { 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 { 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 { 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, } }