UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

337 lines 11.4 kB
/** * LSP Manager * Manages multiple language server connections with auto-discovery and routing */ import { EventEmitter } from 'events'; import { readFile } from 'fs/promises'; import { extname } from 'path'; import { fileURLToPath } from 'url'; import { getShutdownManager } from '../utils/shutdown/index.js'; import { LSPClient } from './lsp-client.js'; import { discoverLanguageServers, getLanguageId } from './server-discovery.js'; export class LSPManager extends EventEmitter { clients = new Map(); // serverName -> client languageToServer = new Map(); // extension -> serverName documentServers = new Map(); // uri -> serverName diagnosticsCache = new Map(); // uri -> diagnostics rootUri; initialized = false; constructor(config = {}) { super(); this.rootUri = config.rootUri || `file://${process.cwd()}`; } /** * Initialize the LSP manager with auto-discovery and/or custom servers */ async initialize(config = {}) { const results = []; const serversToStart = []; // Add custom servers first (they take priority) if (config.servers) { for (const server of config.servers) { serversToStart.push({ ...server, rootUri: server.rootUri || this.rootUri, }); } } // Auto-discover additional servers if enabled (default: true) if (config.autoDiscover !== false) { // Extract file path from rootUri for context-aware server discovery const projectRoot = this.rootUri.startsWith('file://') ? fileURLToPath(this.rootUri) : this.rootUri; const discovered = await discoverLanguageServers(projectRoot); // Only add discovered servers for languages not already covered const coveredLanguages = new Set(); for (const server of serversToStart) { for (const lang of server.languages) { coveredLanguages.add(lang); } } for (const server of discovered) { const hasNewLanguages = server.languages.some(lang => !coveredLanguages.has(lang)); if (hasNewLanguages) { serversToStart.push({ ...server, rootUri: this.rootUri, }); for (const lang of server.languages) { coveredLanguages.add(lang); } } } } // Start all servers in parallel const startPromises = serversToStart.map(async (serverConfig) => { const result = await this.startServer(serverConfig); config.onProgress?.(result); results.push(result); return result; }); await Promise.all(startPromises); this.initialized = true; return results; } /** * Start a single language server */ async startServer(config) { try { const client = new LSPClient(config); // Handle diagnostics from this server client.on('diagnostics', (params) => { this.diagnosticsCache.set(params.uri, params.diagnostics); this.emit('diagnostics', params); }); client.on('exit', (_code) => { this.clients.delete(config.name); // Remove language mappings for this server for (const [lang, serverName] of this.languageToServer.entries()) { if (serverName === config.name) { this.languageToServer.delete(lang); } } }); await client.start(); // Store client and language mappings this.clients.set(config.name, client); for (const lang of config.languages) { this.languageToServer.set(lang, config.name); } return { serverName: config.name, success: true, languages: config.languages, }; } catch (error) { return { serverName: config.name, success: false, error: error instanceof Error ? error.message : String(error), }; } } /** * Stop all language servers */ async shutdown() { const stopPromises = Array.from(this.clients.values()).map(client => client.stop()); await Promise.all(stopPromises); this.clients.clear(); this.languageToServer.clear(); this.documentServers.clear(); this.diagnosticsCache.clear(); this.initialized = false; } /** * Get the client for a file based on its extension */ getClientForFile(filePath) { const ext = extname(filePath).slice(1); // Remove leading dot const serverName = this.languageToServer.get(ext); if (!serverName) return undefined; return this.clients.get(serverName); } /** * Convert file path to URI */ fileToUri(filePath) { if (filePath.startsWith('file://')) return filePath; return `file://${filePath}`; } /** * Open a document in the appropriate language server */ async openDocument(filePath, content) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return false; const uri = this.fileToUri(filePath); const ext = extname(filePath).slice(1); const languageId = getLanguageId(ext); // Read content if not provided const text = content ?? (await readFile(filePath, 'utf-8')); client.openDocument(uri, languageId, text); this.documentServers.set(uri, client.getCapabilities() ? 'active' : ''); return true; } /** * Update a document in the language server */ updateDocument(filePath, content) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return false; const uri = this.fileToUri(filePath); client.updateDocument(uri, content); return true; } /** * Close a document in the language server */ closeDocument(filePath) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return false; const uri = this.fileToUri(filePath); client.closeDocument(uri); this.documentServers.delete(uri); this.diagnosticsCache.delete(uri); return true; } /** * Get diagnostics for a file */ async getDiagnostics(filePath) { const uri = this.fileToUri(filePath); // First check cache (from push notifications) const cached = this.diagnosticsCache.get(uri); if (cached) return cached; // Try pull diagnostics const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return []; return client.getDiagnostics(uri); } /** * Get diagnostics for all open documents */ getAllDiagnostics() { const results = []; for (const [uri, diagnostics] of this.diagnosticsCache.entries()) { if (diagnostics.length > 0) { results.push({ uri, diagnostics }); } } return results; } /** * Get completions at a position in a file */ async getCompletions(filePath, line, character) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return []; const uri = this.fileToUri(filePath); return client.getCompletions(uri, { line, character }); } /** * Get code actions for a range in a file */ async getCodeActions(filePath, startLine, startChar, endLine, endChar, diagnostics) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return []; const uri = this.fileToUri(filePath); // Use provided diagnostics or get from cache const diags = diagnostics || this.diagnosticsCache.get(uri)?.filter(d => { // Filter diagnostics that overlap with the range return d.range.start.line <= endLine && d.range.end.line >= startLine; }) || []; return client.getCodeActions(uri, diags, startLine, startChar, endLine, endChar); } /** * Format a document */ async formatDocument(filePath, options) { const client = this.getClientForFile(filePath); if (!client || !client.isReady()) return []; const uri = this.fileToUri(filePath); return client.formatDocument(uri, options); } /** * Check if LSP is available for a file type */ hasLanguageSupport(filePath) { const ext = extname(filePath).slice(1); return this.languageToServer.has(ext); } /** * Get list of connected servers */ getConnectedServers() { return Array.from(this.clients.keys()); } /** * Get supported languages */ getSupportedLanguages() { return Array.from(this.languageToServer.keys()); } /** * Check if manager is initialized */ isInitialized() { return this.initialized; } /** * Get server status */ getStatus() { const servers = []; for (const [name, client] of this.clients.entries()) { const languages = []; for (const [lang, serverName] of this.languageToServer.entries()) { if (serverName === name) { languages.push(lang); } } servers.push({ name, ready: client.isReady(), languages, }); } return { initialized: this.initialized, servers, }; } } // Singleton instance let lspManagerInstance = null; let lspManagerInitPromise = null; /** * Get or create the LSP manager singleton * Uses promise-based initialization to prevent race conditions */ export async function getLSPManager(config) { if (lspManagerInstance) { return lspManagerInstance; } if (lspManagerInitPromise) { return lspManagerInitPromise; } // Create manager synchronously to ensure instance is set immediately lspManagerInstance = new LSPManager(config); lspManagerInitPromise = Promise.resolve(lspManagerInstance); getShutdownManager().register({ name: 'lsp-manager', priority: 30, handler: async () => { if (lspManagerInstance) { await lspManagerInstance.shutdown(); } }, }); return lspManagerInitPromise; } /** * Reset the LSP manager (for testing) */ export async function resetLSPManager() { if (lspManagerInstance) { await lspManagerInstance.shutdown(); lspManagerInstance = null; } lspManagerInitPromise = null; } //# sourceMappingURL=lsp-manager.js.map