@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
JavaScript
/**
* 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