@ian-pascoe/pi-lsp 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 +187 -0
- package/package.json +57 -0
- package/src/index.ts +1 -0
- package/src/lsp-position-encoding.ts +134 -0
- package/src/lsp-post-edit-diagnostics-rendering.ts +249 -0
- package/src/lsp-post-edit-diagnostics.ts +291 -0
- package/src/lsp-server-client.ts +1237 -0
- package/src/lsp-server-manager.ts +519 -0
- package/src/lsp-session-files.ts +107 -0
- package/src/lsp-tool-contract.ts +468 -0
- package/src/lsp-tool-output.ts +64 -0
- package/src/lsp-tool-rendering.ts +312 -0
- package/src/lsp-tool.ts +1214 -0
- package/src/lsp-workspace-edit.ts +872 -0
- package/src/pi-lsp-extension.ts +379 -0
- package/src/pi-lsp-settings.ts +263 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, matchesGlob, resolve } from "node:path";
|
|
3
|
+
import type { LspServerDefinition, LspTimeouts, ResolvedLspSettings } from "./pi-lsp-settings.js";
|
|
4
|
+
|
|
5
|
+
/** Configures one language identifier for filename and extension routing. */
|
|
6
|
+
export interface LspServerLanguage {
|
|
7
|
+
/** Exact filenames that this language server accepts, without path segments. */
|
|
8
|
+
readonly fileNames?: readonly string[];
|
|
9
|
+
/** File extensions that this language server accepts, including the leading period. */
|
|
10
|
+
readonly extensions?: readonly string[];
|
|
11
|
+
/** Protocol language identifier sent when opening a matching document. */
|
|
12
|
+
readonly languageId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Describes the routing fields of one configured language server. */
|
|
16
|
+
export interface LspServerRoutingDefinition {
|
|
17
|
+
/** Stable settings-map key used to label matching language servers. */
|
|
18
|
+
readonly serverId: string;
|
|
19
|
+
/** Languages and file patterns accepted by this server. */
|
|
20
|
+
readonly languages: readonly LspServerLanguage[];
|
|
21
|
+
/** Basename glob patterns that select this server instance's nearest workspace root. */
|
|
22
|
+
readonly rootMarkers?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Supplies one ancestor directory and its entry basenames, ordered nearest-first. */
|
|
26
|
+
export interface LspAncestorDirectory {
|
|
27
|
+
/** Absolute directory path. */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** Basenames directly contained by this directory. */
|
|
30
|
+
readonly entryNames: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Identifies a server definition and language mapping selected for a file. */
|
|
34
|
+
export interface LspServerRoute {
|
|
35
|
+
/** Configured server ID. */
|
|
36
|
+
readonly serverId: string;
|
|
37
|
+
/** Language mapping that matched the requested file. */
|
|
38
|
+
readonly language: LspServerLanguage;
|
|
39
|
+
/** Nearest matching ancestor directory, or the caller's working directory. */
|
|
40
|
+
readonly rootPath: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimum client lifecycle contract required by the session-scoped server manager. */
|
|
44
|
+
export interface LspManagedServerClient {
|
|
45
|
+
/** Negotiated language-server capabilities, returned unchanged by `capabilities`. */
|
|
46
|
+
readonly capabilities: unknown;
|
|
47
|
+
/** Gracefully stop the language-server process and release protocol resources. */
|
|
48
|
+
shutdown(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Provides all parsed inputs needed to start one language-server process. */
|
|
52
|
+
export interface LspServerStartInput {
|
|
53
|
+
/** Complete configured Server Definition. */
|
|
54
|
+
readonly definition: LspServerDefinition;
|
|
55
|
+
/** Marks a started instance unavailable after a process or protocol failure. */
|
|
56
|
+
readonly onUnavailable: (cause: unknown) => void;
|
|
57
|
+
/** Nearest workspace root selected for this Server Instance. */
|
|
58
|
+
readonly rootPath: string;
|
|
59
|
+
/** Resolved request and lifecycle timeout policy. */
|
|
60
|
+
readonly timeouts: LspTimeouts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Starts one concrete client for a selected Server Definition and root. */
|
|
64
|
+
export type StartLspServerClient<TClient extends LspManagedServerClient> = (
|
|
65
|
+
input: LspServerStartInput,
|
|
66
|
+
) => Promise<TClient>;
|
|
67
|
+
|
|
68
|
+
/** Classifies a labeled failure from one matching Server Instance. */
|
|
69
|
+
export type LspServerFailureCode =
|
|
70
|
+
| "ambiguous-server"
|
|
71
|
+
| "no-capable-server"
|
|
72
|
+
| "no-matching-server"
|
|
73
|
+
| "request-failed"
|
|
74
|
+
| "server-unavailable";
|
|
75
|
+
|
|
76
|
+
/** Preserves one matching server's failure without discarding sibling successes. */
|
|
77
|
+
export interface LspServerFailure {
|
|
78
|
+
/** Stable machine-readable failure class. */
|
|
79
|
+
readonly code: LspServerFailureCode;
|
|
80
|
+
/** Searchable caller-facing error prefixed with `Pi LSP:`. */
|
|
81
|
+
readonly message: string;
|
|
82
|
+
/** Selected workspace root when routing reached a concrete Server Instance. */
|
|
83
|
+
readonly rootPath?: string;
|
|
84
|
+
/** Configured server ID, or the requested missing ID. */
|
|
85
|
+
readonly serverId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Labels a successful value with the Server Instance that produced it. */
|
|
89
|
+
export interface LspServerSuccess<T> {
|
|
90
|
+
/** Selected workspace root. */
|
|
91
|
+
readonly rootPath: string;
|
|
92
|
+
/** Configured server ID. */
|
|
93
|
+
readonly serverId: string;
|
|
94
|
+
/** Successful operation value, including authoritative empty values. */
|
|
95
|
+
readonly value: T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Keeps successful multi-server reads useful when independent servers fail. */
|
|
99
|
+
export interface LspServerReadResult<T> {
|
|
100
|
+
/** Labeled failures in deterministic route order. */
|
|
101
|
+
readonly failures: readonly LspServerFailure[];
|
|
102
|
+
/** Labeled successful values in deterministic route order. */
|
|
103
|
+
readonly successes: readonly LspServerSuccess<T>[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Supplies one ready client and its exact Server Instance route. */
|
|
107
|
+
export interface LspResolvedServerClient<TClient extends LspManagedServerClient> {
|
|
108
|
+
/** Ready language-server client. */
|
|
109
|
+
readonly client: TClient;
|
|
110
|
+
/** Exact configured definition used to start the client. */
|
|
111
|
+
readonly definition: LspServerDefinition;
|
|
112
|
+
/** Exact matching route. */
|
|
113
|
+
readonly route: LspServerRoute;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Returns either one exact ready client or an operation-specific routing failure. */
|
|
117
|
+
export type LspServerResolution<TClient extends LspManagedServerClient> =
|
|
118
|
+
| { readonly kind: "failure"; readonly failure: LspServerFailure }
|
|
119
|
+
| { readonly kind: "success"; readonly instance: LspResolvedServerClient<TClient> };
|
|
120
|
+
|
|
121
|
+
/** Describes one configured or previously resolved Server Instance without starting it. */
|
|
122
|
+
export interface LspServerStatusEntry {
|
|
123
|
+
/** Latest unavailable reason, when startup, process, or protocol lifecycle failed. */
|
|
124
|
+
readonly error?: string;
|
|
125
|
+
/** Workspace root for a resolved instance; absent before any file routes to the server. */
|
|
126
|
+
readonly rootPath?: string;
|
|
127
|
+
/** Configured server ID. */
|
|
128
|
+
readonly serverId: string;
|
|
129
|
+
/** Session lifecycle state. */
|
|
130
|
+
readonly state: "configured" | "running" | "starting" | "unavailable";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reports configuration failures and session-scoped Server Instance states. */
|
|
134
|
+
export interface LspServerManagerStatus {
|
|
135
|
+
/** False when either authored `lsp` settings layer was malformed. */
|
|
136
|
+
readonly enabled: boolean;
|
|
137
|
+
/** Server entries ordered by ID and then root. */
|
|
138
|
+
readonly servers: readonly LspServerStatusEntry[];
|
|
139
|
+
/** Strict settings failures kept visible until Pi `/reload`. */
|
|
140
|
+
readonly warnings: readonly string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Construction inputs for one session-scoped language-server manager. */
|
|
144
|
+
export interface LspServerManagerInput<TClient extends LspManagedServerClient> {
|
|
145
|
+
/** Pi session working directory used for relative paths and root fallback. */
|
|
146
|
+
readonly cwd: string;
|
|
147
|
+
/** Fully parsed trust-aware LSP settings. */
|
|
148
|
+
readonly settings: ResolvedLspSettings;
|
|
149
|
+
/** Concrete process/client constructor owned by the LSP client module. */
|
|
150
|
+
readonly startClient: StartLspServerClient<TClient>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Removes Pi's optional leading path sigil before file routing. */
|
|
154
|
+
export function normalizeLspFilePath(filePath: string): string {
|
|
155
|
+
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function languageMatchesFile(language: LspServerLanguage, filePath: string): boolean {
|
|
159
|
+
const fileName = basename(filePath);
|
|
160
|
+
return (
|
|
161
|
+
language.fileNames?.includes(fileName) === true ||
|
|
162
|
+
language.extensions?.includes(extname(fileName)) === true
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findNearestLspRoot(
|
|
167
|
+
rootMarkers: readonly string[] | undefined,
|
|
168
|
+
ancestorDirectories: readonly LspAncestorDirectory[],
|
|
169
|
+
cwd: string,
|
|
170
|
+
): string {
|
|
171
|
+
if (rootMarkers === undefined || rootMarkers.length === 0) return resolve(cwd);
|
|
172
|
+
for (const directory of ancestorDirectories) {
|
|
173
|
+
if (
|
|
174
|
+
directory.entryNames.some((entryName) =>
|
|
175
|
+
rootMarkers.some((rootMarker) => matchesGlob(entryName, rootMarker)),
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
178
|
+
return directory.path;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return resolve(cwd);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Route one file to every matching configured server in stable settings-map order. */
|
|
185
|
+
export function routeLspServersForFile(
|
|
186
|
+
serverDefinitions: readonly LspServerRoutingDefinition[],
|
|
187
|
+
filePath: string,
|
|
188
|
+
cwd: string,
|
|
189
|
+
ancestorDirectories: readonly LspAncestorDirectory[],
|
|
190
|
+
): readonly LspServerRoute[] {
|
|
191
|
+
const normalizedFilePath = normalizeLspFilePath(filePath);
|
|
192
|
+
const routes: LspServerRoute[] = [];
|
|
193
|
+
|
|
194
|
+
for (const serverDefinition of serverDefinitions) {
|
|
195
|
+
const language = serverDefinition.languages.find((candidate) =>
|
|
196
|
+
languageMatchesFile(candidate, normalizedFilePath),
|
|
197
|
+
);
|
|
198
|
+
if (language === undefined) continue;
|
|
199
|
+
routes.push({
|
|
200
|
+
serverId: serverDefinition.serverId,
|
|
201
|
+
language,
|
|
202
|
+
rootPath: findNearestLspRoot(serverDefinition.rootMarkers, ancestorDirectories, cwd),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return routes;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function describeLspError(cause: unknown): string {
|
|
210
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function lspInstanceKey(serverId: string, rootPath: string): string {
|
|
214
|
+
return JSON.stringify([serverId, rootPath]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readLspAncestorDirectories(filePath: string): Promise<LspAncestorDirectory[]> {
|
|
218
|
+
const directories: LspAncestorDirectory[] = [];
|
|
219
|
+
let currentDirectory = dirname(filePath);
|
|
220
|
+
for (;;) {
|
|
221
|
+
let entryNames: string[] = [];
|
|
222
|
+
try {
|
|
223
|
+
entryNames = await readdir(currentDirectory);
|
|
224
|
+
} catch {
|
|
225
|
+
// A target can be newly created; continue upward until an existing ancestor is found.
|
|
226
|
+
}
|
|
227
|
+
directories.push({ entryNames, path: currentDirectory });
|
|
228
|
+
const parentDirectory = dirname(currentDirectory);
|
|
229
|
+
if (parentDirectory === currentDirectory) return directories;
|
|
230
|
+
currentDirectory = parentDirectory;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function unavailableFailure(route: LspServerRoute, error: string): LspServerFailure {
|
|
235
|
+
return {
|
|
236
|
+
code: "server-unavailable",
|
|
237
|
+
message: `Pi LSP: server ${route.serverId} is unavailable for ${route.rootPath}: ${error}`,
|
|
238
|
+
rootPath: route.rootPath,
|
|
239
|
+
serverId: route.serverId,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Own lazy Server Instance creation, routing, failure state, restart, and session shutdown. */
|
|
244
|
+
export class LspServerManager<TClient extends LspManagedServerClient = LspManagedServerClient> {
|
|
245
|
+
private readonly clients = new Map<string, TClient>();
|
|
246
|
+
private readonly inFlightStarts = new Map<string, Promise<LspServerResolution<TClient>>>();
|
|
247
|
+
private readonly knownRoutes = new Map<string, LspServerRoute>();
|
|
248
|
+
private readonly unavailable = new Map<string, string>();
|
|
249
|
+
|
|
250
|
+
/** Bind parsed settings and one concrete client constructor to the current Pi session. */
|
|
251
|
+
constructor(private readonly input: LspServerManagerInput<TClient>) {}
|
|
252
|
+
|
|
253
|
+
/** Return configuration and known instance state without starting a server. */
|
|
254
|
+
getStatus(): LspServerManagerStatus {
|
|
255
|
+
const servers: LspServerStatusEntry[] = [];
|
|
256
|
+
for (const [serverId] of this.input.settings.servers) {
|
|
257
|
+
const routes = [...this.knownRoutes.entries()]
|
|
258
|
+
.filter(([, route]) => route.serverId === serverId)
|
|
259
|
+
.sort(([, left], [, right]) => left.rootPath.localeCompare(right.rootPath));
|
|
260
|
+
if (routes.length === 0) {
|
|
261
|
+
servers.push({ serverId, state: "configured" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
for (const [key, route] of routes) {
|
|
265
|
+
const error = this.unavailable.get(key);
|
|
266
|
+
if (error !== undefined) {
|
|
267
|
+
servers.push({ error, rootPath: route.rootPath, serverId, state: "unavailable" });
|
|
268
|
+
} else if (this.inFlightStarts.has(key)) {
|
|
269
|
+
servers.push({ rootPath: route.rootPath, serverId, state: "starting" });
|
|
270
|
+
} else if (this.clients.has(key)) {
|
|
271
|
+
servers.push({ rootPath: route.rootPath, serverId, state: "running" });
|
|
272
|
+
} else {
|
|
273
|
+
servers.push({ rootPath: route.rootPath, serverId, state: "configured" });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
enabled: this.input.settings.enabled,
|
|
279
|
+
servers,
|
|
280
|
+
warnings: this.input.settings.warnings,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Resolve all matching Server Definitions and nearest roots without starting clients. */
|
|
285
|
+
async routeFile(filePath: string): Promise<readonly LspServerRoute[]> {
|
|
286
|
+
if (!this.input.settings.enabled) return [];
|
|
287
|
+
const absolutePath = resolve(this.input.cwd, normalizeLspFilePath(filePath));
|
|
288
|
+
const ancestors = await readLspAncestorDirectories(absolutePath);
|
|
289
|
+
const definitions = [...this.input.settings.servers.values()].map((definition) => ({
|
|
290
|
+
languages: definition.languages,
|
|
291
|
+
rootMarkers: definition.rootMarkers,
|
|
292
|
+
serverId: definition.id,
|
|
293
|
+
}));
|
|
294
|
+
return routeLspServersForFile(definitions, absolutePath, this.input.cwd, ancestors);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Query every matching capable instance while retaining independent successes and failures. */
|
|
298
|
+
async runRead<T>(
|
|
299
|
+
filePath: string,
|
|
300
|
+
serverId: string | undefined,
|
|
301
|
+
isCapable: (client: TClient) => boolean,
|
|
302
|
+
operation: (client: TClient, route: LspServerRoute) => Promise<T>,
|
|
303
|
+
): Promise<LspServerReadResult<T>> {
|
|
304
|
+
const routes = await this.selectRoutes(filePath, serverId);
|
|
305
|
+
if (routes.length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
failures: [this.noMatchingFailure(serverId, filePath)],
|
|
308
|
+
successes: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const outcomes = await Promise.all(
|
|
313
|
+
routes.map(async (route): Promise<LspServerSuccess<T> | LspServerFailure> => {
|
|
314
|
+
const resolution = await this.ensureClient(route);
|
|
315
|
+
if (resolution.kind === "failure") return resolution.failure;
|
|
316
|
+
if (!isCapable(resolution.instance.client)) {
|
|
317
|
+
return {
|
|
318
|
+
code: "no-capable-server",
|
|
319
|
+
message: `Pi LSP: server ${route.serverId} does not support the requested operation`,
|
|
320
|
+
rootPath: route.rootPath,
|
|
321
|
+
serverId: route.serverId,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
return {
|
|
326
|
+
rootPath: route.rootPath,
|
|
327
|
+
serverId: route.serverId,
|
|
328
|
+
value: await operation(resolution.instance.client, route),
|
|
329
|
+
};
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return {
|
|
332
|
+
code: "request-failed",
|
|
333
|
+
message: `Pi LSP: server ${route.serverId} request failed: ${describeLspError(error)}`,
|
|
334
|
+
rootPath: route.rootPath,
|
|
335
|
+
serverId: route.serverId,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const failures: LspServerFailure[] = [];
|
|
342
|
+
const successes: LspServerSuccess<T>[] = [];
|
|
343
|
+
for (const outcome of outcomes) {
|
|
344
|
+
if ("code" in outcome) failures.push(outcome);
|
|
345
|
+
else successes.push(outcome);
|
|
346
|
+
}
|
|
347
|
+
return { failures, successes };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Resolve exactly one capable matching instance before a preview-producing mutation request. */
|
|
351
|
+
async resolveMutationClient(
|
|
352
|
+
filePath: string,
|
|
353
|
+
serverId: string | undefined,
|
|
354
|
+
isCapable: (client: TClient) => boolean,
|
|
355
|
+
): Promise<LspServerResolution<TClient>> {
|
|
356
|
+
const routes = await this.selectRoutes(filePath, serverId);
|
|
357
|
+
if (routes.length === 0) {
|
|
358
|
+
return { kind: "failure", failure: this.noMatchingFailure(serverId, filePath) };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const resolutions = await Promise.all(routes.map((route) => this.ensureClient(route)));
|
|
362
|
+
const capable = resolutions.filter(
|
|
363
|
+
(resolution): resolution is Extract<LspServerResolution<TClient>, { kind: "success" }> =>
|
|
364
|
+
resolution.kind === "success" && isCapable(resolution.instance.client),
|
|
365
|
+
);
|
|
366
|
+
const onlyCapable = capable[0];
|
|
367
|
+
if (onlyCapable !== undefined && capable.length === 1) return onlyCapable;
|
|
368
|
+
if (capable.length > 1) {
|
|
369
|
+
return {
|
|
370
|
+
kind: "failure",
|
|
371
|
+
failure: {
|
|
372
|
+
code: "ambiguous-server",
|
|
373
|
+
message: `Pi LSP: mutation matches multiple capable servers; provide server_id (${capable
|
|
374
|
+
.map(({ instance }) => instance.route.serverId)
|
|
375
|
+
.join(", ")})`,
|
|
376
|
+
serverId: serverId ?? "*",
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const unavailableResolution = resolutions.find(
|
|
382
|
+
(resolution): resolution is Extract<LspServerResolution<TClient>, { kind: "failure" }> =>
|
|
383
|
+
resolution.kind === "failure",
|
|
384
|
+
);
|
|
385
|
+
if (unavailableResolution !== undefined) return unavailableResolution;
|
|
386
|
+
return {
|
|
387
|
+
kind: "failure",
|
|
388
|
+
failure: {
|
|
389
|
+
code: "no-capable-server",
|
|
390
|
+
message: "Pi LSP: no matching server supports the requested mutation",
|
|
391
|
+
serverId: serverId ?? "*",
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Start one exact Server Instance and return its negotiated capabilities. */
|
|
397
|
+
async getCapabilities(serverId: string, filePath: string): Promise<LspServerResolution<TClient>> {
|
|
398
|
+
const routes = await this.selectRoutes(filePath, serverId);
|
|
399
|
+
const route = routes[0];
|
|
400
|
+
if (route === undefined) {
|
|
401
|
+
return { kind: "failure", failure: this.noMatchingFailure(serverId, filePath) };
|
|
402
|
+
}
|
|
403
|
+
return this.ensureClient(route);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Clear sticky failure state, stop the old process, and start the exact Server Instance again. */
|
|
407
|
+
async restartServer(serverId: string, filePath: string): Promise<LspServerResolution<TClient>> {
|
|
408
|
+
const routes = await this.selectRoutes(filePath, serverId);
|
|
409
|
+
const route = routes[0];
|
|
410
|
+
if (route === undefined) {
|
|
411
|
+
return { kind: "failure", failure: this.noMatchingFailure(serverId, filePath) };
|
|
412
|
+
}
|
|
413
|
+
const key = lspInstanceKey(route.serverId, route.rootPath);
|
|
414
|
+
const inFlight = this.inFlightStarts.get(key);
|
|
415
|
+
if (inFlight !== undefined) await inFlight;
|
|
416
|
+
const client = this.clients.get(key);
|
|
417
|
+
this.clients.delete(key);
|
|
418
|
+
this.unavailable.delete(key);
|
|
419
|
+
if (client !== undefined) {
|
|
420
|
+
try {
|
|
421
|
+
await client.shutdown();
|
|
422
|
+
} catch {
|
|
423
|
+
// Restart still attempts a fresh process after an old failed client's cleanup error.
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return this.ensureClient(route);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Gracefully stop every client once and clear all session-scoped instance state. */
|
|
430
|
+
async shutdown(): Promise<void> {
|
|
431
|
+
await Promise.allSettled(this.inFlightStarts.values());
|
|
432
|
+
const clients = [...new Set(this.clients.values())];
|
|
433
|
+
this.clients.clear();
|
|
434
|
+
this.inFlightStarts.clear();
|
|
435
|
+
this.unavailable.clear();
|
|
436
|
+
this.knownRoutes.clear();
|
|
437
|
+
await Promise.allSettled(clients.map((client) => client.shutdown()));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async selectRoutes(
|
|
441
|
+
filePath: string,
|
|
442
|
+
serverId: string | undefined,
|
|
443
|
+
): Promise<readonly LspServerRoute[]> {
|
|
444
|
+
const routes = await this.routeFile(filePath);
|
|
445
|
+
return serverId === undefined ? routes : routes.filter((route) => route.serverId === serverId);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private noMatchingFailure(serverId: string | undefined, filePath: string): LspServerFailure {
|
|
449
|
+
const requestedServer = serverId === undefined ? "any configured server" : `server ${serverId}`;
|
|
450
|
+
return {
|
|
451
|
+
code: "no-matching-server",
|
|
452
|
+
message: `Pi LSP: ${requestedServer} does not match ${normalizeLspFilePath(filePath)}`,
|
|
453
|
+
serverId: serverId ?? "*",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private ensureClient(route: LspServerRoute): Promise<LspServerResolution<TClient>> {
|
|
458
|
+
const key = lspInstanceKey(route.serverId, route.rootPath);
|
|
459
|
+
this.knownRoutes.set(key, route);
|
|
460
|
+
const unavailableReason = this.unavailable.get(key);
|
|
461
|
+
if (unavailableReason !== undefined) {
|
|
462
|
+
return Promise.resolve({
|
|
463
|
+
kind: "failure",
|
|
464
|
+
failure: unavailableFailure(route, unavailableReason),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const client = this.clients.get(key);
|
|
468
|
+
const definition = this.input.settings.servers.get(route.serverId);
|
|
469
|
+
if (client !== undefined && definition !== undefined) {
|
|
470
|
+
return Promise.resolve({
|
|
471
|
+
kind: "success",
|
|
472
|
+
instance: { client, definition, route },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const inFlight = this.inFlightStarts.get(key);
|
|
476
|
+
if (inFlight !== undefined) return inFlight;
|
|
477
|
+
if (definition === undefined) {
|
|
478
|
+
return Promise.resolve({
|
|
479
|
+
kind: "failure",
|
|
480
|
+
failure: this.noMatchingFailure(route.serverId, route.rootPath),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const start = this.startClient(key, route, definition);
|
|
485
|
+
this.inFlightStarts.set(key, start);
|
|
486
|
+
void start.finally(() => {
|
|
487
|
+
if (this.inFlightStarts.get(key) === start) this.inFlightStarts.delete(key);
|
|
488
|
+
});
|
|
489
|
+
return start;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async startClient(
|
|
493
|
+
key: string,
|
|
494
|
+
route: LspServerRoute,
|
|
495
|
+
definition: LspServerDefinition,
|
|
496
|
+
): Promise<LspServerResolution<TClient>> {
|
|
497
|
+
try {
|
|
498
|
+
const client = await this.input.startClient({
|
|
499
|
+
definition,
|
|
500
|
+
onUnavailable: (error) => {
|
|
501
|
+
this.unavailable.set(key, describeLspError(error));
|
|
502
|
+
},
|
|
503
|
+
rootPath: route.rootPath,
|
|
504
|
+
timeouts: this.input.settings.timeouts,
|
|
505
|
+
});
|
|
506
|
+
const failure = this.unavailable.get(key);
|
|
507
|
+
if (failure !== undefined) {
|
|
508
|
+
await client.shutdown().catch(() => {});
|
|
509
|
+
return { kind: "failure", failure: unavailableFailure(route, failure) };
|
|
510
|
+
}
|
|
511
|
+
this.clients.set(key, client);
|
|
512
|
+
return { kind: "success", instance: { client, definition, route } };
|
|
513
|
+
} catch (error) {
|
|
514
|
+
const message = describeLspError(error);
|
|
515
|
+
this.unavailable.set(key, message);
|
|
516
|
+
return { kind: "failure", failure: unavailableFailure(route, message) };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The maximum retained byte length for one language server's stderr log. */
|
|
5
|
+
export const MAX_SERVER_STDERR_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
/** Owns private Result Spill and bounded stderr files for one Pi session. */
|
|
8
|
+
export interface LspSessionFiles {
|
|
9
|
+
/** Private directory removed when the Pi session shuts down. */
|
|
10
|
+
readonly directoryPath: string;
|
|
11
|
+
/** Write complete truncated tool output to a Result Spill file. */
|
|
12
|
+
writeResultSpill(output: string): Promise<string>;
|
|
13
|
+
/** Create or return the bounded stderr file path for one language server. */
|
|
14
|
+
getServerStderrPath(serverId: string): Promise<string>;
|
|
15
|
+
/** Retain the latest one megabyte of one language server's stderr stream. */
|
|
16
|
+
appendServerStderr(serverId: string, chunk: Uint8Array): Promise<string>;
|
|
17
|
+
/** Remove all session files after queued writes finish. */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ServerStderrFile {
|
|
22
|
+
readonly path: string;
|
|
23
|
+
content: Buffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class LspSessionFileStore implements LspSessionFiles {
|
|
27
|
+
private closed = false;
|
|
28
|
+
private closePromise: Promise<void> | undefined;
|
|
29
|
+
private nextFileIndex = 0;
|
|
30
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
31
|
+
private readonly stderrFiles = new Map<string, ServerStderrFile>();
|
|
32
|
+
|
|
33
|
+
/** Create a private file store rooted at the given session directory. */
|
|
34
|
+
constructor(readonly directoryPath: string) {}
|
|
35
|
+
|
|
36
|
+
writeResultSpill(output: string): Promise<string> {
|
|
37
|
+
const path = join(this.directoryPath, `result-spill-${this.nextFileIndex++}.txt`);
|
|
38
|
+
return this.enqueueSessionFileWrite(async () => {
|
|
39
|
+
await writeFile(path, output, { encoding: "utf8", mode: 0o600 });
|
|
40
|
+
await chmod(path, 0o600);
|
|
41
|
+
return path;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getServerStderrPath(serverId: string): Promise<string> {
|
|
46
|
+
const stderrFile = this.serverStderrFile(serverId);
|
|
47
|
+
return this.enqueueSessionFileWrite(async () => {
|
|
48
|
+
await writeFile(stderrFile.path, stderrFile.content, { mode: 0o600 });
|
|
49
|
+
await chmod(stderrFile.path, 0o600);
|
|
50
|
+
return stderrFile.path;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
appendServerStderr(serverId: string, chunk: Uint8Array): Promise<string> {
|
|
55
|
+
const stderrFile = this.serverStderrFile(serverId);
|
|
56
|
+
const copiedChunk = Buffer.from(chunk);
|
|
57
|
+
return this.enqueueSessionFileWrite(async () => {
|
|
58
|
+
const combined = Buffer.concat([stderrFile.content, copiedChunk]);
|
|
59
|
+
stderrFile.content =
|
|
60
|
+
combined.length <= MAX_SERVER_STDERR_BYTES
|
|
61
|
+
? combined
|
|
62
|
+
: Buffer.from(combined.subarray(combined.length - MAX_SERVER_STDERR_BYTES));
|
|
63
|
+
await writeFile(stderrFile.path, stderrFile.content, { mode: 0o600 });
|
|
64
|
+
await chmod(stderrFile.path, 0o600);
|
|
65
|
+
return stderrFile.path;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close(): Promise<void> {
|
|
70
|
+
if (this.closePromise !== undefined) return this.closePromise;
|
|
71
|
+
this.closed = true;
|
|
72
|
+
this.closePromise = this.writeQueue.then(
|
|
73
|
+
() => rm(this.directoryPath, { force: true, recursive: true }),
|
|
74
|
+
() => rm(this.directoryPath, { force: true, recursive: true }),
|
|
75
|
+
);
|
|
76
|
+
return this.closePromise;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private serverStderrFile(serverId: string): ServerStderrFile {
|
|
80
|
+
const existing = this.stderrFiles.get(serverId);
|
|
81
|
+
if (existing !== undefined) return existing;
|
|
82
|
+
const stderrFile = {
|
|
83
|
+
path: join(this.directoryPath, `server-stderr-${this.nextFileIndex++}.log`),
|
|
84
|
+
content: Buffer.alloc(0),
|
|
85
|
+
};
|
|
86
|
+
this.stderrFiles.set(serverId, stderrFile);
|
|
87
|
+
return stderrFile;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private enqueueSessionFileWrite<T>(write: () => Promise<T>): Promise<T> {
|
|
91
|
+
if (this.closed) return Promise.reject(new Error("Pi LSP: session files are closed"));
|
|
92
|
+
const result = this.writeQueue.then(write);
|
|
93
|
+
this.writeQueue = result.then(
|
|
94
|
+
() => undefined,
|
|
95
|
+
() => undefined,
|
|
96
|
+
);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create a mode-safe temporary directory for Result Spills and language-server stderr files. */
|
|
102
|
+
export async function createLspSessionFiles(sessionDirectory: string): Promise<LspSessionFiles> {
|
|
103
|
+
await mkdir(sessionDirectory, { mode: 0o700, recursive: true });
|
|
104
|
+
const directoryPath = await mkdtemp(join(sessionDirectory, "pi-lsp-"));
|
|
105
|
+
await chmod(directoryPath, 0o700);
|
|
106
|
+
return new LspSessionFileStore(directoryPath);
|
|
107
|
+
}
|