1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
|
import { extname } from "node:path";
import { createLspClient, type Diagnostic, type LspClient } from "./client.js";
import type { ResolvedLspServer } from "./server.js";
/**
* Process-wide owner of LSP client lifecycles.
*
* Clients are keyed by `root + serverID` and spawned lazily on the first file
* that matches a server's extensions, then reused. Concurrent spawns for the
* same key are de-duplicated via an in-flight map, and servers that fail to
* start are remembered in `broken` so we don't spawn-spam. Modeled on
* opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery.
*
* The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a
* tab's working-directory config (`resolveServersFromConfig`) and pass them in
* alongside the `root`. This keeps per-working-directory config out of the
* manager while letting it own all the long-lived processes for the process.
*/
export class LspManager {
private clients = new Map<string, LspClient>();
private spawning = new Map<string, Promise<LspClient | undefined>>();
private broken = new Set<string>();
private key(root: string, serverID: string): string {
return `${root}\u0000${serverID}`;
}
private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] {
const extension = extname(file) || file;
return servers.filter(
(server) => server.extensions.length === 0 || server.extensions.includes(extension),
);
}
/**
* True if any provided server is configured to attach to this file's
* extension (regardless of whether it has spawned yet). Used to decide
* whether an LSP operation is even applicable to a file.
*/
hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean {
return this.serversForFile(file, servers).length > 0;
}
/**
* Get (spawning if needed) all clients that should attach to `file` at
* `root`. Spawn failures are swallowed (logged via `broken`) and simply
* yield fewer clients — callers degrade gracefully to "no diagnostics".
*/
async getClients(input: {
file: string;
root: string;
servers: ResolvedLspServer[];
}): Promise<LspClient[]> {
const { file, root, servers } = input;
const matching = this.serversForFile(file, servers);
const result: LspClient[] = [];
for (const server of matching) {
const key = this.key(root, server.id);
if (this.broken.has(key)) continue;
const existing = this.clients.get(key);
if (existing) {
result.push(existing);
continue;
}
const inflight = this.spawning.get(key);
if (inflight) {
const client = await inflight;
if (client) result.push(client);
continue;
}
const task = this.spawn(server, root, key);
this.spawning.set(key, task);
task.finally(() => {
if (this.spawning.get(key) === task) this.spawning.delete(key);
});
const client = await task;
if (client) result.push(client);
}
return result;
}
private async spawn(
server: ResolvedLspServer,
root: string,
key: string,
): Promise<LspClient | undefined> {
let handle: ReturnType<ResolvedLspServer["spawn"]>;
try {
handle = server.spawn(root);
} catch (err) {
this.broken.add(key);
console.warn(
`dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
// A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH)
// emits `error` on the child process; mark broken so we don't retry it.
handle.process.on("error", (err) => {
this.broken.add(key);
console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`);
});
try {
const client = await createLspClient({
serverID: server.id,
server: handle,
root,
directory: root,
});
// A racing caller may have created the same client; prefer the
// existing one and discard ours.
const existing = this.clients.get(key);
if (existing) {
await client.shutdown();
return existing;
}
this.clients.set(key, client);
return client;
} catch (err) {
this.broken.add(key);
try {
handle.process.kill();
} catch {
/* already dead */
}
console.warn(
`dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
}
/**
* Open/sync a file with its clients and (optionally) wait for diagnostics
* to settle. `mode: "document"` waits for the file's own diagnostics;
* `"full"` also waits on workspace diagnostics; omitted just syncs.
*/
async touchFile(input: {
file: string;
root: string;
servers: ResolvedLspServer[];
mode?: "document" | "full";
}): Promise<void> {
const clients = await this.getClients(input);
await Promise.all(
clients.map(async (client) => {
const after = Date.now();
const version = await client.notifyOpen(input.file);
if (!input.mode) return;
await client.waitForDiagnostics({
path: input.file,
version,
mode: input.mode,
after,
});
}),
).catch((err) => {
console.warn(
`dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`,
);
});
}
/**
* Merged diagnostics for a single file across all of its clients, keyed by
* absolute file path. Includes related-file diagnostics a client surfaced
* (e.g. workspace pulls), so the result map may contain more than `file`.
*/
getDiagnostics(input: {
root: string;
servers: ResolvedLspServer[];
file: string;
}): Record<string, Diagnostic[]> {
const results: Record<string, Diagnostic[]> = {};
const matching = this.serversForFile(input.file, input.servers);
for (const server of matching) {
const client = this.clients.get(this.key(input.root, server.id));
if (!client) continue;
for (const [path, diags] of client.diagnostics.entries()) {
results[path] = (results[path] ?? []).concat(diags);
}
}
return results;
}
/**
* Run a positional LSP request (hover/definition/references/etc.) against
* every client for the file and flatten the (non-null) results. `line`/
* `character` are 0-based here — the caller converts from editor 1-based.
*/
async request(input: {
file: string;
root: string;
servers: ResolvedLspServer[];
method: string;
params: Record<string, unknown>;
}): Promise<unknown[]> {
const clients = await this.getClients(input);
const results = await Promise.all(
clients.map((client) => client.request(input.method, input.params)),
);
return results.filter((r) => r !== null && r !== undefined);
}
/** Shut down every live client and clear all state. */
async shutdownAll(): Promise<void> {
const clients = [...this.clients.values()];
this.clients.clear();
this.spawning.clear();
this.broken.clear();
await Promise.all(clients.map((client) => client.shutdown().catch(() => {})));
}
}
|