/ services / lsp / LSPServerManager.ts
LSPServerManager.ts
  1  import * as path from 'path'
  2  import { pathToFileURL } from 'url'
  3  import { logForDebugging } from '../../utils/debug.js'
  4  import { errorMessage } from '../../utils/errors.js'
  5  import { logError } from '../../utils/log.js'
  6  import { getAllLspServers } from './config.js'
  7  import {
  8    createLSPServerInstance,
  9    type LSPServerInstance,
 10  } from './LSPServerInstance.js'
 11  import 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   */
 16  export 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   */
 59  export 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  }