Files
omp-lsp/lib/servers.js
T

149 lines
7.1 KiB
JavaScript

// 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 }
}