summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/manager.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/lsp/src/manager.ts')
-rw-r--r--packages/lsp/src/manager.ts215
1 files changed, 215 insertions, 0 deletions
diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts
new file mode 100644
index 0000000..7080c65
--- /dev/null
+++ b/packages/lsp/src/manager.ts
@@ -0,0 +1,215 @@
+/**
+ * Manager — lazy-spawn one client per (serverID, root); dedup concurrent
+ * spawns; track a broken set (no retry storm); status(cwd); shutdownAll().
+ */
+
+import { join } from "node:path";
+import {
+ type ClientDeps,
+ type FileWatcher,
+ type FsAccess,
+ LanguageServerClient,
+ type SpawnProcess,
+} from "./client.js";
+import { type ResolvedServer, resolveServers } from "./config.js";
+import { findRoot } from "./root.js";
+import type { LspServerState, LspServerStatus } from "./types.js";
+
+export type Logger = {
+ readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
+};
+
+export interface ManagerDeps {
+ readonly spawn: SpawnProcess;
+ readonly fileWatcher: FileWatcher;
+ readonly fs: FsAccess;
+ readonly logger?: Logger | undefined;
+}
+
+type ClientEntry = {
+ readonly client: LanguageServerClient;
+ readonly server: ResolvedServer;
+ readonly promise: Promise<void>;
+};
+
+export class LspManager {
+ private clients = new Map<string, ClientEntry>();
+ private broken = new Set<string>();
+ private spawning = new Map<string, Promise<void>>();
+ private deps: ManagerDeps;
+
+ constructor(deps: ManagerDeps) {
+ this.deps = deps;
+ }
+
+ async status(cwd: string): Promise<readonly LspServerStatus[]> {
+ // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox
+ // project) gets its own .dispatch/lsp.json or opencode.json, not a global one.
+ const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json"));
+ const opencodeJson = await this.readOrNull(join(cwd, "opencode.json"));
+ const servers = await resolveServers({
+ cwd,
+ dispatchLspJson,
+ opencodeJson,
+ exists: this.deps.fs.exists,
+ });
+
+ const results: LspServerStatus[] = [];
+
+ for (const server of servers) {
+ const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists);
+ const key = `${server.id}:${root}`;
+
+ if (this.broken.has(key)) {
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: "Previously failed to start",
+ };
+ results.push(status);
+ continue;
+ }
+
+ const existing = this.clients.get(key);
+ if (existing) {
+ const state = existing.client.getState();
+ const stateError = existing.client.getStateError();
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = stateError;
+ }
+ results.push(status);
+ continue;
+ }
+
+ try {
+ await this.spawnClient(server, root, key);
+ const entry = this.clients.get(key);
+ if (entry) {
+ const state = entry.client.getState();
+ const stateError = entry.client.getStateError();
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = stateError;
+ }
+ results.push(status);
+ }
+ } catch (err: unknown) {
+ this.broken.add(key);
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: err instanceof Error ? err.message : String(err),
+ };
+ results.push(status);
+ }
+ }
+
+ return results;
+ }
+
+ getClient(serverId: string, root: string): LanguageServerClient | undefined {
+ const key = `${serverId}:${root}`;
+ return this.clients.get(key)?.client;
+ }
+
+ /** Read a config file's contents, or null if it is absent/unreadable. */
+ private async readOrNull(path: string): Promise<string | null> {
+ if (!(await this.deps.fs.exists(path))) return null;
+ try {
+ return await this.deps.fs.readText(path);
+ } catch {
+ return null;
+ }
+ }
+
+ private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const existingSpawn = this.spawning.get(key);
+ if (existingSpawn) return existingSpawn;
+
+ const spawnPromise = this.doSpawn(server, root, key);
+ this.spawning.set(key, spawnPromise);
+
+ try {
+ await spawnPromise;
+ } finally {
+ this.spawning.delete(key);
+ }
+ }
+
+ private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const clientDeps: ClientDeps = {
+ spawn: this.deps.spawn,
+ fileWatcher: this.deps.fileWatcher,
+ fs: this.deps.fs,
+ command: server.command,
+ root,
+ serverId: server.id,
+ };
+ if (server.env) {
+ (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env;
+ }
+ if (server.initialization) {
+ (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization =
+ server.initialization;
+ }
+
+ const client = new LanguageServerClient(clientDeps);
+ const entry: ClientEntry = {
+ client,
+ server,
+ promise: client.start(),
+ };
+
+ this.clients.set(key, entry);
+ await entry.promise;
+
+ if (client.getState() === "error") {
+ this.broken.add(key);
+ this.deps.logger?.warn("LSP server failed to start", {
+ serverId: server.id,
+ root,
+ error: client.getStateError() ?? "unknown",
+ });
+ } else {
+ this.deps.logger?.info("LSP server connected", {
+ serverId: server.id,
+ root,
+ });
+ }
+ }
+
+ shutdownAll(): void {
+ for (const [key, entry] of this.clients) {
+ entry.client.shutdown();
+ this.deps.logger?.info("LSP server shutdown", { key });
+ }
+ this.clients.clear();
+ this.broken.clear();
+ this.spawning.clear();
+ }
+}
+
+function mapState(state: LspServerState): LspServerState {
+ return state;
+}