@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,379 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_BYTES,
|
|
5
|
+
DEFAULT_MAX_LINES,
|
|
6
|
+
getAgentDir,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
truncateHead,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type ExtensionFactory,
|
|
12
|
+
type SessionEntry,
|
|
13
|
+
type ToolResultEvent,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { type Static, Type } from "typebox";
|
|
16
|
+
import { Value } from "typebox/value";
|
|
17
|
+
import { PositionEncodingKind, type Diagnostic } from "vscode-languageserver-protocol/node";
|
|
18
|
+
import {
|
|
19
|
+
appendPiPostEditDiagnostics,
|
|
20
|
+
type PostEditDiagnosticOutcome,
|
|
21
|
+
type PostEditDiagnosticPath,
|
|
22
|
+
type PostEditDiagnosticsResultPatch,
|
|
23
|
+
type PostEditDiagnosticsRunner,
|
|
24
|
+
} from "./lsp-post-edit-diagnostics.js";
|
|
25
|
+
import {
|
|
26
|
+
createPostEditDiagnosticsEntryData,
|
|
27
|
+
POST_EDIT_DIAGNOSTICS_ENTRY_TYPE,
|
|
28
|
+
PostEditDiagnosticsEntryDataSchema,
|
|
29
|
+
renderPostEditDiagnosticsEntry,
|
|
30
|
+
} from "./lsp-post-edit-diagnostics-rendering.js";
|
|
31
|
+
import {
|
|
32
|
+
convertLspProtocolPosition,
|
|
33
|
+
normalizeLspPositionEncoding,
|
|
34
|
+
type LspPositionEncoding,
|
|
35
|
+
} from "./lsp-position-encoding.js";
|
|
36
|
+
import { LspServerClient } from "./lsp-server-client.js";
|
|
37
|
+
import { LspServerManager, normalizeLspFilePath } from "./lsp-server-manager.js";
|
|
38
|
+
import { createLspSessionFiles, type LspSessionFiles } from "./lsp-session-files.js";
|
|
39
|
+
import {
|
|
40
|
+
LspToolResultDetailsSchema,
|
|
41
|
+
type LspWorkspaceEditPreviewRecord,
|
|
42
|
+
} from "./lsp-tool-contract.js";
|
|
43
|
+
import { registerLspTool } from "./lsp-tool.js";
|
|
44
|
+
import { LspWorkspaceEditStore } from "./lsp-workspace-edit.js";
|
|
45
|
+
import { resolveLspSettings } from "./pi-lsp-settings.js";
|
|
46
|
+
|
|
47
|
+
/** Runtime construction effects kept narrow so lifecycle tests can select an isolated Pi agent directory. */
|
|
48
|
+
export interface PiLspLifecycleEffects {
|
|
49
|
+
/** Return Pi's trust-aware global settings directory. */
|
|
50
|
+
getAgentDirectory(): string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ActivePiLspSession {
|
|
54
|
+
readonly cwd: string;
|
|
55
|
+
readonly manager: LspServerManager<LspServerClient>;
|
|
56
|
+
readonly sessionFiles: LspSessionFiles;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const productionPiLspLifecycleEffects: PiLspLifecycleEffects = {
|
|
60
|
+
getAgentDirectory: getAgentDir,
|
|
61
|
+
};
|
|
62
|
+
const DiagnosticMarkupContentSchema = Type.Object(
|
|
63
|
+
{
|
|
64
|
+
kind: Type.String(),
|
|
65
|
+
value: Type.String(),
|
|
66
|
+
},
|
|
67
|
+
{ additionalProperties: false },
|
|
68
|
+
);
|
|
69
|
+
const AppendedTextContentSchema = Type.Object(
|
|
70
|
+
{
|
|
71
|
+
type: Type.Literal("text"),
|
|
72
|
+
text: Type.String(),
|
|
73
|
+
},
|
|
74
|
+
{ additionalProperties: false },
|
|
75
|
+
);
|
|
76
|
+
function branchLspToolResultDetails(
|
|
77
|
+
entries: readonly SessionEntry[],
|
|
78
|
+
): readonly LspWorkspaceEditPreviewRecord[] {
|
|
79
|
+
const records = new Map<string, LspWorkspaceEditPreviewRecord>();
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.type !== "message") continue;
|
|
82
|
+
const message = entry.message;
|
|
83
|
+
if (message.role !== "toolResult" || message.toolName !== "lsp") continue;
|
|
84
|
+
if (!Value.Check(LspToolResultDetailsSchema, message.details)) continue;
|
|
85
|
+
const details = message.details;
|
|
86
|
+
for (const record of details.preview_records ?? []) {
|
|
87
|
+
records.set(record.preview_id, record);
|
|
88
|
+
}
|
|
89
|
+
if (details.kind === "workspace_edit_preview") {
|
|
90
|
+
records.set(details.preview_record.preview_id, details.preview_record);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (details.kind === "operation") continue;
|
|
94
|
+
const applied = records.get(details.preview_id);
|
|
95
|
+
if (applied !== undefined) {
|
|
96
|
+
records.set(details.preview_id, { ...applied, state: "applied" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...records.values()];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizedDiagnosticOutcome(
|
|
103
|
+
diagnostic: Diagnostic,
|
|
104
|
+
serverId: string,
|
|
105
|
+
filePath: string,
|
|
106
|
+
documentText: string,
|
|
107
|
+
positionEncoding: LspPositionEncoding,
|
|
108
|
+
): PostEditDiagnosticOutcome {
|
|
109
|
+
const position = convertLspProtocolPosition(
|
|
110
|
+
documentText,
|
|
111
|
+
diagnostic.range.start,
|
|
112
|
+
positionEncoding,
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
kind: "diagnostic",
|
|
116
|
+
diagnostic: {
|
|
117
|
+
serverId,
|
|
118
|
+
path: filePath,
|
|
119
|
+
line: position.line,
|
|
120
|
+
character: position.character,
|
|
121
|
+
severity: diagnostic.severity ?? 4,
|
|
122
|
+
message: Value.Check(Type.String(), diagnostic.message)
|
|
123
|
+
? diagnostic.message
|
|
124
|
+
: Value.Parse(DiagnosticMarkupContentSchema, diagnostic.message).value,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function failureDiagnosticOutcome(
|
|
130
|
+
path: string,
|
|
131
|
+
failure: { readonly code: string; readonly message: string; readonly serverId: string },
|
|
132
|
+
): PostEditDiagnosticOutcome {
|
|
133
|
+
if (failure.code === "no-matching-server") {
|
|
134
|
+
return { kind: "no_configured_server", path };
|
|
135
|
+
}
|
|
136
|
+
if (failure.message.toLowerCase().includes("timed out")) {
|
|
137
|
+
return { kind: "timeout", path, serverId: failure.serverId };
|
|
138
|
+
}
|
|
139
|
+
return { kind: "unavailable_server", path, serverId: failure.serverId };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class ManagerPostEditDiagnosticsRunner implements PostEditDiagnosticsRunner {
|
|
143
|
+
constructor(
|
|
144
|
+
private readonly session: ActivePiLspSession,
|
|
145
|
+
private readonly signal: AbortSignal | undefined,
|
|
146
|
+
) {}
|
|
147
|
+
|
|
148
|
+
async runPostEditDiagnostics(
|
|
149
|
+
paths: readonly PostEditDiagnosticPath[],
|
|
150
|
+
): Promise<readonly PostEditDiagnosticOutcome[]> {
|
|
151
|
+
const outcomes: PostEditDiagnosticOutcome[] = [];
|
|
152
|
+
for (const { path } of paths) {
|
|
153
|
+
const filePath = resolve(this.session.cwd, normalizeLspFilePath(path));
|
|
154
|
+
const result = await this.session.manager.runRead(
|
|
155
|
+
filePath,
|
|
156
|
+
undefined,
|
|
157
|
+
() => true,
|
|
158
|
+
async (client, route): Promise<readonly PostEditDiagnosticOutcome[]> => {
|
|
159
|
+
const diagnostics = await client.documentDiagnostics(
|
|
160
|
+
filePath,
|
|
161
|
+
route.language.languageId,
|
|
162
|
+
this.signal,
|
|
163
|
+
);
|
|
164
|
+
if (diagnostics.status === "timeout") {
|
|
165
|
+
return [{ kind: "timeout", path: filePath, serverId: route.serverId }];
|
|
166
|
+
}
|
|
167
|
+
if (diagnostics.diagnostics.length === 0) {
|
|
168
|
+
return [{ kind: "no_diagnostics", path: filePath }];
|
|
169
|
+
}
|
|
170
|
+
const documentText = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
171
|
+
await readFile(filePath),
|
|
172
|
+
);
|
|
173
|
+
const encoding = normalizeLspPositionEncoding(client.positionEncoding);
|
|
174
|
+
return diagnostics.diagnostics.map((diagnostic) =>
|
|
175
|
+
normalizedDiagnosticOutcome(
|
|
176
|
+
diagnostic,
|
|
177
|
+
route.serverId,
|
|
178
|
+
filePath,
|
|
179
|
+
documentText,
|
|
180
|
+
encoding,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
const successfulOutcomes = result.successes.flatMap(({ value }) => value);
|
|
186
|
+
outcomes.push(...successfulOutcomes);
|
|
187
|
+
outcomes.push(
|
|
188
|
+
...result.failures.map((failure) => failureDiagnosticOutcome(filePath, failure)),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return outcomes;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function appendSessionPostEditDiagnostics(
|
|
196
|
+
event: ToolResultEvent,
|
|
197
|
+
session: ActivePiLspSession,
|
|
198
|
+
context: ExtensionContext,
|
|
199
|
+
): Promise<PostEditDiagnosticsResultPatch | undefined> {
|
|
200
|
+
const patch = await appendPiPostEditDiagnostics(
|
|
201
|
+
event,
|
|
202
|
+
new ManagerPostEditDiagnosticsRunner(session, context.signal),
|
|
203
|
+
);
|
|
204
|
+
if (patch === undefined) return undefined;
|
|
205
|
+
const appendedValue = patch.content.at(-1);
|
|
206
|
+
if (!Value.Check(AppendedTextContentSchema, appendedValue)) return undefined;
|
|
207
|
+
let appended: Static<typeof AppendedTextContentSchema> = appendedValue;
|
|
208
|
+
const truncation = truncateHead(appended.text, {
|
|
209
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
210
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
211
|
+
});
|
|
212
|
+
if (truncation.truncated) {
|
|
213
|
+
const spillPath = await session.sessionFiles.writeResultSpill(appended.text);
|
|
214
|
+
appended = {
|
|
215
|
+
type: "text",
|
|
216
|
+
text: `${truncation.content}\n\n[Pi LSP: diagnostics truncated; complete Result Spill: ${spillPath}]`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const partialApplyFailure =
|
|
220
|
+
event.toolName === "lsp" &&
|
|
221
|
+
Value.Check(LspToolResultDetailsSchema, event.details) &&
|
|
222
|
+
event.details.kind === "workspace_edit_apply" &&
|
|
223
|
+
event.details.state === "partial_failure";
|
|
224
|
+
return {
|
|
225
|
+
...patch,
|
|
226
|
+
content: [...event.content, appended],
|
|
227
|
+
isError: partialApplyFailure || patch.isError,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Own settings, tool registration, replay, diagnostics middleware, and resource shutdown for one extension instance. */
|
|
232
|
+
export class PiLspLifecycleController {
|
|
233
|
+
private readonly pendingPostEditDiagnosticOutcomes: PostEditDiagnosticOutcome[] = [];
|
|
234
|
+
private session: ActivePiLspSession | undefined;
|
|
235
|
+
private shutdownPromise: Promise<void> | undefined;
|
|
236
|
+
private toolRegistered = false;
|
|
237
|
+
|
|
238
|
+
/** Bind one lifecycle controller to Pi and production or test construction effects. */
|
|
239
|
+
constructor(
|
|
240
|
+
private readonly pi: ExtensionAPI,
|
|
241
|
+
private readonly effects: PiLspLifecycleEffects,
|
|
242
|
+
) {}
|
|
243
|
+
|
|
244
|
+
/** Register Pi LSP lifecycle handlers and model-invisible diagnostics entry rendering. */
|
|
245
|
+
register(): void {
|
|
246
|
+
this.pi.registerEntryRenderer(POST_EDIT_DIAGNOSTICS_ENTRY_TYPE, (entry, { expanded }, theme) =>
|
|
247
|
+
Value.Check(PostEditDiagnosticsEntryDataSchema, entry.data)
|
|
248
|
+
? renderPostEditDiagnosticsEntry(entry.data, expanded, theme)
|
|
249
|
+
: undefined,
|
|
250
|
+
);
|
|
251
|
+
this.pi.on("session_start", (_event, context) => this.startSession(context));
|
|
252
|
+
this.pi.on("tool_result", (event, context) => this.handleToolResult(event, context));
|
|
253
|
+
this.pi.on("turn_end", () => this.flushPostEditDiagnosticsEntry());
|
|
254
|
+
this.pi.on("session_shutdown", () => this.shutdownSession());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async startSession(context: ExtensionContext): Promise<void> {
|
|
258
|
+
await this.shutdownSession();
|
|
259
|
+
this.pendingPostEditDiagnosticOutcomes.length = 0;
|
|
260
|
+
const settingsManager = SettingsManager.create(context.cwd, this.effects.getAgentDirectory(), {
|
|
261
|
+
projectTrusted: context.isProjectTrusted(),
|
|
262
|
+
});
|
|
263
|
+
const settings = resolveLspSettings(settingsManager);
|
|
264
|
+
if (settings.warnings.length > 0) {
|
|
265
|
+
context.ui.notify(`Pi LSP settings:\n- ${settings.warnings.join("\n- ")}`, "warning");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const sessionFiles = await createLspSessionFiles(context.sessionManager.getSessionDir());
|
|
269
|
+
const workspaceEdits = new LspWorkspaceEditStore();
|
|
270
|
+
const replay = workspaceEdits.replayPreviewRecords(
|
|
271
|
+
branchLspToolResultDetails(context.sessionManager.getBranch()),
|
|
272
|
+
);
|
|
273
|
+
if (replay.rejected > 0) {
|
|
274
|
+
context.ui.notify(
|
|
275
|
+
`Pi LSP ignored ${replay.rejected} invalid Workspace Edit Preview record${replay.rejected === 1 ? "" : "s"} on the active session branch.`,
|
|
276
|
+
"warning",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const manager = new LspServerManager<LspServerClient>({
|
|
281
|
+
cwd: context.cwd,
|
|
282
|
+
settings,
|
|
283
|
+
startClient: async ({ definition, onUnavailable, rootPath, timeouts }) => {
|
|
284
|
+
let client: LspServerClient | undefined;
|
|
285
|
+
client = await LspServerClient.start({
|
|
286
|
+
serverId: definition.id,
|
|
287
|
+
rootPath,
|
|
288
|
+
command: definition.command,
|
|
289
|
+
args: definition.args,
|
|
290
|
+
environment: { ...definition.environment },
|
|
291
|
+
initializationOptions: definition.initializationOptions ?? null,
|
|
292
|
+
settings: definition.settings ?? null,
|
|
293
|
+
timeouts,
|
|
294
|
+
stderrPath: await sessionFiles.getServerStderrPath(`${definition.id}\u0000${rootPath}`),
|
|
295
|
+
onUnavailable,
|
|
296
|
+
onWorkspaceEdit: async (edit) =>
|
|
297
|
+
(
|
|
298
|
+
await workspaceEdits.createPreview({
|
|
299
|
+
edit,
|
|
300
|
+
serverId: definition.id,
|
|
301
|
+
positionEncoding: client?.positionEncoding ?? PositionEncodingKind.UTF16,
|
|
302
|
+
})
|
|
303
|
+
).preview_id,
|
|
304
|
+
});
|
|
305
|
+
return client;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
this.session = { cwd: context.cwd, manager, sessionFiles };
|
|
309
|
+
|
|
310
|
+
if (!this.toolRegistered) {
|
|
311
|
+
registerLspTool(this.pi, { manager, workspaceEdits, sessionFiles });
|
|
312
|
+
this.toolRegistered = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private handleToolResult(
|
|
317
|
+
event: ToolResultEvent,
|
|
318
|
+
context: ExtensionContext,
|
|
319
|
+
):
|
|
320
|
+
| Promise<
|
|
321
|
+
| {
|
|
322
|
+
readonly content: ToolResultEvent["content"];
|
|
323
|
+
readonly details: ToolResultEvent["details"];
|
|
324
|
+
readonly isError: boolean;
|
|
325
|
+
}
|
|
326
|
+
| undefined
|
|
327
|
+
>
|
|
328
|
+
| undefined {
|
|
329
|
+
const session = this.session;
|
|
330
|
+
if (session === undefined) return undefined;
|
|
331
|
+
return appendSessionPostEditDiagnostics(event, session, context).then((patch) => {
|
|
332
|
+
if (patch === undefined) return undefined;
|
|
333
|
+
this.pendingPostEditDiagnosticOutcomes.push(...patch.outcomes);
|
|
334
|
+
return { content: patch.content, details: patch.details, isError: patch.isError };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private flushPostEditDiagnosticsEntry(): void {
|
|
339
|
+
const session = this.session;
|
|
340
|
+
const outcomes = this.pendingPostEditDiagnosticOutcomes.splice(0);
|
|
341
|
+
if (session === undefined) return;
|
|
342
|
+
const entry = createPostEditDiagnosticsEntryData(session.cwd, outcomes);
|
|
343
|
+
if (entry !== undefined) this.pi.appendEntry(POST_EDIT_DIAGNOSTICS_ENTRY_TYPE, entry);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async shutdownSession(): Promise<void> {
|
|
347
|
+
if (this.session === undefined) {
|
|
348
|
+
await this.shutdownPromise;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const session = this.session;
|
|
352
|
+
this.session = undefined;
|
|
353
|
+
this.pendingPostEditDiagnosticOutcomes.length = 0;
|
|
354
|
+
const shutdown = (async () => {
|
|
355
|
+
try {
|
|
356
|
+
await session.manager.shutdown();
|
|
357
|
+
} finally {
|
|
358
|
+
await session.sessionFiles.close();
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
this.shutdownPromise = shutdown;
|
|
362
|
+
try {
|
|
363
|
+
await shutdown;
|
|
364
|
+
} finally {
|
|
365
|
+
if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Compose the source-TypeScript Pi LSP extension without starting a language server at load time. */
|
|
371
|
+
export function createPiLspExtension(
|
|
372
|
+
effects: PiLspLifecycleEffects = productionPiLspLifecycleEffects,
|
|
373
|
+
): ExtensionFactory {
|
|
374
|
+
return (pi) => new PiLspLifecycleController(pi, effects).register();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const piLspExtension = createPiLspExtension();
|
|
378
|
+
|
|
379
|
+
export default piLspExtension;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LSP_TIMEOUTS = {
|
|
6
|
+
diagnosticsMs: 3000,
|
|
7
|
+
initializeMs: 45000,
|
|
8
|
+
requestMs: 3000,
|
|
9
|
+
shutdownMs: 5000,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
const NonEmptyStringSchema = Type.String({ minLength: 1 });
|
|
13
|
+
const PositiveMillisecondsSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
14
|
+
const JsonValueSchema = Type.Any();
|
|
15
|
+
const LspLanguageMappingSchema = Type.Object(
|
|
16
|
+
{
|
|
17
|
+
extensions: Type.Optional(Type.Array(NonEmptyStringSchema, { minItems: 1 })),
|
|
18
|
+
fileNames: Type.Optional(Type.Array(NonEmptyStringSchema, { minItems: 1 })),
|
|
19
|
+
languageId: NonEmptyStringSchema,
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
23
|
+
const LspServerDefinitionSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
args: Type.Optional(Type.Array(Type.String())),
|
|
26
|
+
command: Type.Optional(NonEmptyStringSchema),
|
|
27
|
+
environment: Type.Optional(
|
|
28
|
+
Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])),
|
|
29
|
+
),
|
|
30
|
+
initializationOptions: Type.Optional(JsonValueSchema),
|
|
31
|
+
languages: Type.Optional(Type.Array(LspLanguageMappingSchema, { minItems: 1 })),
|
|
32
|
+
rootMarkers: Type.Optional(Type.Array(NonEmptyStringSchema)),
|
|
33
|
+
settings: Type.Optional(JsonValueSchema),
|
|
34
|
+
},
|
|
35
|
+
{ additionalProperties: false },
|
|
36
|
+
);
|
|
37
|
+
const LspTimeoutsSchema = Type.Object(
|
|
38
|
+
{
|
|
39
|
+
diagnosticsMs: Type.Optional(PositiveMillisecondsSchema),
|
|
40
|
+
initializeMs: Type.Optional(PositiveMillisecondsSchema),
|
|
41
|
+
requestMs: Type.Optional(PositiveMillisecondsSchema),
|
|
42
|
+
shutdownMs: Type.Optional(PositiveMillisecondsSchema),
|
|
43
|
+
},
|
|
44
|
+
{ additionalProperties: false },
|
|
45
|
+
);
|
|
46
|
+
const LspLayerSchema = Type.Object(
|
|
47
|
+
{
|
|
48
|
+
servers: Type.Optional(
|
|
49
|
+
Type.Record(
|
|
50
|
+
Type.String({ minLength: 1 }),
|
|
51
|
+
Type.Union([LspServerDefinitionSchema, Type.Null()]),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
timeouts: Type.Optional(LspTimeoutsSchema),
|
|
55
|
+
},
|
|
56
|
+
{ additionalProperties: false },
|
|
57
|
+
);
|
|
58
|
+
const SettingsDocumentSchema = Type.Object({ lsp: Type.Optional(JsonValueSchema) });
|
|
59
|
+
|
|
60
|
+
interface JsonObject {
|
|
61
|
+
readonly [key: string]: JsonValue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
|
|
65
|
+
type LspServerDefinitionWire = Static<typeof LspServerDefinitionSchema>;
|
|
66
|
+
type LspTimeoutsWire = Static<typeof LspTimeoutsSchema>;
|
|
67
|
+
type LspLayerWire = Static<typeof LspLayerSchema>;
|
|
68
|
+
|
|
69
|
+
/** Describes one configured filename or extension mapping to an LSP language identifier. */
|
|
70
|
+
export interface LspLanguageMapping {
|
|
71
|
+
readonly extensions: readonly string[];
|
|
72
|
+
readonly fileNames: readonly string[];
|
|
73
|
+
readonly languageId: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Contains the parsed command and protocol values for one enabled language server. */
|
|
77
|
+
export interface LspServerDefinition {
|
|
78
|
+
readonly args: readonly string[];
|
|
79
|
+
readonly command: string;
|
|
80
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly initializationOptions?: JsonValue;
|
|
83
|
+
readonly languages: readonly LspLanguageMapping[];
|
|
84
|
+
readonly rootMarkers: readonly string[];
|
|
85
|
+
readonly settings?: JsonValue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Contains bounded timeout values used for all requests made to an LSP server. */
|
|
89
|
+
export interface LspTimeouts {
|
|
90
|
+
readonly diagnosticsMs: number;
|
|
91
|
+
readonly initializeMs: number;
|
|
92
|
+
readonly requestMs: number;
|
|
93
|
+
readonly shutdownMs: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reports resolved trusted configuration, or disabled startup when either layer is malformed. */
|
|
97
|
+
export interface ResolvedLspSettings {
|
|
98
|
+
readonly enabled: boolean;
|
|
99
|
+
readonly servers: ReadonlyMap<string, LspServerDefinition>;
|
|
100
|
+
readonly timeouts: LspTimeouts;
|
|
101
|
+
readonly warnings: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Reads Pi's already trust-filtered global and project settings documents. */
|
|
105
|
+
export interface LspSettingsReader {
|
|
106
|
+
getGlobalSettings(): LspSettingsDocumentInput;
|
|
107
|
+
getProjectSettings(): LspSettingsDocumentInput;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Minimal Pi settings document shape containing the extension-owned optional `lsp` value. */
|
|
111
|
+
type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
|
|
112
|
+
|
|
113
|
+
/** Pi's core Settings type or a test/boundary document carrying the extension-owned `lsp` value. */
|
|
114
|
+
export type LspSettingsDocumentInput = PiSettingsDocument | { readonly lsp?: JsonValue };
|
|
115
|
+
|
|
116
|
+
type ParsedLspLayer =
|
|
117
|
+
| { readonly kind: "absent" }
|
|
118
|
+
| { readonly kind: "invalid"; readonly warning: string }
|
|
119
|
+
| { readonly kind: "valid"; readonly value: LspLayerWire };
|
|
120
|
+
|
|
121
|
+
function lspValidationWarning(value: JsonValue, scope: "global" | "project"): string {
|
|
122
|
+
const error = Value.Errors(LspLayerSchema, value)[0];
|
|
123
|
+
const path = error?.instancePath.replaceAll("/", ".") ?? "";
|
|
124
|
+
const unknownField =
|
|
125
|
+
error?.keyword === "additionalProperties" ? error.params.additionalProperties[0] : undefined;
|
|
126
|
+
return `${scope} lsp${path}${unknownField === undefined ? "" : `.${unknownField}`}: ${error?.message ?? "invalid settings"}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readLspLayer(
|
|
130
|
+
settings: LspSettingsDocumentInput,
|
|
131
|
+
scope: "global" | "project",
|
|
132
|
+
): ParsedLspLayer {
|
|
133
|
+
if (!Value.Check(SettingsDocumentSchema, settings)) {
|
|
134
|
+
return { kind: "invalid", warning: `${scope} settings: expected a JSON object` };
|
|
135
|
+
}
|
|
136
|
+
if (settings.lsp === undefined) return { kind: "absent" };
|
|
137
|
+
if (!Value.Check(LspLayerSchema, settings.lsp)) {
|
|
138
|
+
return { kind: "invalid", warning: lspValidationWarning(settings.lsp, scope) };
|
|
139
|
+
}
|
|
140
|
+
for (const [serverId, server] of Object.entries(settings.lsp.servers ?? {})) {
|
|
141
|
+
if (server === null) continue;
|
|
142
|
+
if (server.command === undefined || server.languages === undefined) {
|
|
143
|
+
return {
|
|
144
|
+
kind: "invalid",
|
|
145
|
+
warning: `${scope} lsp.servers.${serverId}: command and languages are required`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
server.languages.some(
|
|
150
|
+
(language) => language.extensions === undefined && language.fileNames === undefined,
|
|
151
|
+
)
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "invalid",
|
|
155
|
+
warning: `${scope} lsp.servers.${serverId}.languages: each language needs extensions or fileNames`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { kind: "valid", value: settings.lsp };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function mergeLspTimeouts(globalLayer: ParsedLspLayer, projectLayer: ParsedLspLayer): LspTimeouts {
|
|
163
|
+
const timeoutValues: readonly (LspTimeoutsWire | undefined)[] = [
|
|
164
|
+
globalLayer.kind === "valid" ? globalLayer.value.timeouts : undefined,
|
|
165
|
+
projectLayer.kind === "valid" ? projectLayer.value.timeouts : undefined,
|
|
166
|
+
];
|
|
167
|
+
return Object.assign({}, DEFAULT_LSP_TIMEOUTS, ...timeoutValues);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveLspEnvironment(
|
|
171
|
+
configuredEnvironment: Readonly<Record<string, string | null>> | undefined,
|
|
172
|
+
) {
|
|
173
|
+
const environment: Record<string, string> = {};
|
|
174
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
175
|
+
if (value !== undefined) environment[key] = value;
|
|
176
|
+
}
|
|
177
|
+
for (const [key, value] of Object.entries(configuredEnvironment ?? {})) {
|
|
178
|
+
if (value === null) {
|
|
179
|
+
delete environment[key];
|
|
180
|
+
} else {
|
|
181
|
+
environment[key] = value;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return environment;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveLspServerDefinition(
|
|
188
|
+
id: string,
|
|
189
|
+
server: LspServerDefinitionWire,
|
|
190
|
+
): LspServerDefinition {
|
|
191
|
+
const languages: LspLanguageMapping[] = (server.languages ?? []).map((language) => ({
|
|
192
|
+
extensions: language.extensions ?? [],
|
|
193
|
+
fileNames: language.fileNames ?? [],
|
|
194
|
+
languageId: language.languageId,
|
|
195
|
+
}));
|
|
196
|
+
const resolved = {
|
|
197
|
+
args: server.args ?? [],
|
|
198
|
+
command: server.command ?? "",
|
|
199
|
+
environment: resolveLspEnvironment(server.environment),
|
|
200
|
+
id,
|
|
201
|
+
languages,
|
|
202
|
+
rootMarkers: server.rootMarkers ?? [],
|
|
203
|
+
};
|
|
204
|
+
const initializationOptions = server.initializationOptions;
|
|
205
|
+
const settings = server.settings;
|
|
206
|
+
if (initializationOptions !== undefined && settings !== undefined) {
|
|
207
|
+
return {
|
|
208
|
+
...resolved,
|
|
209
|
+
initializationOptions: structuredClone(initializationOptions),
|
|
210
|
+
settings: structuredClone(settings),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (initializationOptions !== undefined) {
|
|
214
|
+
return { ...resolved, initializationOptions: structuredClone(initializationOptions) };
|
|
215
|
+
}
|
|
216
|
+
if (settings !== undefined) {
|
|
217
|
+
return { ...resolved, settings: structuredClone(settings) };
|
|
218
|
+
}
|
|
219
|
+
return resolved;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function mergeLspServers(
|
|
223
|
+
globalLayer: ParsedLspLayer,
|
|
224
|
+
projectLayer: ParsedLspLayer,
|
|
225
|
+
): ReadonlyMap<string, LspServerDefinition> {
|
|
226
|
+
const serverDefinitions = new Map<string, LspServerDefinitionWire>();
|
|
227
|
+
const addServers = (layer: ParsedLspLayer, projectLayerValue: boolean): void => {
|
|
228
|
+
if (layer.kind !== "valid") return;
|
|
229
|
+
for (const [id, server] of Object.entries(layer.value.servers ?? {})) {
|
|
230
|
+
if (server === null) {
|
|
231
|
+
if (projectLayerValue) serverDefinitions.delete(id);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
serverDefinitions.set(id, server);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
addServers(globalLayer, false);
|
|
238
|
+
addServers(projectLayer, true);
|
|
239
|
+
return new Map(
|
|
240
|
+
[...serverDefinitions]
|
|
241
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
242
|
+
.map(([id, server]) => [id, resolveLspServerDefinition(id, server)]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Resolve global and trusted-project LSP settings without adding an `lsp` field to Pi's Settings type. */
|
|
247
|
+
export function resolveLspSettings(reader: LspSettingsReader): ResolvedLspSettings {
|
|
248
|
+
const globalLayer = readLspLayer(reader.getGlobalSettings(), "global");
|
|
249
|
+
const projectLayer = readLspLayer(reader.getProjectSettings(), "project");
|
|
250
|
+
const warnings = [globalLayer, projectLayer]
|
|
251
|
+
.filter(
|
|
252
|
+
(layer): layer is Extract<ParsedLspLayer, { readonly kind: "invalid" }> =>
|
|
253
|
+
layer.kind === "invalid",
|
|
254
|
+
)
|
|
255
|
+
.map((layer) => layer.warning);
|
|
256
|
+
const enabled = warnings.length === 0;
|
|
257
|
+
return {
|
|
258
|
+
enabled,
|
|
259
|
+
servers: enabled ? mergeLspServers(globalLayer, projectLayer) : new Map(),
|
|
260
|
+
timeouts: mergeLspTimeouts(globalLayer, projectLayer),
|
|
261
|
+
warnings,
|
|
262
|
+
};
|
|
263
|
+
}
|