240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
// 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
|
|
}
|