// Minimal LSP client over stdio (JSON-RPC 2.0 with Content-Length framing). import { spawn } from 'node:child_process' import { EventEmitter } from 'node:events' import fs from 'node:fs' import path from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' export const toUri = (p) => pathToFileURL(p).href export const fromUri = (u) => { try { return fileURLToPath(u) } catch { return u } } export class LspClient extends EventEmitter { constructor({ id, command, args = [], root, env, initializationOptions, settings, languageIdFor }) { super() this.id = id this.command = command this.args = args this.root = root this.env = env this.initializationOptions = initializationOptions this.settings = settings this.languageIdFor = languageIdFor this.seq = 0 this.pending = new Map() this.buffer = Buffer.alloc(0) this.docs = new Map() // uri -> { version, text } this.diagnostics = new Map() // uri -> { version, items, at } this.capabilities = null this.state = 'new' this.stderr = '' this.lastUsed = Date.now() } async start(timeoutMs = 30000) { if (this.state !== 'new') return this.ready this.state = 'starting' this.ready = (async () => { this.proc = spawn(this.command, this.args, { cwd: this.root, env: { ...process.env, ...this.env }, stdio: ['pipe', 'pipe', 'pipe'] }) this.proc.on('error', (e) => this.fail(e)) this.proc.on('exit', (code, sig) => this.fail(new Error(`${this.id} exited (${sig ?? code})${this.stderr ? `: ${this.stderr.slice(-400)}` : ''}`))) this.proc.stdout.on('data', (d) => this.onData(d)) this.proc.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-4000) }) this.proc.stdin.on('error', () => {}) const res = await this.request('initialize', { processId: process.pid, rootUri: toUri(this.root), rootPath: this.root, workspaceFolders: [{ uri: toUri(this.root), name: path.basename(this.root) }], initializationOptions: this.initializationOptions, capabilities: { general: { positionEncodings: ['utf-16'] }, workspace: { workspaceFolders: true, configuration: true, applyEdit: true, workspaceEdit: { documentChanges: true }, symbol: {}, didChangeWatchedFiles: { dynamicRegistration: false } }, textDocument: { synchronization: { didSave: true, dynamicRegistration: false }, publishDiagnostics: { relatedInformation: true, versionSupport: true, codeDescriptionSupport: true }, diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, hover: { contentFormat: ['markdown', 'plaintext'] }, definition: { linkSupport: true }, typeDefinition: { linkSupport: true }, implementation: { linkSupport: true }, references: {}, documentSymbol: { hierarchicalDocumentSymbolSupport: true }, rename: { prepareSupport: true }, codeAction: { codeActionLiteralSupport: { codeActionKind: { valueSet: ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports', 'source.fixAll'] } }, resolveSupport: { properties: ['edit'] } }, }, }, }, timeoutMs) this.capabilities = res?.capabilities ?? {} this.notify('initialized', {}) if (this.settings) this.notify('workspace/didChangeConfiguration', { settings: this.settings }) this.state = 'ready' return this })() try { return await this.ready } catch (e) { this.fail(e); throw e } } fail(err) { if (this.state === 'dead') return this.state = 'dead' this.error = err for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(err) } this.pending.clear() try { this.proc?.kill() } catch {} this.emit('dead', err) } onData(chunk) { this.buffer = Buffer.concat([this.buffer, chunk]) for (;;) { const sep = this.buffer.indexOf('\r\n\r\n') if (sep < 0) return const header = this.buffer.slice(0, sep).toString('ascii') const m = /Content-Length:\s*(\d+)/i.exec(header) if (!m) { this.buffer = this.buffer.slice(sep + 4); continue } const len = Number(m[1]) if (this.buffer.length < sep + 4 + len) return const body = this.buffer.slice(sep + 4, sep + 4 + len).toString('utf8') this.buffer = this.buffer.slice(sep + 4 + len) let msg try { msg = JSON.parse(body) } catch { continue } this.onMessage(msg) } } onMessage(msg) { if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined) && !msg.method) { const p = this.pending.get(msg.id) if (!p) return this.pending.delete(msg.id) clearTimeout(p.timer) if (msg.error) p.reject(Object.assign(new Error(msg.error.message || 'LSP error'), { code: msg.error.code })) else p.resolve(msg.result) return } if (msg.method && msg.id !== undefined) return this.onServerRequest(msg) if (msg.method === 'textDocument/publishDiagnostics') { const { uri, diagnostics = [], version } = msg.params || {} this.diagnostics.set(uri, { version, items: diagnostics, at: Date.now() }) this.emit('diagnostics', uri) } } onServerRequest(msg) { const reply = (result) => this.send({ jsonrpc: '2.0', id: msg.id, result }) switch (msg.method) { case 'workspace/configuration': return reply((msg.params?.items || []).map((it) => pick(this.settings, it.section))) case 'workspace/workspaceFolders': return reply([{ uri: toUri(this.root), name: path.basename(this.root) }]) case 'client/registerCapability': case 'client/unregisterCapability': case 'window/workDoneProgress/create': return reply(null) case 'workspace/applyEdit': // Edits the server asks for are applied by the tool layer, which owns fs policy. this.emit('applyEdit', msg.params) return reply({ applied: false, failureReason: 'apply edits through the lsp tool' }) default: return reply(null) } } send(obj) { if (!this.proc?.stdin?.writable) return const s = JSON.stringify(obj) this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(s, 'utf8')}\r\n\r\n${s}`) } request(method, params, timeoutMs = 20000) { if (this.state === 'dead') return Promise.reject(this.error || new Error(`${this.id} is not running`)) this.lastUsed = Date.now() const id = ++this.seq return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id) this.send({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }) reject(new Error(`${this.id}: ${method} timed out after ${timeoutMs}ms`)) }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) this.send({ jsonrpc: '2.0', id, method, params }) }) } notify(method, params) { this.lastUsed = Date.now() this.send({ jsonrpc: '2.0', method, params }) } /** Open or update a document from disk (full sync). Returns the uri and version. */ sync(file) { const uri = toUri(file) let text try { text = fs.readFileSync(file, 'utf8') } catch { text = null } const doc = this.docs.get(uri) if (text === null) { if (doc) { this.notify('textDocument/didClose', { textDocument: { uri } }); this.docs.delete(uri) } return { uri, version: null, missing: true } } if (!doc) { const d = { version: 1, text } this.docs.set(uri, d) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.languageIdFor(file), version: 1, text } }) return { uri, version: 1, changed: true } } if (doc.text === text) return { uri, version: doc.version, changed: false } doc.version++ doc.text = text this.notify('textDocument/didChange', { textDocument: { uri, version: doc.version }, contentChanges: [{ text }] }) this.notify('textDocument/didSave', { textDocument: { uri }, text }) return { uri, version: doc.version, changed: true } } /** * Fresh diagnostics for a synced document: pull when supported, otherwise wait for a * publish newer than the sync (matching version when the server reports one), then let * it settle briefly because many servers publish twice (syntax, then semantic). */ async diagnosticsFor(file, { budgetMs = 4000, settleMs = 350 } = {}) { const startedAt = Date.now() const { uri, version, missing } = this.sync(file) if (missing) return { uri, items: [], fresh: true } if (this.capabilities?.diagnosticProvider) { try { const r = await this.request('textDocument/diagnostic', { textDocument: { uri } }, budgetMs) if (r?.kind === 'full') return { uri, items: r.items || [], fresh: true } if (r?.kind === 'unchanged') return { uri, items: this.diagnostics.get(uri)?.items ?? [], fresh: true } } catch { /* fall back to push */ } } const deadline = startedAt + budgetMs const isFresh = () => { const d = this.diagnostics.get(uri) if (!d) return false if (d.version != null && version != null) return d.version >= version return d.at >= startedAt } await new Promise((resolve) => { let settle = null const done = () => { clearTimeout(timer); clearTimeout(settle); this.off('diagnostics', on); resolve() } const on = (u) => { if (u !== uri || !isFresh()) return clearTimeout(settle) settle = setTimeout(done, settleMs) } const timer = setTimeout(done, Math.max(0, deadline - Date.now())) this.on('diagnostics', on) if (isFresh()) on(uri) }) return { uri, items: this.diagnostics.get(uri)?.items ?? [], fresh: isFresh() } } async stop() { if (this.state === 'dead') return try { await this.request('shutdown', null, 3000) } catch {} this.notify('exit', null) setTimeout(() => { try { this.proc?.kill() } catch {} }, 500) this.state = 'dead' } } function pick(obj, section) { if (!section || !obj) return obj ?? null let cur = obj for (const k of String(section).split('.')) { if (cur == null) return null; cur = cur[k] } return cur ?? null }