149 lines
7.1 KiB
JavaScript
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 }
|
|
}
|