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