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