- lsp/ module: types, config, JSON-RPC client, server-manager, operations - lsp_diagnostics: TypeScript/JavaScript diagnostics for a file - lsp_goto_definition: find symbol definition at position - lsp_find_references: find all references to a symbol - Registered as READ_TOOLS in tool index
120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { join } from 'node:path';
|
|
import { existsSync } from 'node:fs';
|
|
import { LspClient } from './client.js';
|
|
import { getServerConfig } from './config.js';
|
|
|
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const SWEEP_INTERVAL_MS = 30_000;
|
|
|
|
interface LspInstance {
|
|
client: LspClient;
|
|
proc: ChildProcess;
|
|
lastUsed: number;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export class LspServerManager {
|
|
private instances = new Map<string, LspInstance>();
|
|
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor() {
|
|
this.startSweeper();
|
|
}
|
|
|
|
private startSweeper(): void {
|
|
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
this.sweepTimer.unref?.();
|
|
}
|
|
|
|
private findProjectRoot(filePath: string): string | null {
|
|
let dir = filePath;
|
|
const config = getServerConfig(filePath);
|
|
if (!config) return null;
|
|
while (true) {
|
|
for (const pattern of config.rootPatterns) {
|
|
if (existsSync(join(dir, pattern))) return dir;
|
|
}
|
|
const parent = join(dir, '..');
|
|
if (parent === dir) return dir;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
async getClient(filePath: string): Promise<LspClient | null> {
|
|
const config = getServerConfig(filePath);
|
|
if (!config) return null;
|
|
const projectRoot = this.findProjectRoot(filePath);
|
|
if (!projectRoot) return null;
|
|
|
|
const existing = this.instances.get(projectRoot);
|
|
if (existing) {
|
|
existing.lastUsed = Date.now();
|
|
clearTimeout(existing.timer);
|
|
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
|
existing.timer.unref?.();
|
|
return existing.client;
|
|
}
|
|
|
|
return this.spawn(projectRoot, config.command, config.args);
|
|
}
|
|
|
|
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
|
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
|
const client = new LspClient(proc.stdin!, proc.stdout!);
|
|
|
|
await client.request('initialize', {
|
|
processId: process.pid,
|
|
rootUri: `file://${projectRoot}`,
|
|
capabilities: {
|
|
textDocument: {
|
|
diagnostic: { dynamicRegistration: false },
|
|
definition: { dynamicRegistration: false },
|
|
references: { dynamicRegistration: false },
|
|
},
|
|
},
|
|
});
|
|
await client.notify('initialized', {});
|
|
|
|
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
|
timer.unref?.();
|
|
|
|
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
|
proc.on('exit', () => this.instances.delete(projectRoot));
|
|
|
|
return client;
|
|
}
|
|
|
|
private kill(projectRoot: string): void {
|
|
const inst = this.instances.get(projectRoot);
|
|
if (!inst) return;
|
|
this.instances.delete(projectRoot);
|
|
inst.proc.kill('SIGTERM');
|
|
setTimeout(() => {
|
|
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
|
}, 5000);
|
|
}
|
|
|
|
private sweep(): void {
|
|
const now = Date.now();
|
|
for (const [root, inst] of this.instances) {
|
|
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
|
this.kill(root);
|
|
}
|
|
}
|
|
}
|
|
|
|
shutdown(): void {
|
|
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
for (const root of [...this.instances.keys()]) {
|
|
this.kill(root);
|
|
}
|
|
}
|
|
|
|
getActiveCount(): number {
|
|
return this.instances.size;
|
|
}
|
|
}
|
|
|
|
export const lspManager = new LspServerManager();
|