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

388 lines 13.6 kB
/** * LSP Client implementation * Manages connections to language servers via JSON-RPC over stdio */ import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import { createChildLogger } from '../utils/logging/index.js'; import { CompletionTriggerKind, LSPMethods, } from './protocol.js'; const logger = createChildLogger({ module: 'lsp-client' }); export class LSPClient extends EventEmitter { config; process = null; buffer = ''; requestId = 0; pendingRequests = new Map(); initialized = false; serverCapabilities = null; openDocuments = new Map(); // uri -> version constructor(config) { super(); this.config = config; } /** * Start the language server process and initialize */ async start() { return new Promise((resolve, reject) => { try { this.process = spawn(this.config.command, this.config.args || [], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...this.config.env }, }); this.process.stdout?.on('data', (data) => { this.handleData(data.toString()); }); this.process.stderr?.on('data', (_data) => { }); this.process.on('error', error => { this.emit('error', error); reject(error); }); this.process.on('exit', code => { this.emit('exit', code); this.initialized = false; }); // Initialize the server this.initialize() .then(result => { this.serverCapabilities = result.capabilities; this.initialized = true; resolve(result); }) .catch(reject); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); } }); } /** * Stop the language server */ async stop() { if (!this.process) return; try { // Send shutdown request await this.sendRequest(LSPMethods.Shutdown, null); // Send exit notification this.sendNotification(LSPMethods.Exit, null); } catch (error) { // Errors during shutdown are expected and non-critical logger.debug({ err: error, server: this.config.name }, 'LSP shutdown error (non-critical)'); } // Force kill if still running if (this.process && !this.process.killed) { this.process.kill(); } // Clear all pending request timeouts for (const pending of this.pendingRequests.values()) { clearTimeout(pending.timeoutId); } this.process = null; this.initialized = false; this.pendingRequests.clear(); this.openDocuments.clear(); } /** * Check if the server is running and initialized */ isReady() { return this.initialized && this.process !== null && !this.process.killed; } /** * Get server capabilities */ getCapabilities() { return this.serverCapabilities; } /** * Open a text document */ openDocument(uri, languageId, text) { const version = 1; this.openDocuments.set(uri, version); const params = { textDocument: { uri, languageId, version, text, }, }; this.sendNotification(LSPMethods.DidOpen, params); } /** * Update a text document */ updateDocument(uri, text) { const version = (this.openDocuments.get(uri) || 0) + 1; this.openDocuments.set(uri, version); const params = { textDocument: { uri, version }, contentChanges: [{ text }], }; this.sendNotification(LSPMethods.DidChange, params); } /** * Close a text document */ closeDocument(uri) { this.openDocuments.delete(uri); const params = { textDocument: { uri }, }; this.sendNotification(LSPMethods.DidClose, params); } /** * Get completions at a position */ async getCompletions(uri, position) { if (!this.serverCapabilities?.completionProvider) { return []; } const params = { textDocument: { uri }, position, context: { triggerKind: CompletionTriggerKind.Invoked }, }; const result = (await this.sendRequest(LSPMethods.Completion, params)); if (!result) return []; if (Array.isArray(result)) return result; return result.items; } /** * Get code actions (quick fixes, refactorings) */ async getCodeActions(uri, diagnostics, startLine, startChar, endLine, endChar) { if (!this.serverCapabilities?.codeActionProvider) { return []; } const params = { textDocument: { uri }, range: { start: { line: startLine, character: startChar }, end: { line: endLine, character: endChar }, }, context: { diagnostics }, }; const result = (await this.sendRequest(LSPMethods.CodeAction, params)); return result || []; } /** * Format a document */ async formatDocument(uri, options) { if (!this.serverCapabilities?.documentFormattingProvider) { return []; } const params = { textDocument: { uri }, options: { tabSize: options?.tabSize ?? 2, insertSpaces: options?.insertSpaces ?? true, trimTrailingWhitespace: options?.trimTrailingWhitespace ?? true, insertFinalNewline: options?.insertFinalNewline ?? true, trimFinalNewlines: options?.trimFinalNewlines ?? true, }, }; const result = (await this.sendRequest(LSPMethods.Formatting, params)); return result || []; } /** * Request diagnostics for a document (pull model) * Note: Most LSPs use push model via publishDiagnostics notification */ async getDiagnostics(uri) { // Check if server supports pull diagnostics if (this.serverCapabilities?.diagnosticProvider) { try { const result = (await this.sendRequest(LSPMethods.DocumentDiagnostic, { textDocument: { uri }, })); return result?.items || []; } catch (error) { // Fall back to cached diagnostics if pull not supported logger.debug({ err: error, uri }, 'Pull diagnostics not supported, using cached'); return []; } } return []; } // Private methods async initialize() { const params = { processId: process.pid, rootUri: this.config.rootUri || `file://${process.cwd()}`, capabilities: { textDocument: { synchronization: { dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: true, }, completion: { dynamicRegistration: false, completionItem: { snippetSupport: true, commitCharactersSupport: true, documentationFormat: ['markdown', 'plaintext'], deprecatedSupport: true, }, }, hover: { dynamicRegistration: false, contentFormat: ['markdown', 'plaintext'], }, publishDiagnostics: { relatedInformation: true, versionSupport: true, }, codeAction: { dynamicRegistration: false, codeActionLiteralSupport: { codeActionKind: { valueSet: [ 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports', ], }, }, }, formatting: { dynamicRegistration: false, }, }, workspace: { applyEdit: true, workspaceEdit: { documentChanges: true, }, didChangeConfiguration: { dynamicRegistration: false, }, workspaceFolders: true, }, }, workspaceFolders: this.config.rootUri ? [{ uri: this.config.rootUri, name: 'workspace' }] : null, }; const result = (await this.sendRequest(LSPMethods.Initialize, params)); // Send initialized notification this.sendNotification(LSPMethods.Initialized, {}); return result; } sendRequest(method, params) { return new Promise((resolve, reject) => { if (!this.process?.stdin) { reject(new Error('LSP process not running')); return; } const id = ++this.requestId; const request = { jsonrpc: '2.0', id, method, params, }; // Timeout after 30 seconds const timeoutId = setTimeout(() => { if (this.pendingRequests.has(id)) { this.pendingRequests.delete(id); reject(new Error(`LSP request timeout: ${method}`)); } }, 30000); this.pendingRequests.set(id, { resolve, reject, method, timeoutId }); this.send(request); }); } sendNotification(method, params) { if (!this.process?.stdin) return; const notification = { jsonrpc: '2.0', method, params, }; this.send(notification); } send(message) { const content = JSON.stringify(message); const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`; this.process?.stdin?.write(header + content); } handleData(data) { this.buffer += data; while (true) { // Look for Content-Length header const headerEnd = this.buffer.indexOf('\r\n\r\n'); if (headerEnd === -1) break; const header = this.buffer.substring(0, headerEnd); const match = header.match(/Content-Length:\s*(\d+)/i); if (!match) { // Invalid header, skip to next potential header this.buffer = this.buffer.substring(headerEnd + 4); continue; } const contentLength = parseInt(match[1], 10); const contentStart = headerEnd + 4; const contentEnd = contentStart + contentLength; if (this.buffer.length < contentEnd) { // Not enough data yet break; } const content = this.buffer.substring(contentStart, contentEnd); this.buffer = this.buffer.substring(contentEnd); try { const message = JSON.parse(content); this.handleMessage(message); } catch (error) { // Skip malformed JSON messages but log for debugging logger.debug({ err: error, content: content.substring(0, 100) }, 'Malformed JSON-RPC message'); } } } handleMessage(message) { // Check if it's a response if ('id' in message && message.id !== null) { const pending = this.pendingRequests.get(message.id); if (pending) { clearTimeout(pending.timeoutId); this.pendingRequests.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message)); } else { pending.resolve(message.result); } } return; } // It's a notification if ('method' in message) { this.handleNotification(message); } } handleNotification(notification) { switch (notification.method) { case LSPMethods.PublishDiagnostics: this.emit('diagnostics', notification.params); break; // Handle other notifications as needed } } } //# sourceMappingURL=lsp-client.js.map