source dump of claude code
at main 420 lines 13 kB view raw
1import * as path from 'path' 2import { pathToFileURL } from 'url' 3import { logForDebugging } from '../../utils/debug.js' 4import { errorMessage } from '../../utils/errors.js' 5import { logError } from '../../utils/log.js' 6import { getAllLspServers } from './config.js' 7import { 8 createLSPServerInstance, 9 type LSPServerInstance, 10} from './LSPServerInstance.js' 11import type { ScopedLspServerConfig } from './types.js' 12/** 13 * LSP Server Manager interface returned by createLSPServerManager. 14 * Manages multiple LSP server instances and routes requests based on file extensions. 15 */ 16export type LSPServerManager = { 17 /** Initialize the manager by loading all configured LSP servers */ 18 initialize(): Promise<void> 19 /** Shutdown all running servers and clear state */ 20 shutdown(): Promise<void> 21 /** Get the LSP server instance for a given file path */ 22 getServerForFile(filePath: string): LSPServerInstance | undefined 23 /** Ensure the appropriate LSP server is started for the given file */ 24 ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined> 25 /** Send a request to the appropriate LSP server for the given file */ 26 sendRequest<T>( 27 filePath: string, 28 method: string, 29 params: unknown, 30 ): Promise<T | undefined> 31 /** Get all running server instances */ 32 getAllServers(): Map<string, LSPServerInstance> 33 /** Synchronize file open to LSP server (sends didOpen notification) */ 34 openFile(filePath: string, content: string): Promise<void> 35 /** Synchronize file change to LSP server (sends didChange notification) */ 36 changeFile(filePath: string, content: string): Promise<void> 37 /** Synchronize file save to LSP server (sends didSave notification) */ 38 saveFile(filePath: string): Promise<void> 39 /** Synchronize file close to LSP server (sends didClose notification) */ 40 closeFile(filePath: string): Promise<void> 41 /** Check if a file is already open on a compatible LSP server */ 42 isFileOpen(filePath: string): boolean 43} 44 45/** 46 * Creates an LSP server manager instance. 47 * 48 * Manages multiple LSP server instances and routes requests based on file extensions. 49 * Uses factory function pattern with closures for state encapsulation (avoiding classes). 50 * 51 * @returns LSP server manager instance 52 * 53 * @example 54 * const manager = createLSPServerManager() 55 * await manager.initialize() 56 * const result = await manager.sendRequest('/path/to/file.ts', 'textDocument/definition', params) 57 * await manager.shutdown() 58 */ 59export function createLSPServerManager(): LSPServerManager { 60 // Private state managed via closures 61 const servers: Map<string, LSPServerInstance> = new Map() 62 const extensionMap: Map<string, string[]> = new Map() 63 // Track which files have been opened on which servers (URI -> server name) 64 const openedFiles: Map<string, string> = new Map() 65 66 /** 67 * Initialize the manager by loading all configured LSP servers. 68 * 69 * @throws {Error} If configuration loading fails 70 */ 71 async function initialize(): Promise<void> { 72 let serverConfigs: Record<string, ScopedLspServerConfig> 73 74 try { 75 const result = await getAllLspServers() 76 serverConfigs = result.servers 77 logForDebugging( 78 `[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`, 79 ) 80 } catch (error) { 81 const err = error as Error 82 logError( 83 new Error(`Failed to load LSP server configuration: ${err.message}`), 84 ) 85 throw error 86 } 87 88 // Build extension → server mapping 89 for (const [serverName, config] of Object.entries(serverConfigs)) { 90 try { 91 // Validate config before using it 92 if (!config.command) { 93 throw new Error( 94 `Server ${serverName} missing required 'command' field`, 95 ) 96 } 97 if ( 98 !config.extensionToLanguage || 99 Object.keys(config.extensionToLanguage).length === 0 100 ) { 101 throw new Error( 102 `Server ${serverName} missing required 'extensionToLanguage' field`, 103 ) 104 } 105 106 // Map file extensions to this server (derive from extensionToLanguage) 107 const fileExtensions = Object.keys(config.extensionToLanguage) 108 for (const ext of fileExtensions) { 109 const normalized = ext.toLowerCase() 110 if (!extensionMap.has(normalized)) { 111 extensionMap.set(normalized, []) 112 } 113 const serverList = extensionMap.get(normalized) 114 if (serverList) { 115 serverList.push(serverName) 116 } 117 } 118 119 // Create server instance 120 const instance = createLSPServerInstance(serverName, config) 121 servers.set(serverName, instance) 122 123 // Register handler for workspace/configuration requests from the server 124 // Some servers (like TypeScript) send these even when we say we don't support them 125 instance.onRequest( 126 'workspace/configuration', 127 (params: { items: Array<{ section?: string }> }) => { 128 logForDebugging( 129 `LSP: Received workspace/configuration request from ${serverName}`, 130 ) 131 // Return empty/null config for each requested item 132 // This satisfies the protocol without providing actual configuration 133 return params.items.map(() => null) 134 }, 135 ) 136 } catch (error) { 137 const err = error as Error 138 logError( 139 new Error( 140 `Failed to initialize LSP server ${serverName}: ${err.message}`, 141 ), 142 ) 143 // Continue with other servers - don't fail entire initialization 144 } 145 } 146 147 logForDebugging(`LSP manager initialized with ${servers.size} servers`) 148 } 149 150 /** 151 * Shutdown all running servers and clear state. 152 * Only servers in 'running' state are explicitly stopped; 153 * servers in other states are cleared without shutdown. 154 * 155 * @throws {Error} If one or more servers fail to stop 156 */ 157 async function shutdown(): Promise<void> { 158 const toStop = Array.from(servers.entries()).filter( 159 ([, s]) => s.state === 'running' || s.state === 'error', 160 ) 161 162 const results = await Promise.allSettled( 163 toStop.map(([, server]) => server.stop()), 164 ) 165 166 servers.clear() 167 extensionMap.clear() 168 openedFiles.clear() 169 170 const errors = results 171 .map((r, i) => 172 r.status === 'rejected' 173 ? `${toStop[i]![0]}: ${errorMessage(r.reason)}` 174 : null, 175 ) 176 .filter((e): e is string => e !== null) 177 178 if (errors.length > 0) { 179 const err = new Error( 180 `Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`, 181 ) 182 logError(err) 183 throw err 184 } 185 } 186 187 /** 188 * Get the LSP server instance for a given file path. 189 * If multiple servers handle the same extension, returns the first registered server. 190 * Returns undefined if no server handles this file type. 191 */ 192 function getServerForFile(filePath: string): LSPServerInstance | undefined { 193 const ext = path.extname(filePath).toLowerCase() 194 const serverNames = extensionMap.get(ext) 195 196 if (!serverNames || serverNames.length === 0) { 197 return undefined 198 } 199 200 // Use first server (can add priority later) 201 const serverName = serverNames[0] 202 if (!serverName) { 203 return undefined 204 } 205 206 return servers.get(serverName) 207 } 208 209 /** 210 * Ensure the appropriate LSP server is started for the given file. 211 * Returns undefined if no server handles this file type. 212 * 213 * @throws {Error} If server fails to start 214 */ 215 async function ensureServerStarted( 216 filePath: string, 217 ): Promise<LSPServerInstance | undefined> { 218 const server = getServerForFile(filePath) 219 if (!server) return undefined 220 221 if (server.state === 'stopped' || server.state === 'error') { 222 try { 223 await server.start() 224 } catch (error) { 225 const err = error as Error 226 logError( 227 new Error( 228 `Failed to start LSP server for file ${filePath}: ${err.message}`, 229 ), 230 ) 231 throw error 232 } 233 } 234 235 return server 236 } 237 238 /** 239 * Send a request to the appropriate LSP server for the given file. 240 * Returns undefined if no server handles this file type. 241 * 242 * @throws {Error} If server fails to start or request fails 243 */ 244 async function sendRequest<T>( 245 filePath: string, 246 method: string, 247 params: unknown, 248 ): Promise<T | undefined> { 249 const server = await ensureServerStarted(filePath) 250 if (!server) return undefined 251 252 try { 253 return await server.sendRequest<T>(method, params) 254 } catch (error) { 255 const err = error as Error 256 logError( 257 new Error( 258 `LSP request failed for file ${filePath}, method '${method}': ${err.message}`, 259 ), 260 ) 261 throw error 262 } 263 } 264 265 // Return public interface 266 function getAllServers(): Map<string, LSPServerInstance> { 267 return servers 268 } 269 270 async function openFile(filePath: string, content: string): Promise<void> { 271 const server = await ensureServerStarted(filePath) 272 if (!server) return 273 274 const fileUri = pathToFileURL(path.resolve(filePath)).href 275 276 // Skip if already opened on this server 277 if (openedFiles.get(fileUri) === server.name) { 278 logForDebugging( 279 `LSP: File already open, skipping didOpen for ${filePath}`, 280 ) 281 return 282 } 283 284 // Get language ID from server's extensionToLanguage mapping 285 const ext = path.extname(filePath).toLowerCase() 286 const languageId = server.config.extensionToLanguage[ext] || 'plaintext' 287 288 try { 289 await server.sendNotification('textDocument/didOpen', { 290 textDocument: { 291 uri: fileUri, 292 languageId, 293 version: 1, 294 text: content, 295 }, 296 }) 297 // Track that this file is now open on this server 298 openedFiles.set(fileUri, server.name) 299 logForDebugging( 300 `LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`, 301 ) 302 } catch (error) { 303 const err = new Error( 304 `Failed to sync file open ${filePath}: ${errorMessage(error)}`, 305 ) 306 logError(err) 307 // Re-throw to propagate error to caller 308 throw err 309 } 310 } 311 312 async function changeFile(filePath: string, content: string): Promise<void> { 313 const server = getServerForFile(filePath) 314 if (!server || server.state !== 'running') { 315 return openFile(filePath, content) 316 } 317 318 const fileUri = pathToFileURL(path.resolve(filePath)).href 319 320 // If file hasn't been opened on this server yet, open it first 321 // LSP servers require didOpen before didChange 322 if (openedFiles.get(fileUri) !== server.name) { 323 return openFile(filePath, content) 324 } 325 326 try { 327 await server.sendNotification('textDocument/didChange', { 328 textDocument: { 329 uri: fileUri, 330 version: 1, 331 }, 332 contentChanges: [{ text: content }], 333 }) 334 logForDebugging(`LSP: Sent didChange for ${filePath}`) 335 } catch (error) { 336 const err = new Error( 337 `Failed to sync file change ${filePath}: ${errorMessage(error)}`, 338 ) 339 logError(err) 340 // Re-throw to propagate error to caller 341 throw err 342 } 343 } 344 345 /** 346 * Save a file in LSP servers (sends didSave notification) 347 * Called after file is written to disk to trigger diagnostics 348 */ 349 async function saveFile(filePath: string): Promise<void> { 350 const server = getServerForFile(filePath) 351 if (!server || server.state !== 'running') return 352 353 try { 354 await server.sendNotification('textDocument/didSave', { 355 textDocument: { 356 uri: pathToFileURL(path.resolve(filePath)).href, 357 }, 358 }) 359 logForDebugging(`LSP: Sent didSave for ${filePath}`) 360 } catch (error) { 361 const err = new Error( 362 `Failed to sync file save ${filePath}: ${errorMessage(error)}`, 363 ) 364 logError(err) 365 // Re-throw to propagate error to caller 366 throw err 367 } 368 } 369 370 /** 371 * Close a file in LSP servers (sends didClose notification) 372 * 373 * NOTE: Currently available but not yet integrated with compact flow. 374 * TODO: Integrate with compact - call closeFile() when compact removes files from context 375 * This will notify LSP servers that files are no longer in active use. 376 */ 377 async function closeFile(filePath: string): Promise<void> { 378 const server = getServerForFile(filePath) 379 if (!server || server.state !== 'running') return 380 381 const fileUri = pathToFileURL(path.resolve(filePath)).href 382 383 try { 384 await server.sendNotification('textDocument/didClose', { 385 textDocument: { 386 uri: fileUri, 387 }, 388 }) 389 // Remove from tracking so file can be reopened later 390 openedFiles.delete(fileUri) 391 logForDebugging(`LSP: Sent didClose for ${filePath}`) 392 } catch (error) { 393 const err = new Error( 394 `Failed to sync file close ${filePath}: ${errorMessage(error)}`, 395 ) 396 logError(err) 397 // Re-throw to propagate error to caller 398 throw err 399 } 400 } 401 402 function isFileOpen(filePath: string): boolean { 403 const fileUri = pathToFileURL(path.resolve(filePath)).href 404 return openedFiles.has(fileUri) 405 } 406 407 return { 408 initialize, 409 shutdown, 410 getServerForFile, 411 ensureServerStarted, 412 sendRequest, 413 getAllServers, 414 openFile, 415 changeFile, 416 saveFile, 417 closeFile, 418 isFileOpen, 419 } 420}