Initial commit: omp-lsp DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
17b4a5f433
11 files changed
+1091
No files matched your search
+239
@@ -0,0 +1,239 @@
|
||||
// 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
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// Formatting helpers for diagnostics, locations, symbols, hovers and workspace edits.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fromUri } from './client.js'
|
||||
|
||||
const SEV = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' }
|
||||
|
||||
export function rel(file, cwd) {
|
||||
if (!cwd) return file
|
||||
const r = path.relative(cwd, file)
|
||||
return r && !r.startsWith('..') && !path.isAbsolute(r) ? r : file
|
||||
}
|
||||
|
||||
/** Diagnostics → compact lines; returns {text, counts}. */
|
||||
export function formatDiagnostics(file, items, { cwd, minSeverity = 2, max = 40 } = {}) {
|
||||
const filtered = items.filter((d) => (d.severity ?? 1) <= minSeverity)
|
||||
const counts = { error: 0, warning: 0, info: 0, hint: 0 }
|
||||
for (const d of items) counts[SEV[d.severity ?? 1]]++
|
||||
const sorted = [...filtered].sort((a, b) => (a.severity ?? 1) - (b.severity ?? 1) || a.range.start.line - b.range.start.line)
|
||||
const lines = sorted.slice(0, max).map((d) => {
|
||||
const l = d.range.start.line + 1; const c = d.range.start.character + 1
|
||||
const code = d.code != null ? ` [${typeof d.code === 'object' ? d.code.value : d.code}]` : ''
|
||||
const src = d.source ? ` (${d.source})` : ''
|
||||
return `${rel(file, cwd)}:${l}:${c} ${SEV[d.severity ?? 1]}${code}: ${String(d.message).split('\n')[0]}${src}`
|
||||
})
|
||||
if (sorted.length > max) lines.push(`… ${sorted.length - max} more`)
|
||||
return { text: lines.join('\n'), counts, shown: filtered.length }
|
||||
}
|
||||
|
||||
export function lineText(file, line) {
|
||||
try { return fs.readFileSync(file, 'utf8').split(/\r?\n/)[line] ?? '' } catch { return '' }
|
||||
}
|
||||
|
||||
export function formatLocations(locs, { cwd, max = 50 } = {}) {
|
||||
const arr = (Array.isArray(locs) ? locs : locs ? [locs] : []).map((l) => ({ uri: l.uri ?? l.targetUri, range: l.range ?? l.targetSelectionRange ?? l.targetRange }))
|
||||
if (!arr.length) return 'No results.'
|
||||
const out = arr.slice(0, max).map((l) => {
|
||||
const f = fromUri(l.uri); const ln = l.range.start.line
|
||||
return `${rel(f, cwd)}:${ln + 1}:${l.range.start.character + 1}: ${lineText(f, ln).trim()}`
|
||||
})
|
||||
if (arr.length > max) out.push(`… ${arr.length - max} more`)
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
const KIND = ['', 'file', 'module', 'namespace', 'package', 'class', 'method', 'property', 'field', 'constructor', 'enum', 'interface', 'function', 'variable', 'constant', 'string', 'number', 'boolean', 'array', 'object', 'key', 'null', 'enum-member', 'struct', 'event', 'operator', 'type-param']
|
||||
export function formatSymbols(syms, { cwd, depth = 0, max = 200 } = {}) {
|
||||
const out = []
|
||||
const walk = (list, d) => {
|
||||
for (const s of list || []) {
|
||||
if (out.length >= max) return
|
||||
const range = s.selectionRange ?? s.range ?? s.location?.range
|
||||
const where = s.location ? `${rel(fromUri(s.location.uri), cwd)}:` : ''
|
||||
out.push(`${' '.repeat(d)}${KIND[s.kind] ?? 'symbol'} ${s.name}${s.detail ? ` ${s.detail}` : ''} — ${where}${range ? range.start.line + 1 : '?'}`)
|
||||
if (s.children) walk(s.children, d + 1)
|
||||
}
|
||||
}
|
||||
walk(syms, depth)
|
||||
return out.length ? out.join('\n') : 'No symbols.'
|
||||
}
|
||||
|
||||
export function formatHover(h) {
|
||||
if (!h?.contents) return 'No hover information.'
|
||||
const c = h.contents
|
||||
const part = (x) => (typeof x === 'string' ? x : x.value ?? '')
|
||||
return (Array.isArray(c) ? c.map(part) : [part(c)]).filter(Boolean).join('\n\n') || 'No hover information.'
|
||||
}
|
||||
|
||||
/** WorkspaceEdit → [{file, edits:[{range,newText}]}] plus file ops. */
|
||||
export function normalizeWorkspaceEdit(we) {
|
||||
const files = new Map(); const ops = []
|
||||
const add = (uri, edits) => { const f = fromUri(uri); files.set(f, [...(files.get(f) || []), ...edits]) }
|
||||
for (const [uri, edits] of Object.entries(we?.changes || {})) add(uri, edits)
|
||||
for (const dc of we?.documentChanges || []) {
|
||||
if (dc.textDocument) add(dc.textDocument.uri, dc.edits)
|
||||
else if (dc.kind) ops.push(dc)
|
||||
}
|
||||
return { files, ops }
|
||||
}
|
||||
|
||||
/** Apply LSP TextEdits (utf-16 positions) to text. */
|
||||
export function applyTextEdits(text, edits) {
|
||||
const lineStarts = [0]
|
||||
for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStarts.push(i + 1)
|
||||
const off = (p) => Math.min(text.length, (lineStarts[p.line] ?? text.length) + p.character)
|
||||
const sorted = [...edits].sort((a, b) => off(b.range.start) - off(a.range.start))
|
||||
let out = text
|
||||
for (const e of sorted) out = out.slice(0, off(e.range.start)) + e.newText + out.slice(off(e.range.end))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Column (0-based utf-16) of `symbol` on a line; supports `name#N` occurrence selectors. */
|
||||
export function columnOf(lineStr, symbol) {
|
||||
if (!symbol) return lineStr.search(/\S/) >= 0 ? lineStr.search(/\S/) : 0
|
||||
let name = symbol; let nth = 1
|
||||
const m = /^(.*)#(\d+)$/.exec(symbol)
|
||||
if (m) { name = m[1]; nth = Number(m[2]) }
|
||||
const ident = /^[A-Za-z_$][\w$]*$/.test(name)
|
||||
const find = (hay, needle) => {
|
||||
const re = ident ? new RegExp(`(?<![\\w$])${needle.replace(/[$]/g, '\\$')}(?![\\w$])`, 'g') : new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
|
||||
const hits = []; let x
|
||||
while ((x = re.exec(hay))) { hits.push(x.index); if (!x[0].length) re.lastIndex++ }
|
||||
return hits
|
||||
}
|
||||
let hits = find(lineStr, name)
|
||||
if (!hits.length) hits = find(lineStr.toLowerCase(), name.toLowerCase())
|
||||
if (hits.length < nth) return -1
|
||||
return hits[nth - 1]
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Language server catalog, auto-detection and per-root client pool.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { LspClient } from './client.js'
|
||||
|
||||
/**
|
||||
* Built-in server definitions. `extensions` route files; `roots` are marker files used to find
|
||||
* the project root (nearest ancestor containing one); `command` is looked up on PATH (plus
|
||||
* ~/.local/bin and node_modules/.bin of the root).
|
||||
*/
|
||||
export const BUILTIN_SERVERS = {
|
||||
typescript: {
|
||||
command: 'typescript-language-server', args: ['--stdio'],
|
||||
extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'],
|
||||
roots: ['tsconfig.json', 'jsconfig.json', 'package.json'],
|
||||
languageId: (f) => ({ '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'typescriptreact', '.jsx': 'javascriptreact' }[path.extname(f)] ?? 'javascript'),
|
||||
},
|
||||
pyright: {
|
||||
command: 'pyright-langserver', args: ['--stdio'],
|
||||
extensions: ['.py', '.pyi'],
|
||||
roots: ['pyrightconfig.json', 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'],
|
||||
languageId: () => 'python',
|
||||
settings: { python: { analysis: { autoSearchPaths: true, useLibraryCodeForTypes: true, diagnosticMode: 'openFilesOnly' } } },
|
||||
},
|
||||
gopls: { command: 'gopls', args: [], extensions: ['.go'], roots: ['go.mod', 'go.work'], languageId: () => 'go' },
|
||||
'rust-analyzer': { command: 'rust-analyzer', args: [], extensions: ['.rs'], roots: ['Cargo.toml'], languageId: () => 'rust' },
|
||||
clangd: { command: 'clangd', args: ['--background-index=false'], extensions: ['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh'], roots: ['compile_commands.json', 'CMakeLists.txt', '.clangd'], languageId: (f) => (['.c', '.h'].includes(path.extname(f)) ? 'c' : 'cpp') },
|
||||
bash: { command: 'bash-language-server', args: ['start'], extensions: ['.sh', '.bash', '.zsh'], roots: [], languageId: () => 'shellscript' },
|
||||
json: { command: 'vscode-json-language-server', args: ['--stdio'], extensions: ['.json', '.jsonc'], roots: [], languageId: (f) => (path.extname(f) === '.jsonc' ? 'jsonc' : 'json') },
|
||||
yaml: { command: 'yaml-language-server', args: ['--stdio'], extensions: ['.yaml', '.yml'], roots: [], languageId: () => 'yaml', settings: { yaml: { validate: true, schemaStore: { enable: false } } } },
|
||||
css: { command: 'vscode-css-language-server', args: ['--stdio'], extensions: ['.css', '.scss', '.less'], roots: [], languageId: (f) => path.extname(f).slice(1) },
|
||||
html: { command: 'vscode-html-language-server', args: ['--stdio'], extensions: ['.html', '.htm'], roots: [], languageId: () => 'html' },
|
||||
}
|
||||
|
||||
const whichCache = new Map()
|
||||
export function which(cmd, extraDirs = []) {
|
||||
if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null
|
||||
const key = `${cmd}|${extraDirs.join(':')}`
|
||||
if (whichCache.has(key)) return whichCache.get(key)
|
||||
const dirs = [...extraDirs, ...(process.env.PATH || '').split(path.delimiter), path.join(process.env.HOME || '', '.local/bin')]
|
||||
let found = null
|
||||
for (const d of dirs) {
|
||||
if (!d) continue
|
||||
const p = path.join(d, cmd)
|
||||
try { fs.accessSync(p, fs.constants.X_OK); found = p; break } catch {}
|
||||
}
|
||||
whichCache.set(key, found)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Nearest ancestor of `file` (up to `ceiling`) containing one of `markers`. */
|
||||
export function findRoot(file, markers, ceiling) {
|
||||
let dir = path.dirname(file)
|
||||
const stop = ceiling ? path.resolve(ceiling) : path.parse(dir).root
|
||||
for (;;) {
|
||||
for (const m of markers) if (fs.existsSync(path.join(dir, m))) return dir
|
||||
if (dir === stop || dir === path.dirname(dir)) break
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class ServerPool {
|
||||
/**
|
||||
* @param {object} opts { servers: overrides/extra defs, disabled: string[], idleMs }
|
||||
*/
|
||||
constructor({ servers = {}, disabled = [], idleMs = 10 * 60_000, log = () => {} } = {}) {
|
||||
this.defs = {}
|
||||
for (const [id, def] of Object.entries({ ...BUILTIN_SERVERS, ...servers })) {
|
||||
if (disabled.includes(id) || def === false || def?.enabled === false) continue
|
||||
const base = BUILTIN_SERVERS[id] ?? {}
|
||||
this.defs[id] = { ...base, ...def }
|
||||
if (typeof this.defs[id].languageId === 'string') { const l = this.defs[id].languageId; this.defs[id].languageId = () => l }
|
||||
if (typeof this.defs[id].languageId !== 'function') this.defs[id].languageId = () => id
|
||||
}
|
||||
this.clients = new Map() // `${id}|${root}` -> LspClient
|
||||
this.broken = new Map() // key -> { error, at }
|
||||
this.idleMs = idleMs
|
||||
this.log = log
|
||||
this.reaper = setInterval(() => this.reap(), 60_000)
|
||||
this.reaper.unref?.()
|
||||
}
|
||||
|
||||
/** Server definitions able to handle this file (installed only). */
|
||||
defsFor(file) {
|
||||
const ext = path.extname(file).toLowerCase()
|
||||
return Object.entries(this.defs).filter(([, d]) => (d.extensions || []).includes(ext))
|
||||
}
|
||||
|
||||
resolveCommand(def, root) {
|
||||
return which(def.command, root ? [path.join(root, 'node_modules/.bin')] : [])
|
||||
}
|
||||
|
||||
/** Get (starting if needed) the client for a file. Returns null when no server applies. */
|
||||
async clientFor(file, { cwd } = {}) {
|
||||
for (const [id, def] of this.defsFor(file)) {
|
||||
const root = (def.roots?.length ? findRoot(file, def.roots, null) : null) ?? cwd ?? path.dirname(file)
|
||||
const command = this.resolveCommand(def, root)
|
||||
if (!command) continue
|
||||
const key = `${id}|${root}`
|
||||
const broken = this.broken.get(key)
|
||||
if (broken && Date.now() - broken.at < 60_000) continue
|
||||
let c = this.clients.get(key)
|
||||
if (c && c.state === 'dead') { this.clients.delete(key); c = null }
|
||||
if (!c) {
|
||||
c = new LspClient({ id, command, args: def.args || [], root, env: def.env, initializationOptions: def.initializationOptions, settings: def.settings, languageIdFor: def.languageId })
|
||||
this.clients.set(key, c)
|
||||
c.on('dead', (e) => { this.log(`${id} (${root}) stopped: ${e?.message ?? e}`); this.clients.delete(key) })
|
||||
}
|
||||
try {
|
||||
await c.start(def.startupTimeoutMs ?? 45_000)
|
||||
return c
|
||||
} catch (e) {
|
||||
this.broken.set(key, { error: String(e?.message ?? e), at: Date.now() })
|
||||
this.clients.delete(key)
|
||||
this.log(`${id} failed to start: ${e?.message ?? e}`)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
status() {
|
||||
const servers = Object.entries(this.defs).map(([id, d]) => ({
|
||||
id,
|
||||
command: d.command,
|
||||
installed: !!this.resolveCommand(d),
|
||||
extensions: d.extensions,
|
||||
}))
|
||||
const running = [...this.clients.values()].map((c) => ({ id: c.id, root: c.root, state: c.state, openDocuments: c.docs.size }))
|
||||
const broken = [...this.broken.entries()].map(([k, v]) => ({ server: k, error: v.error }))
|
||||
return { servers, running, broken }
|
||||
}
|
||||
|
||||
reap() {
|
||||
for (const [k, c] of this.clients) if (Date.now() - c.lastUsed > this.idleMs) { c.stop(); this.clients.delete(k) }
|
||||
}
|
||||
|
||||
async stopAll() {
|
||||
clearInterval(this.reaper)
|
||||
await Promise.all([...this.clients.values()].map((c) => c.stop().catch(() => {})))
|
||||
this.clients.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function gitRootOf(dir) {
|
||||
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null }
|
||||
}
|
||||
Reference in new issue
Block a user