pi-lsp-adapter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/package.json +79 -0
- package/src/commands/registerCommands.ts +228 -0
- package/src/commands/status.ts +79 -0
- package/src/config/loadConfig.ts +377 -0
- package/src/config/paths.ts +58 -0
- package/src/config/trust.ts +85 -0
- package/src/detect/filetypes.ts +86 -0
- package/src/detect/root.ts +32 -0
- package/src/index.ts +132 -0
- package/src/install/installers.ts +513 -0
- package/src/install/lockfile.ts +159 -0
- package/src/install/manager.ts +177 -0
- package/src/install/version.ts +40 -0
- package/src/lsp/client.ts +465 -0
- package/src/lsp/processRegistry.ts +339 -0
- package/src/lsp/runtimeManager.ts +552 -0
- package/src/registry/builtin.ts +171 -0
- package/src/registry/schema.ts +185 -0
- package/src/resolve/languages.ts +96 -0
- package/src/resolve/resolveServer.ts +315 -0
- package/src/state.ts +20 -0
- package/src/statusLine.ts +21 -0
- package/src/tools/lspFormat.ts +594 -0
- package/src/tools/registerLspTools.ts +241 -0
- package/src/tools/registerLspWarmup.ts +25 -0
- package/src/tools/resultCache.ts +162 -0
- package/src/ui/lspPanel.ts +123 -0
- package/src/util/deepMerge.ts +38 -0
- package/src/util/errors.ts +22 -0
- package/src/util/hash.ts +10 -0
- package/src/util/helpers.ts +41 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { delay } from "../util/helpers.js";
|
|
4
|
+
import type {
|
|
5
|
+
Definition,
|
|
6
|
+
DocumentSymbol,
|
|
7
|
+
Hover,
|
|
8
|
+
Location,
|
|
9
|
+
LocationLink,
|
|
10
|
+
SymbolInformation,
|
|
11
|
+
WorkspaceSymbol,
|
|
12
|
+
} from "vscode-languageserver-protocol";
|
|
13
|
+
import { detectFiletype } from "../detect/filetypes.js";
|
|
14
|
+
import { detectRoot } from "../detect/root.js";
|
|
15
|
+
import type { LoadLspConfigResult } from "../config/loadConfig.js";
|
|
16
|
+
import type { LspInstallManager } from "../install/manager.js";
|
|
17
|
+
import { readLockfile, type LockfileOptions } from "../install/lockfile.js";
|
|
18
|
+
import type { InstalledServerMetadata, ServerDefinition } from "../registry/schema.js";
|
|
19
|
+
import { resolveServerConfig } from "../resolve/resolveServer.js";
|
|
20
|
+
import type { LspProcessRegistry } from "./processRegistry.js";
|
|
21
|
+
import { LspClient, type LspConnectionFactory, type LspDiagnosticsResult, type LspServerSpawner } from "./client.js";
|
|
22
|
+
|
|
23
|
+
export interface LspRuntimeManagerOptions {
|
|
24
|
+
cwd: string;
|
|
25
|
+
ownerId: string;
|
|
26
|
+
config: LoadLspConfigResult;
|
|
27
|
+
installManager: LspInstallManager;
|
|
28
|
+
processRegistry: LspProcessRegistry;
|
|
29
|
+
spawner?: LspServerSpawner;
|
|
30
|
+
connectionFactory?: LspConnectionFactory;
|
|
31
|
+
lockfileOptions?: LockfileOptions;
|
|
32
|
+
requestTimeoutMs?: number;
|
|
33
|
+
diagnosticsWaitMs?: number;
|
|
34
|
+
shutdownGraceMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LspStartStatus = "started" | "already-running" | "missing" | "declined" | "error";
|
|
38
|
+
|
|
39
|
+
export interface LspStartResult {
|
|
40
|
+
serverId: string;
|
|
41
|
+
rootDir: string;
|
|
42
|
+
status: LspStartStatus;
|
|
43
|
+
message: string;
|
|
44
|
+
installedNow?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LspRuntimeFileResult<T> {
|
|
48
|
+
serverId: string;
|
|
49
|
+
rootDir: string;
|
|
50
|
+
filePath: string;
|
|
51
|
+
uri: string;
|
|
52
|
+
result: T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LspWorkspaceSymbolsResult {
|
|
56
|
+
serverId: string;
|
|
57
|
+
rootDir: string;
|
|
58
|
+
result: SymbolInformation[] | WorkspaceSymbol[] | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SelectedServer {
|
|
62
|
+
server: ServerDefinition;
|
|
63
|
+
rootDir: string;
|
|
64
|
+
rootMarker?: string;
|
|
65
|
+
filetype: string;
|
|
66
|
+
filePath: string;
|
|
67
|
+
text: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ClientTarget {
|
|
71
|
+
client: LspClient;
|
|
72
|
+
serverId: string;
|
|
73
|
+
rootDir: string;
|
|
74
|
+
started: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ClientShutdownResult {
|
|
78
|
+
client: LspClient;
|
|
79
|
+
stopped: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ClientStartOptions {
|
|
83
|
+
allowPromptInstall: boolean;
|
|
84
|
+
allowAutoInstall?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface EnsureClientInput {
|
|
88
|
+
server: ServerDefinition;
|
|
89
|
+
rootDir: string;
|
|
90
|
+
rootMarker?: string;
|
|
91
|
+
allowPromptInstall: boolean;
|
|
92
|
+
allowAutoInstall?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface StartClientInput extends EnsureClientInput {
|
|
96
|
+
key: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class LspRuntimeError extends Error {
|
|
100
|
+
constructor(
|
|
101
|
+
message: string,
|
|
102
|
+
readonly code:
|
|
103
|
+
| "no-filetype"
|
|
104
|
+
| "no-server"
|
|
105
|
+
| "not-installed"
|
|
106
|
+
| "declined"
|
|
107
|
+
| "start-failed"
|
|
108
|
+
| "outside-workspace"
|
|
109
|
+
| "invalid-position",
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "LspRuntimeError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class LspRuntimeManager {
|
|
117
|
+
private readonly cwd: string;
|
|
118
|
+
private readonly ownerId: string;
|
|
119
|
+
private readonly config: LoadLspConfigResult;
|
|
120
|
+
private readonly installManager: LspInstallManager;
|
|
121
|
+
private readonly processRegistry: LspProcessRegistry;
|
|
122
|
+
private readonly spawner?: LspServerSpawner;
|
|
123
|
+
private readonly connectionFactory?: LspConnectionFactory;
|
|
124
|
+
private readonly lockfileOptions: LockfileOptions;
|
|
125
|
+
private readonly requestTimeoutMs: number;
|
|
126
|
+
private readonly diagnosticsWaitMs: number;
|
|
127
|
+
private readonly shutdownGraceMs: number;
|
|
128
|
+
private readonly clients = new Map<string, LspClient>();
|
|
129
|
+
private readonly starting = new Map<string, Promise<ClientTarget>>();
|
|
130
|
+
private readonly filetypeCache = new Map<string, string>();
|
|
131
|
+
|
|
132
|
+
constructor(options: LspRuntimeManagerOptions) {
|
|
133
|
+
this.cwd = options.cwd;
|
|
134
|
+
this.ownerId = options.ownerId;
|
|
135
|
+
this.config = options.config;
|
|
136
|
+
this.installManager = options.installManager;
|
|
137
|
+
this.processRegistry = options.processRegistry;
|
|
138
|
+
this.spawner = options.spawner;
|
|
139
|
+
this.connectionFactory = options.connectionFactory;
|
|
140
|
+
this.lockfileOptions = options.lockfileOptions ?? {};
|
|
141
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
|
|
142
|
+
this.diagnosticsWaitMs = options.diagnosticsWaitMs ?? 350;
|
|
143
|
+
this.shutdownGraceMs = options.shutdownGraceMs ?? 1_000;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async startServer(
|
|
147
|
+
serverId?: string,
|
|
148
|
+
options: ClientStartOptions = { allowPromptInstall: false },
|
|
149
|
+
): Promise<LspStartResult[]> {
|
|
150
|
+
const targets = serverId ? [serverId] : await this.installedServerIds();
|
|
151
|
+
if (targets.length === 0) return [];
|
|
152
|
+
|
|
153
|
+
const results: LspStartResult[] = [];
|
|
154
|
+
for (const targetId of targets) {
|
|
155
|
+
results.push(await this.startServerAtRoot(targetId, this.cwd, options));
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async restartServer(
|
|
161
|
+
serverId?: string,
|
|
162
|
+
options: ClientStartOptions = { allowPromptInstall: false },
|
|
163
|
+
): Promise<LspStartResult[]> {
|
|
164
|
+
const targetIds = new Set(serverId ? [serverId] : [...this.clients.values()].map((client) => client.serverId));
|
|
165
|
+
if (!serverId && targetIds.size === 0) {
|
|
166
|
+
return this.startServer(undefined, options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stopped = await this.shutdownClients((client) => targetIds.has(client.serverId));
|
|
170
|
+
const failed = stopped.filter((entry) => !entry.stopped);
|
|
171
|
+
if (failed.length > 0) {
|
|
172
|
+
return failed.map(({ client }) => ({
|
|
173
|
+
serverId: client.serverId,
|
|
174
|
+
rootDir: client.rootDir,
|
|
175
|
+
status: "error",
|
|
176
|
+
message: `Could not restart ${client.serverId} for ${client.rootDir}; old process did not exit.`,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return this.startServer(serverId, options);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async shutdown(): Promise<void> {
|
|
184
|
+
this.filetypeCache.clear();
|
|
185
|
+
await this.shutdownClients(() => true);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async stopServer(serverId?: string): Promise<number> {
|
|
189
|
+
const stopped = await this.shutdownClients((client) => !serverId || client.serverId === serverId);
|
|
190
|
+
return stopped.filter((entry) => entry.stopped).length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async warmupFile(filePath: string): Promise<boolean> {
|
|
194
|
+
try {
|
|
195
|
+
const selected = await this.selectServerForFile(filePath);
|
|
196
|
+
const target = await this.ensureClient({
|
|
197
|
+
server: selected.server,
|
|
198
|
+
rootDir: selected.rootDir,
|
|
199
|
+
rootMarker: selected.rootMarker,
|
|
200
|
+
allowPromptInstall: false,
|
|
201
|
+
allowAutoInstall: false,
|
|
202
|
+
});
|
|
203
|
+
await target.client.syncFile(selected.filePath, selected.filetype, selected.text);
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async diagnostics(filePath: string): Promise<LspDiagnosticsResult> {
|
|
211
|
+
const target = await this.prepareFileTarget(filePath);
|
|
212
|
+
await delay(this.diagnosticsWaitMs);
|
|
213
|
+
return {
|
|
214
|
+
serverId: target.client.serverId,
|
|
215
|
+
rootDir: target.client.rootDir,
|
|
216
|
+
filePath: target.filePath,
|
|
217
|
+
uri: target.uri,
|
|
218
|
+
diagnostics: target.client.getDiagnostics(target.uri),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async hover(filePath: string, line: number, character: number): Promise<LspRuntimeFileResult<Hover | null>> {
|
|
223
|
+
const target = await this.prepareFilePositionTarget(filePath, line, character);
|
|
224
|
+
return {
|
|
225
|
+
serverId: target.client.serverId,
|
|
226
|
+
rootDir: target.client.rootDir,
|
|
227
|
+
filePath: target.filePath,
|
|
228
|
+
uri: target.uri,
|
|
229
|
+
result: await target.client.hover(target.uri, line, character),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async definition(
|
|
234
|
+
filePath: string,
|
|
235
|
+
line: number,
|
|
236
|
+
character: number,
|
|
237
|
+
): Promise<LspRuntimeFileResult<Definition | LocationLink[] | null>> {
|
|
238
|
+
const target = await this.prepareFilePositionTarget(filePath, line, character);
|
|
239
|
+
return {
|
|
240
|
+
serverId: target.client.serverId,
|
|
241
|
+
rootDir: target.client.rootDir,
|
|
242
|
+
filePath: target.filePath,
|
|
243
|
+
uri: target.uri,
|
|
244
|
+
result: await target.client.definition(target.uri, line, character),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async references(
|
|
249
|
+
filePath: string,
|
|
250
|
+
line: number,
|
|
251
|
+
character: number,
|
|
252
|
+
includeDeclaration = false,
|
|
253
|
+
): Promise<LspRuntimeFileResult<Location[] | null>> {
|
|
254
|
+
const target = await this.prepareFilePositionTarget(filePath, line, character);
|
|
255
|
+
return {
|
|
256
|
+
serverId: target.client.serverId,
|
|
257
|
+
rootDir: target.client.rootDir,
|
|
258
|
+
filePath: target.filePath,
|
|
259
|
+
uri: target.uri,
|
|
260
|
+
result: await target.client.references(target.uri, line, character, includeDeclaration),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async documentSymbols(
|
|
265
|
+
filePath: string,
|
|
266
|
+
): Promise<LspRuntimeFileResult<DocumentSymbol[] | SymbolInformation[] | null>> {
|
|
267
|
+
const target = await this.prepareFileTarget(filePath);
|
|
268
|
+
return {
|
|
269
|
+
serverId: target.client.serverId,
|
|
270
|
+
rootDir: target.client.rootDir,
|
|
271
|
+
filePath: target.filePath,
|
|
272
|
+
uri: target.uri,
|
|
273
|
+
result: await target.client.documentSymbols(target.uri),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async workspaceSymbols(
|
|
278
|
+
query: string,
|
|
279
|
+
serverId?: string,
|
|
280
|
+
options: ClientStartOptions = { allowPromptInstall: false },
|
|
281
|
+
): Promise<LspWorkspaceSymbolsResult[]> {
|
|
282
|
+
const clients = serverId
|
|
283
|
+
? [
|
|
284
|
+
await this.ensureClient({
|
|
285
|
+
server: this.getServer(serverId),
|
|
286
|
+
rootDir: this.cwd,
|
|
287
|
+
allowPromptInstall: options.allowPromptInstall,
|
|
288
|
+
}),
|
|
289
|
+
]
|
|
290
|
+
: this.workspaceSymbolClients();
|
|
291
|
+
const results: LspWorkspaceSymbolsResult[] = [];
|
|
292
|
+
for (const target of clients) {
|
|
293
|
+
results.push({
|
|
294
|
+
serverId: target.client.serverId,
|
|
295
|
+
rootDir: target.client.rootDir,
|
|
296
|
+
result: await target.client.workspaceSymbols(query),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return results;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
activeClients(): Array<{ id: string; serverId: string; rootDir: string }> {
|
|
303
|
+
const active: Array<{ id: string; serverId: string; rootDir: string }> = [];
|
|
304
|
+
for (const client of this.clients.values()) {
|
|
305
|
+
if (!client.isExited) active.push({ id: client.id, serverId: client.serverId, rootDir: client.rootDir });
|
|
306
|
+
}
|
|
307
|
+
return active;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private workspaceSymbolClients(): ClientTarget[] {
|
|
311
|
+
const active: ClientTarget[] = [];
|
|
312
|
+
for (const client of this.clients.values()) {
|
|
313
|
+
if (!client.isExited) active.push({ client, serverId: client.serverId, rootDir: client.rootDir, started: false });
|
|
314
|
+
}
|
|
315
|
+
return active;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async prepareFileTarget(filePath: string): Promise<SelectedServer & { client: LspClient; uri: string }> {
|
|
319
|
+
const selected = await this.selectServerForFile(filePath);
|
|
320
|
+
return this.attachClient(selected);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async prepareFilePositionTarget(
|
|
324
|
+
filePath: string,
|
|
325
|
+
line: number,
|
|
326
|
+
character: number,
|
|
327
|
+
): Promise<SelectedServer & { client: LspClient; uri: string }> {
|
|
328
|
+
const selected = await this.selectServerForFile(filePath);
|
|
329
|
+
validatePosition(selected, line, character);
|
|
330
|
+
return this.attachClient(selected);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async attachClient(selected: SelectedServer): Promise<SelectedServer & { client: LspClient; uri: string }> {
|
|
334
|
+
const target = await this.ensureClient({
|
|
335
|
+
server: selected.server,
|
|
336
|
+
rootDir: selected.rootDir,
|
|
337
|
+
rootMarker: selected.rootMarker,
|
|
338
|
+
allowPromptInstall: false,
|
|
339
|
+
});
|
|
340
|
+
const uri = await target.client.syncFile(selected.filePath, selected.filetype, selected.text);
|
|
341
|
+
return { ...selected, client: target.client, uri };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async selectServerForFile(filePath: string): Promise<SelectedServer> {
|
|
345
|
+
const resolvedPath = this.resolvePath(filePath);
|
|
346
|
+
const text = await readFile(resolvedPath, "utf8");
|
|
347
|
+
const cached = this.filetypeCache.get(resolvedPath);
|
|
348
|
+
const filetype = cached ?? detectFiletype({ path: resolvedPath, content: text });
|
|
349
|
+
if (cached === undefined) {
|
|
350
|
+
if (filetype) this.filetypeCache.set(resolvedPath, filetype);
|
|
351
|
+
}
|
|
352
|
+
if (!filetype) {
|
|
353
|
+
throw new LspRuntimeError(`No LSP filetype detected for ${resolvedPath}.`, "no-filetype");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const server = Object.values(this.config.catalog.servers).find((entry) => entry.filetypes.includes(filetype));
|
|
357
|
+
if (!server) {
|
|
358
|
+
throw new LspRuntimeError(
|
|
359
|
+
`No configured LSP server handles filetype ${filetype} for ${resolvedPath}.`,
|
|
360
|
+
"no-server",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const root = await detectRoot(resolvedPath, server.rootMarkers);
|
|
365
|
+
const rootDir = root && isPathInside(this.cwd, root.rootDir) ? root.rootDir : this.cwd;
|
|
366
|
+
return {
|
|
367
|
+
server,
|
|
368
|
+
rootDir,
|
|
369
|
+
rootMarker: rootDir === root?.rootDir ? root.marker : undefined,
|
|
370
|
+
filetype,
|
|
371
|
+
filePath: resolvedPath,
|
|
372
|
+
text,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async startServerAtRoot(
|
|
377
|
+
serverId: string,
|
|
378
|
+
rootDir: string,
|
|
379
|
+
options: ClientStartOptions,
|
|
380
|
+
): Promise<LspStartResult> {
|
|
381
|
+
try {
|
|
382
|
+
const target = await this.ensureClient({
|
|
383
|
+
server: this.getServer(serverId),
|
|
384
|
+
rootDir,
|
|
385
|
+
allowPromptInstall: options.allowPromptInstall,
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
serverId,
|
|
389
|
+
rootDir: target.rootDir,
|
|
390
|
+
status: target.started ? "started" : "already-running",
|
|
391
|
+
message: target.started
|
|
392
|
+
? `Started ${serverId} for ${target.rootDir}.`
|
|
393
|
+
: `${serverId} is already running for ${target.rootDir}.`,
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof LspRuntimeError && (error.code === "not-installed" || error.code === "declined")) {
|
|
397
|
+
return {
|
|
398
|
+
serverId,
|
|
399
|
+
rootDir,
|
|
400
|
+
status: error.code === "not-installed" ? "missing" : "declined",
|
|
401
|
+
message: error.message,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { serverId, rootDir, status: "error", message: error instanceof Error ? error.message : String(error) };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private getServer(serverId: string): ServerDefinition {
|
|
409
|
+
const server = this.config.catalog.servers[serverId];
|
|
410
|
+
if (!server) throw new LspRuntimeError(`Unknown LSP server: ${serverId}.`, "no-server");
|
|
411
|
+
return server;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async ensureClient(input: EnsureClientInput): Promise<ClientTarget> {
|
|
415
|
+
const server = input.server;
|
|
416
|
+
const rootDir = resolve(input.rootDir);
|
|
417
|
+
const key = clientKey(server.id, rootDir);
|
|
418
|
+
const existing = this.clients.get(key);
|
|
419
|
+
if (existing && !existing.isExited) {
|
|
420
|
+
return { client: existing, serverId: server.id, rootDir, started: false };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const inflight = this.starting.get(key);
|
|
424
|
+
if (inflight) return inflight;
|
|
425
|
+
|
|
426
|
+
const start = this.startClient({ ...input, rootDir, key });
|
|
427
|
+
this.starting.set(key, start);
|
|
428
|
+
try {
|
|
429
|
+
return await start;
|
|
430
|
+
} finally {
|
|
431
|
+
this.starting.delete(key);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async startClient(input: StartClientInput): Promise<ClientTarget> {
|
|
436
|
+
const install = await this.ensureInstalled(input.server.id, {
|
|
437
|
+
allowPromptInstall: input.allowPromptInstall,
|
|
438
|
+
allowAutoInstall: input.allowAutoInstall,
|
|
439
|
+
});
|
|
440
|
+
const resolved = await resolveServerConfig({
|
|
441
|
+
server: input.server,
|
|
442
|
+
rootDir: input.rootDir,
|
|
443
|
+
rootMarker: input.rootMarker,
|
|
444
|
+
install,
|
|
445
|
+
});
|
|
446
|
+
let client: LspClient | undefined;
|
|
447
|
+
try {
|
|
448
|
+
client = new LspClient({
|
|
449
|
+
id: input.key,
|
|
450
|
+
ownerId: this.ownerId,
|
|
451
|
+
config: resolved,
|
|
452
|
+
processRegistry: this.processRegistry,
|
|
453
|
+
spawner: this.spawner,
|
|
454
|
+
connectionFactory: this.connectionFactory,
|
|
455
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
456
|
+
shutdownGraceMs: this.shutdownGraceMs,
|
|
457
|
+
});
|
|
458
|
+
await client.start();
|
|
459
|
+
this.clients.set(input.key, client);
|
|
460
|
+
return { client, serverId: input.server.id, rootDir: input.rootDir, started: true };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
await client?.shutdown().catch(() => false);
|
|
463
|
+
throw new LspRuntimeError(
|
|
464
|
+
`Failed to start ${input.server.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
465
|
+
"start-failed",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async ensureInstalled(serverId: string, options: ClientStartOptions): Promise<InstalledServerMetadata> {
|
|
471
|
+
const lockfile = await readLockfile(this.lockfileOptions);
|
|
472
|
+
const existing = lockfile.servers[serverId];
|
|
473
|
+
if (existing) return existing;
|
|
474
|
+
|
|
475
|
+
if (!options.allowPromptInstall && (!(options.allowAutoInstall ?? true) || this.config.installMode !== "auto")) {
|
|
476
|
+
throw new LspRuntimeError(
|
|
477
|
+
`${serverId} is not installed. Run /lsp install ${serverId} to install it explicitly.`,
|
|
478
|
+
"not-installed",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const result = await this.installManager.ensureInstalled(serverId);
|
|
483
|
+
if (result.status === "installed") return result.metadata;
|
|
484
|
+
throw new LspRuntimeError(result.message, result.status === "declined" ? "declined" : "not-installed");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async installedServerIds(): Promise<string[]> {
|
|
488
|
+
const lockfile = await readLockfile(this.lockfileOptions);
|
|
489
|
+
return Object.keys(lockfile.servers).filter((serverId) => this.config.catalog.servers[serverId] !== undefined);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async shutdownClients(predicate: (client: LspClient) => boolean): Promise<ClientShutdownResult[]> {
|
|
493
|
+
const entries = [...this.clients.entries()].filter(([_key, client]) => predicate(client));
|
|
494
|
+
const stopped = await Promise.all(
|
|
495
|
+
entries.map(async ([key, client]) => {
|
|
496
|
+
const didStop = await client.shutdown();
|
|
497
|
+
if (didStop || client.isExited) this.clients.delete(key);
|
|
498
|
+
return { client, stopped: didStop || client.isExited };
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
return stopped;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private resolvePath(filePath: string): string {
|
|
505
|
+
const resolvedPath = isAbsolute(filePath) ? resolve(filePath) : resolve(this.cwd, filePath);
|
|
506
|
+
if (!isPathInside(this.cwd, resolvedPath)) {
|
|
507
|
+
throw new LspRuntimeError(
|
|
508
|
+
`Refusing to start LSP for ${resolvedPath}; target is outside workspace ${this.cwd}.`,
|
|
509
|
+
"outside-workspace",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return resolvedPath;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function clientKey(serverId: string, rootDir: string): string {
|
|
517
|
+
return `${serverId}:${rootDir}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isPathInside(rootDir: string, targetPath: string): boolean {
|
|
521
|
+
const relativePath = relative(resolve(rootDir), resolve(targetPath));
|
|
522
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function validatePosition(selected: SelectedServer, line: number, character: number): void {
|
|
526
|
+
const lines = splitLines(selected.text);
|
|
527
|
+
if (!Number.isInteger(line) || !Number.isInteger(character) || line < 0 || character < 0) {
|
|
528
|
+
throw new LspRuntimeError(
|
|
529
|
+
`Invalid LSP position for ${selected.filePath}. Use a valid 1-based line/column from the file and place the column on an identifier token.`,
|
|
530
|
+
"invalid-position",
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (line >= lines.length) {
|
|
535
|
+
throw new LspRuntimeError(
|
|
536
|
+
`Position is outside ${selected.filePath}: line ${line + 1} was requested, but the file has ${lines.length} line(s). Use a valid 1-based line/column from the file and place the column on an identifier token.`,
|
|
537
|
+
"invalid-position",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const maxCharacter = lines[line]?.length ?? 0;
|
|
542
|
+
if (character > maxCharacter) {
|
|
543
|
+
throw new LspRuntimeError(
|
|
544
|
+
`Position is outside ${selected.filePath}: column ${character + 1} was requested on line ${line + 1}, but the maximum column is ${maxCharacter + 1}. Place the column on the identifier token you want to inspect.`,
|
|
545
|
+
"invalid-position",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function splitLines(text: string): string[] {
|
|
551
|
+
return text.split(/\r\n|\r|\n/u);
|
|
552
|
+
}
|