Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
13 kB · 420 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421import * 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, }}