@ian-pascoe/pi-lsp 0.1.0 → 0.2.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/README.md +14 -5
- package/package.json +10 -6
- package/skills/pi-lsp/SKILL.md +16 -0
- package/src/lsp-position-encoding.ts +2 -1
- package/src/lsp-post-edit-diagnostics-rendering.ts +2 -1
- package/src/lsp-post-edit-diagnostics.ts +7 -33
- package/src/lsp-server-client.ts +67 -85
- package/src/lsp-server-manager.ts +53 -19
- package/src/lsp-session-files.ts +1 -23
- package/src/lsp-tool-contract.ts +7 -11
- package/src/lsp-tool-output.ts +22 -10
- package/src/lsp-tool-rendering.ts +1 -4
- package/src/lsp-tool.ts +44 -66
- package/src/lsp-workspace-edit.ts +22 -79
- package/src/pi-lsp-extension.ts +28 -25
- package/src/pi-lsp-settings.ts +151 -71
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
chmod,
|
|
4
4
|
lstat,
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
convertLspProtocolPosition,
|
|
28
28
|
normalizeLspPositionEncoding,
|
|
29
29
|
} from "./lsp-position-encoding.js";
|
|
30
|
+
import {
|
|
31
|
+
FileSnapshotSchema,
|
|
32
|
+
LspWorkspaceEditPreviewRecordSchema,
|
|
33
|
+
WorkspaceEditOperationSchema,
|
|
34
|
+
} from "./lsp-tool-contract.js";
|
|
35
|
+
import type { Static } from "typebox";
|
|
30
36
|
|
|
31
37
|
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
32
38
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
@@ -48,63 +54,19 @@ export interface LspMutationManifest {
|
|
|
48
54
|
readonly entries: readonly LspMutationManifestEntry[];
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
| {
|
|
54
|
-
readonly kind: "file";
|
|
55
|
-
readonly content_base64: string;
|
|
56
|
-
readonly hash: string;
|
|
57
|
-
readonly mode: number;
|
|
58
|
-
}
|
|
59
|
-
| { readonly kind: "symlink"; readonly link_target: string; readonly mode: number };
|
|
60
|
-
|
|
61
|
-
type ModifyOperation = {
|
|
62
|
-
readonly kind: "modify";
|
|
63
|
-
readonly named_path: string;
|
|
64
|
-
readonly path: string;
|
|
65
|
-
readonly named_before: FileSnapshot;
|
|
66
|
-
readonly before: FileSnapshot;
|
|
67
|
-
after_base64: string;
|
|
68
|
-
readonly mode: number;
|
|
69
|
-
};
|
|
57
|
+
/** Canonical pre-mutation snapshot of one filesystem path used by guarded rollback. */
|
|
58
|
+
type FileSnapshot = Static<typeof FileSnapshotSchema>;
|
|
70
59
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
readonly mode: number;
|
|
77
|
-
};
|
|
60
|
+
/** Schema-derived operation with a writable `after_base64` for incremental document edits. */
|
|
61
|
+
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
|
62
|
+
type EditableOperation = Writable<
|
|
63
|
+
Extract<Static<typeof WorkspaceEditOperationSchema>, { kind: "modify" | "create" }>
|
|
64
|
+
>;
|
|
78
65
|
|
|
79
|
-
type
|
|
80
|
-
readonly kind: "delete";
|
|
81
|
-
readonly named_path: string;
|
|
82
|
-
readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
type RenameOperation = {
|
|
86
|
-
readonly kind: "rename";
|
|
87
|
-
readonly named_path: string;
|
|
88
|
-
readonly destination_path: string;
|
|
89
|
-
readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
|
|
90
|
-
readonly destination_before: FileSnapshot;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
type NormalizedWorkspaceOperation =
|
|
94
|
-
| ModifyOperation
|
|
95
|
-
| CreateOperation
|
|
96
|
-
| DeleteOperation
|
|
97
|
-
| RenameOperation;
|
|
66
|
+
type NormalizedWorkspaceOperation = Static<typeof WorkspaceEditOperationSchema>;
|
|
98
67
|
|
|
99
68
|
/** Schema-friendly preview record persisted in LSP tool result details. */
|
|
100
|
-
|
|
101
|
-
readonly kind: "workspace_edit_preview";
|
|
102
|
-
readonly preview_id: string;
|
|
103
|
-
readonly server_id: string;
|
|
104
|
-
readonly summary: string;
|
|
105
|
-
readonly state: "available" | "applied";
|
|
106
|
-
readonly operations: readonly NormalizedWorkspaceOperation[];
|
|
107
|
-
}
|
|
69
|
+
type LspWorkspaceEditPreview = Static<typeof LspWorkspaceEditPreviewRecordSchema>;
|
|
108
70
|
|
|
109
71
|
/** Result of one guarded Workspace Edit application. */
|
|
110
72
|
export interface LspWorkspaceEditApplyResult {
|
|
@@ -164,8 +126,6 @@ type WorkspaceEditErrorCode =
|
|
|
164
126
|
|
|
165
127
|
/** Expected preview normalization, validation, apply, or rollback failure. */
|
|
166
128
|
export class LspWorkspaceEditError extends Error {
|
|
167
|
-
readonly _tag = "LspWorkspaceEditError" as const;
|
|
168
|
-
|
|
169
129
|
/** Construct a stable Workspace Edit failure with optional unrecovered paths. */
|
|
170
130
|
constructor(
|
|
171
131
|
readonly code: WorkspaceEditErrorCode,
|
|
@@ -194,16 +154,6 @@ interface DecodedUtf8Document {
|
|
|
194
154
|
readonly text: string;
|
|
195
155
|
}
|
|
196
156
|
|
|
197
|
-
/** Counts accepted and rejected persisted Workspace Edit Preview records. */
|
|
198
|
-
export interface LspWorkspaceEditReplayResult {
|
|
199
|
-
readonly accepted: number;
|
|
200
|
-
readonly rejected: number;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function hashContents(contents: Buffer): string {
|
|
204
|
-
return createHash("sha256").update(contents).digest("hex");
|
|
205
|
-
}
|
|
206
|
-
|
|
207
157
|
function contentsFromSnapshot(snapshot: FileSnapshot): Buffer {
|
|
208
158
|
if (snapshot.kind !== "file") {
|
|
209
159
|
throw new LspWorkspaceEditError("invalid_destination", "expected a regular file");
|
|
@@ -236,7 +186,6 @@ async function snapshotNamedPath(path: string): Promise<FileSnapshot> {
|
|
|
236
186
|
return {
|
|
237
187
|
kind: "file",
|
|
238
188
|
content_base64: contents.toString("base64"),
|
|
239
|
-
hash: hashContents(contents),
|
|
240
189
|
mode,
|
|
241
190
|
};
|
|
242
191
|
}
|
|
@@ -424,10 +373,6 @@ async function restorePath(
|
|
|
424
373
|
await symlink(snapshot.link_target, path);
|
|
425
374
|
}
|
|
426
375
|
|
|
427
|
-
function mutablePreview(preview: LspWorkspaceEditPreview): LspWorkspaceEditPreview {
|
|
428
|
-
return structuredClone(preview);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
376
|
/** Own persisted Workspace Edit Preview state and guarded one-use application. */
|
|
432
377
|
export class LspWorkspaceEditStore {
|
|
433
378
|
private readonly previews = new Map<string, LspWorkspaceEditPreview>();
|
|
@@ -446,7 +391,7 @@ export class LspWorkspaceEditStore {
|
|
|
446
391
|
/** Normalize and persist one language-server Workspace Edit without mutating files. */
|
|
447
392
|
async createPreview(input: CreateWorkspaceEditPreviewInput): Promise<LspWorkspaceEditPreview> {
|
|
448
393
|
const operations: NormalizedWorkspaceOperation[] = [];
|
|
449
|
-
const editableOperations = new Map<string,
|
|
394
|
+
const editableOperations = new Map<string, EditableOperation>();
|
|
450
395
|
const resourceActions = new Map<string, string>();
|
|
451
396
|
const destinations = new Set<string>();
|
|
452
397
|
const encoding = input.positionEncoding ?? "utf-16";
|
|
@@ -500,7 +445,7 @@ export class LspWorkspaceEditStore {
|
|
|
500
445
|
const current = contentsFromSnapshot(before);
|
|
501
446
|
const decoded = decodeUtf8(current, targetPath);
|
|
502
447
|
const after = encodeUtf8(applyTextEdits(decoded.text, edits, encoding), decoded.bom);
|
|
503
|
-
const operation:
|
|
448
|
+
const operation: EditableOperation = {
|
|
504
449
|
kind: "modify",
|
|
505
450
|
named_path: namedPath,
|
|
506
451
|
path: targetPath,
|
|
@@ -540,7 +485,7 @@ export class LspWorkspaceEditStore {
|
|
|
540
485
|
`create destination exists: ${path}`,
|
|
541
486
|
);
|
|
542
487
|
}
|
|
543
|
-
const operation:
|
|
488
|
+
const operation: EditableOperation = {
|
|
544
489
|
kind: "create",
|
|
545
490
|
named_path: path,
|
|
546
491
|
before,
|
|
@@ -649,18 +594,16 @@ export class LspWorkspaceEditStore {
|
|
|
649
594
|
}
|
|
650
595
|
|
|
651
596
|
/** Rebuild branch-local available/applied preview state from persisted tool result records. */
|
|
652
|
-
replayPreviewRecords(records: readonly LSPAny[]):
|
|
653
|
-
let accepted = 0;
|
|
597
|
+
replayPreviewRecords(records: readonly LSPAny[]): number {
|
|
654
598
|
let rejected = 0;
|
|
655
599
|
for (const record of records) {
|
|
656
600
|
if (!isWorkspaceEditPreview(record)) {
|
|
657
601
|
rejected++;
|
|
658
602
|
continue;
|
|
659
603
|
}
|
|
660
|
-
this.previews.set(record.preview_id,
|
|
661
|
-
accepted++;
|
|
604
|
+
this.previews.set(record.preview_id, structuredClone(record));
|
|
662
605
|
}
|
|
663
|
-
return
|
|
606
|
+
return rejected;
|
|
664
607
|
}
|
|
665
608
|
|
|
666
609
|
/** Revalidate and apply one preview inside every sorted canonical mutation queue. */
|
package/src/pi-lsp-extension.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
DEFAULT_MAX_BYTES,
|
|
5
|
-
DEFAULT_MAX_LINES,
|
|
6
4
|
getAgentDir,
|
|
7
5
|
SettingsManager,
|
|
8
|
-
truncateHead,
|
|
9
6
|
type ExtensionAPI,
|
|
10
7
|
type ExtensionContext,
|
|
11
8
|
type ExtensionFactory,
|
|
@@ -14,13 +11,16 @@ import {
|
|
|
14
11
|
} from "@earendil-works/pi-coding-agent";
|
|
15
12
|
import { type Static, Type } from "typebox";
|
|
16
13
|
import { Value } from "typebox/value";
|
|
17
|
-
import { PositionEncodingKind, type Diagnostic } from "vscode-languageserver-protocol/node";
|
|
18
14
|
import {
|
|
19
|
-
|
|
15
|
+
DocumentDiagnosticRequest,
|
|
16
|
+
PositionEncodingKind,
|
|
17
|
+
type Diagnostic,
|
|
18
|
+
} from "vscode-languageserver-protocol/node";
|
|
19
|
+
import {
|
|
20
|
+
appendPostEditDiagnostics,
|
|
20
21
|
type PostEditDiagnosticOutcome,
|
|
21
22
|
type PostEditDiagnosticPath,
|
|
22
23
|
type PostEditDiagnosticsResultPatch,
|
|
23
|
-
type PostEditDiagnosticsRunner,
|
|
24
24
|
} from "./lsp-post-edit-diagnostics.js";
|
|
25
25
|
import {
|
|
26
26
|
createPostEditDiagnosticsEntryData,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type LspWorkspaceEditPreviewRecord,
|
|
42
42
|
} from "./lsp-tool-contract.js";
|
|
43
43
|
import { registerLspTool } from "./lsp-tool.js";
|
|
44
|
+
import { truncateLspOutputText } from "./lsp-tool-output.js";
|
|
44
45
|
import { LspWorkspaceEditStore } from "./lsp-workspace-edit.js";
|
|
45
46
|
import { resolveLspSettings } from "./pi-lsp-settings.js";
|
|
46
47
|
|
|
@@ -139,13 +140,13 @@ function failureDiagnosticOutcome(
|
|
|
139
140
|
return { kind: "unavailable_server", path, serverId: failure.serverId };
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
class ManagerPostEditDiagnosticsRunner
|
|
143
|
+
class ManagerPostEditDiagnosticsRunner {
|
|
143
144
|
constructor(
|
|
144
145
|
private readonly session: ActivePiLspSession,
|
|
145
146
|
private readonly signal: AbortSignal | undefined,
|
|
146
147
|
) {}
|
|
147
148
|
|
|
148
|
-
async
|
|
149
|
+
async run(
|
|
149
150
|
paths: readonly PostEditDiagnosticPath[],
|
|
150
151
|
): Promise<readonly PostEditDiagnosticOutcome[]> {
|
|
151
152
|
const outcomes: PostEditDiagnosticOutcome[] = [];
|
|
@@ -154,7 +155,7 @@ class ManagerPostEditDiagnosticsRunner implements PostEditDiagnosticsRunner {
|
|
|
154
155
|
const result = await this.session.manager.runRead(
|
|
155
156
|
filePath,
|
|
156
157
|
undefined,
|
|
157
|
-
() =>
|
|
158
|
+
(client) => client.hasCapability(DocumentDiagnosticRequest.method),
|
|
158
159
|
async (client, route): Promise<readonly PostEditDiagnosticOutcome[]> => {
|
|
159
160
|
const diagnostics = await client.documentDiagnostics(
|
|
160
161
|
filePath,
|
|
@@ -185,7 +186,13 @@ class ManagerPostEditDiagnosticsRunner implements PostEditDiagnosticsRunner {
|
|
|
185
186
|
const successfulOutcomes = result.successes.flatMap(({ value }) => value);
|
|
186
187
|
outcomes.push(...successfulOutcomes);
|
|
187
188
|
outcomes.push(
|
|
188
|
-
...result.failures.
|
|
189
|
+
...result.failures.flatMap((failure) =>
|
|
190
|
+
failure.code === "no-capable-server" ||
|
|
191
|
+
(failure.code === "no-matching-server" &&
|
|
192
|
+
this.session.manager.hasConfiguredLanguageServerForFile(filePath))
|
|
193
|
+
? []
|
|
194
|
+
: [failureDiagnosticOutcome(filePath, failure)],
|
|
195
|
+
),
|
|
189
196
|
);
|
|
190
197
|
}
|
|
191
198
|
return outcomes;
|
|
@@ -197,24 +204,20 @@ async function appendSessionPostEditDiagnostics(
|
|
|
197
204
|
session: ActivePiLspSession,
|
|
198
205
|
context: ExtensionContext,
|
|
199
206
|
): Promise<PostEditDiagnosticsResultPatch | undefined> {
|
|
200
|
-
const patch = await
|
|
201
|
-
|
|
202
|
-
new ManagerPostEditDiagnosticsRunner(session, context.signal),
|
|
207
|
+
const patch = await appendPostEditDiagnostics(event, (paths) =>
|
|
208
|
+
new ManagerPostEditDiagnosticsRunner(session, context.signal).run(paths),
|
|
203
209
|
);
|
|
204
210
|
if (patch === undefined) return undefined;
|
|
205
211
|
const appendedValue = patch.content.at(-1);
|
|
206
212
|
if (!Value.Check(AppendedTextContentSchema, appendedValue)) return undefined;
|
|
207
213
|
let appended: Static<typeof AppendedTextContentSchema> = appendedValue;
|
|
208
|
-
const truncation =
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
appended = {
|
|
215
|
-
type: "text",
|
|
216
|
-
text: `${truncation.content}\n\n[Pi LSP: diagnostics truncated; complete Result Spill: ${spillPath}]`,
|
|
217
|
-
};
|
|
214
|
+
const truncation = await truncateLspOutputText(
|
|
215
|
+
appended.text,
|
|
216
|
+
session.sessionFiles,
|
|
217
|
+
"diagnostics",
|
|
218
|
+
);
|
|
219
|
+
if (truncation.spillPath !== undefined) {
|
|
220
|
+
appended = { type: "text", text: truncation.text };
|
|
218
221
|
}
|
|
219
222
|
const partialApplyFailure =
|
|
220
223
|
event.toolName === "lsp" &&
|
|
@@ -270,9 +273,9 @@ export class PiLspLifecycleController {
|
|
|
270
273
|
const replay = workspaceEdits.replayPreviewRecords(
|
|
271
274
|
branchLspToolResultDetails(context.sessionManager.getBranch()),
|
|
272
275
|
);
|
|
273
|
-
if (replay
|
|
276
|
+
if (replay > 0) {
|
|
274
277
|
context.ui.notify(
|
|
275
|
-
`Pi LSP ignored ${replay
|
|
278
|
+
`Pi LSP ignored ${replay} invalid Workspace Edit Preview record${replay === 1 ? "" : "s"} on the active session branch.`,
|
|
276
279
|
"warning",
|
|
277
280
|
);
|
|
278
281
|
}
|
package/src/pi-lsp-settings.ts
CHANGED
|
@@ -12,6 +12,7 @@ const DEFAULT_LSP_TIMEOUTS = {
|
|
|
12
12
|
const NonEmptyStringSchema = Type.String({ minLength: 1 });
|
|
13
13
|
const PositiveMillisecondsSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
14
14
|
const JsonValueSchema = Type.Any();
|
|
15
|
+
const JsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
|
|
15
16
|
const LspLanguageMappingSchema = Type.Object(
|
|
16
17
|
{
|
|
17
18
|
extensions: Type.Optional(Type.Array(NonEmptyStringSchema, { minItems: 1 })),
|
|
@@ -29,6 +30,7 @@ const LspServerDefinitionSchema = Type.Object(
|
|
|
29
30
|
),
|
|
30
31
|
initializationOptions: Type.Optional(JsonValueSchema),
|
|
31
32
|
languages: Type.Optional(Type.Array(LspLanguageMappingSchema, { minItems: 1 })),
|
|
33
|
+
requireRootMarker: Type.Optional(Type.Boolean()),
|
|
32
34
|
rootMarkers: Type.Optional(Type.Array(NonEmptyStringSchema)),
|
|
33
35
|
settings: Type.Optional(JsonValueSchema),
|
|
34
36
|
},
|
|
@@ -43,18 +45,6 @@ const LspTimeoutsSchema = Type.Object(
|
|
|
43
45
|
},
|
|
44
46
|
{ additionalProperties: false },
|
|
45
47
|
);
|
|
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
48
|
const SettingsDocumentSchema = Type.Object({ lsp: Type.Optional(JsonValueSchema) });
|
|
59
49
|
|
|
60
50
|
interface JsonObject {
|
|
@@ -64,7 +54,16 @@ interface JsonObject {
|
|
|
64
54
|
type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
|
|
65
55
|
type LspServerDefinitionWire = Static<typeof LspServerDefinitionSchema>;
|
|
66
56
|
type LspTimeoutsWire = Static<typeof LspTimeoutsSchema>;
|
|
67
|
-
type
|
|
57
|
+
type LspTimeoutName = keyof typeof DEFAULT_LSP_TIMEOUTS;
|
|
58
|
+
const LSP_TIMEOUT_NAMES: readonly LspTimeoutName[] = [
|
|
59
|
+
"diagnosticsMs",
|
|
60
|
+
"initializeMs",
|
|
61
|
+
"requestMs",
|
|
62
|
+
"shutdownMs",
|
|
63
|
+
];
|
|
64
|
+
type ParsedLspServerDefinition =
|
|
65
|
+
| { readonly kind: "excluded" }
|
|
66
|
+
| { readonly kind: "valid"; readonly value: LspServerDefinitionWire };
|
|
68
67
|
|
|
69
68
|
/** Describes one configured filename or extension mapping to an LSP language identifier. */
|
|
70
69
|
export interface LspLanguageMapping {
|
|
@@ -73,7 +72,7 @@ export interface LspLanguageMapping {
|
|
|
73
72
|
readonly languageId: string;
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
/** Contains the parsed command and protocol values for one
|
|
75
|
+
/** Contains the parsed command and protocol values for one configured language server. */
|
|
77
76
|
export interface LspServerDefinition {
|
|
78
77
|
readonly args: readonly string[];
|
|
79
78
|
readonly command: string;
|
|
@@ -81,6 +80,8 @@ export interface LspServerDefinition {
|
|
|
81
80
|
readonly id: string;
|
|
82
81
|
readonly initializationOptions?: JsonValue;
|
|
83
82
|
readonly languages: readonly LspLanguageMapping[];
|
|
83
|
+
/** Require any root marker above a candidate file; false falls back to Pi's working directory. */
|
|
84
|
+
readonly requireRootMarker: boolean;
|
|
84
85
|
readonly rootMarkers: readonly string[];
|
|
85
86
|
readonly settings?: JsonValue;
|
|
86
87
|
}
|
|
@@ -93,9 +94,8 @@ export interface LspTimeouts {
|
|
|
93
94
|
readonly shutdownMs: number;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
/** Reports resolved trusted configuration,
|
|
97
|
+
/** Reports resolved trusted configuration, retaining valid entries when other settings are invalid. */
|
|
97
98
|
export interface ResolvedLspSettings {
|
|
98
|
-
readonly enabled: boolean;
|
|
99
99
|
readonly servers: ReadonlyMap<string, LspServerDefinition>;
|
|
100
100
|
readonly timeouts: LspTimeouts;
|
|
101
101
|
readonly warnings: readonly string[];
|
|
@@ -113,58 +113,150 @@ type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
|
|
|
113
113
|
/** Pi's core Settings type or a test/boundary document carrying the extension-owned `lsp` value. */
|
|
114
114
|
export type LspSettingsDocumentInput = PiSettingsDocument | { readonly lsp?: JsonValue };
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
interface ParsedLspLayer {
|
|
117
|
+
readonly servers: ReadonlyMap<string, ParsedLspServerDefinition>;
|
|
118
|
+
readonly timeouts: LspTimeoutsWire;
|
|
119
|
+
readonly warnings: readonly string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isJsonObject(value: JsonValue): value is JsonObject {
|
|
123
|
+
return Value.Check(JsonObjectSchema, value);
|
|
124
|
+
}
|
|
120
125
|
|
|
121
|
-
function
|
|
122
|
-
|
|
126
|
+
function schemaValidationWarning(
|
|
127
|
+
schema: typeof LspServerDefinitionSchema | typeof PositiveMillisecondsSchema,
|
|
128
|
+
value: JsonValue,
|
|
129
|
+
prefix: string,
|
|
130
|
+
): string {
|
|
131
|
+
const error = Value.Errors(schema, value)[0];
|
|
123
132
|
const path = error?.instancePath.replaceAll("/", ".") ?? "";
|
|
124
133
|
const unknownField =
|
|
125
134
|
error?.keyword === "additionalProperties" ? error.params.additionalProperties[0] : undefined;
|
|
126
|
-
|
|
135
|
+
const unknownFieldSuffix = unknownField === undefined ? "" : `.${String(unknownField)}`;
|
|
136
|
+
return `${prefix}${path}${unknownFieldSuffix}: ${error?.message ?? "invalid settings"}`;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
function
|
|
130
|
-
|
|
139
|
+
function parseLspServerDefinitions(
|
|
140
|
+
value: JsonValue | undefined,
|
|
131
141
|
scope: "global" | "project",
|
|
132
|
-
): ParsedLspLayer {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
return { kind: "invalid", warning: lspValidationWarning(settings.lsp, scope) };
|
|
142
|
+
): Pick<ParsedLspLayer, "servers" | "warnings"> {
|
|
143
|
+
const warnings: string[] = [];
|
|
144
|
+
const servers = new Map<string, ParsedLspServerDefinition>();
|
|
145
|
+
if (value === undefined) return { servers, warnings };
|
|
146
|
+
if (!isJsonObject(value)) {
|
|
147
|
+
return { servers, warnings: [`${scope} lsp.servers: expected a JSON object`] };
|
|
139
148
|
}
|
|
140
|
-
for (const [
|
|
141
|
-
if (
|
|
149
|
+
for (const [id, server] of Object.entries(value)) {
|
|
150
|
+
if (!Value.Check(NonEmptyStringSchema, id)) {
|
|
151
|
+
warnings.push(`${scope} lsp.servers.${String(id)}: server ID must be a non-empty string`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (server === null) {
|
|
155
|
+
servers.set(id, { kind: "excluded" });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!Value.Check(LspServerDefinitionSchema, server)) {
|
|
159
|
+
warnings.push(
|
|
160
|
+
schemaValidationWarning(LspServerDefinitionSchema, server, `${scope} lsp.servers.${id}`),
|
|
161
|
+
);
|
|
162
|
+
servers.set(id, { kind: "excluded" });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
142
165
|
if (server.command === undefined || server.languages === undefined) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
166
|
+
warnings.push(`${scope} lsp.servers.${id}: command and languages are required`);
|
|
167
|
+
servers.set(id, { kind: "excluded" });
|
|
168
|
+
continue;
|
|
147
169
|
}
|
|
148
170
|
if (
|
|
149
171
|
server.languages.some(
|
|
150
172
|
(language) => language.extensions === undefined && language.fileNames === undefined,
|
|
151
173
|
)
|
|
152
174
|
) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
};
|
|
175
|
+
warnings.push(
|
|
176
|
+
`${scope} lsp.servers.${id}.languages: each language needs extensions or fileNames`,
|
|
177
|
+
);
|
|
178
|
+
servers.set(id, { kind: "excluded" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (server.requireRootMarker === true && (server.rootMarkers?.length ?? 0) === 0) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`${scope} lsp.servers.${id}.rootMarkers: at least one root marker is required when requireRootMarker is true`,
|
|
184
|
+
);
|
|
185
|
+
servers.set(id, { kind: "excluded" });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
servers.set(id, { kind: "valid", value: server });
|
|
189
|
+
}
|
|
190
|
+
return { servers, warnings };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isLspTimeoutName(name: string): name is LspTimeoutName {
|
|
194
|
+
return LSP_TIMEOUT_NAMES.some((timeoutName) => timeoutName === name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseLspTimeouts(
|
|
198
|
+
value: JsonValue | undefined,
|
|
199
|
+
scope: "global" | "project",
|
|
200
|
+
): Pick<ParsedLspLayer, "timeouts" | "warnings"> {
|
|
201
|
+
const warnings: string[] = [];
|
|
202
|
+
const timeouts: LspTimeoutsWire = {};
|
|
203
|
+
if (value === undefined) return { timeouts, warnings };
|
|
204
|
+
if (!isJsonObject(value)) {
|
|
205
|
+
return { timeouts, warnings: [`${scope} lsp.timeouts: expected a JSON object`] };
|
|
206
|
+
}
|
|
207
|
+
for (const [name, timeout] of Object.entries(value)) {
|
|
208
|
+
if (!isLspTimeoutName(name)) {
|
|
209
|
+
warnings.push(`${scope} lsp.timeouts.${name}: unknown field`);
|
|
210
|
+
continue;
|
|
157
211
|
}
|
|
212
|
+
if (!Value.Check(PositiveMillisecondsSchema, timeout)) {
|
|
213
|
+
warnings.push(
|
|
214
|
+
schemaValidationWarning(
|
|
215
|
+
PositiveMillisecondsSchema,
|
|
216
|
+
timeout,
|
|
217
|
+
`${scope} lsp.timeouts.${name}`,
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
timeouts[name] = timeout;
|
|
158
223
|
}
|
|
159
|
-
return {
|
|
224
|
+
return { timeouts, warnings };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readLspLayer(
|
|
228
|
+
settings: LspSettingsDocumentInput,
|
|
229
|
+
scope: "global" | "project",
|
|
230
|
+
): ParsedLspLayer {
|
|
231
|
+
if (!Value.Check(SettingsDocumentSchema, settings)) {
|
|
232
|
+
return {
|
|
233
|
+
servers: new Map(),
|
|
234
|
+
timeouts: {},
|
|
235
|
+
warnings: [`${scope} settings: expected a JSON object`],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (settings.lsp === undefined) return { servers: new Map(), timeouts: {}, warnings: [] };
|
|
239
|
+
if (!isJsonObject(settings.lsp)) {
|
|
240
|
+
return {
|
|
241
|
+
servers: new Map(),
|
|
242
|
+
timeouts: {},
|
|
243
|
+
warnings: [`${scope} lsp: expected a JSON object`],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const warnings = Object.keys(settings.lsp)
|
|
247
|
+
.filter((field) => field !== "servers" && field !== "timeouts")
|
|
248
|
+
.map((field) => `${scope} lsp.${field}: unknown field`);
|
|
249
|
+
const parsedServers = parseLspServerDefinitions(settings.lsp.servers, scope);
|
|
250
|
+
const parsedTimeouts = parseLspTimeouts(settings.lsp.timeouts, scope);
|
|
251
|
+
return {
|
|
252
|
+
servers: parsedServers.servers,
|
|
253
|
+
timeouts: parsedTimeouts.timeouts,
|
|
254
|
+
warnings: [...warnings, ...parsedServers.warnings, ...parsedTimeouts.warnings],
|
|
255
|
+
};
|
|
160
256
|
}
|
|
161
257
|
|
|
162
258
|
function mergeLspTimeouts(globalLayer: ParsedLspLayer, projectLayer: ParsedLspLayer): LspTimeouts {
|
|
163
|
-
|
|
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);
|
|
259
|
+
return Object.assign({}, DEFAULT_LSP_TIMEOUTS, globalLayer.timeouts, projectLayer.timeouts);
|
|
168
260
|
}
|
|
169
261
|
|
|
170
262
|
function resolveLspEnvironment(
|
|
@@ -199,6 +291,7 @@ function resolveLspServerDefinition(
|
|
|
199
291
|
environment: resolveLspEnvironment(server.environment),
|
|
200
292
|
id,
|
|
201
293
|
languages,
|
|
294
|
+
requireRootMarker: server.requireRootMarker ?? false,
|
|
202
295
|
rootMarkers: server.rootMarkers ?? [],
|
|
203
296
|
};
|
|
204
297
|
const initializationOptions = server.initializationOptions;
|
|
@@ -224,18 +317,13 @@ function mergeLspServers(
|
|
|
224
317
|
projectLayer: ParsedLspLayer,
|
|
225
318
|
): ReadonlyMap<string, LspServerDefinition> {
|
|
226
319
|
const serverDefinitions = new Map<string, LspServerDefinitionWire>();
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
serverDefinitions.set(id, server);
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
addServers(globalLayer, false);
|
|
238
|
-
addServers(projectLayer, true);
|
|
320
|
+
for (const [id, server] of globalLayer.servers) {
|
|
321
|
+
if (server.kind === "valid") serverDefinitions.set(id, server.value);
|
|
322
|
+
}
|
|
323
|
+
for (const [id, server] of projectLayer.servers) {
|
|
324
|
+
serverDefinitions.delete(id);
|
|
325
|
+
if (server.kind === "valid") serverDefinitions.set(id, server.value);
|
|
326
|
+
}
|
|
239
327
|
return new Map(
|
|
240
328
|
[...serverDefinitions]
|
|
241
329
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
@@ -243,21 +331,13 @@ function mergeLspServers(
|
|
|
243
331
|
);
|
|
244
332
|
}
|
|
245
333
|
|
|
246
|
-
/** Resolve global and trusted-project LSP settings
|
|
334
|
+
/** Resolve global and trusted-project LSP settings, quarantining invalid entries instead of disabling valid settings. */
|
|
247
335
|
export function resolveLspSettings(reader: LspSettingsReader): ResolvedLspSettings {
|
|
248
336
|
const globalLayer = readLspLayer(reader.getGlobalSettings(), "global");
|
|
249
337
|
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
338
|
return {
|
|
258
|
-
|
|
259
|
-
servers: enabled ? mergeLspServers(globalLayer, projectLayer) : new Map(),
|
|
339
|
+
servers: mergeLspServers(globalLayer, projectLayer),
|
|
260
340
|
timeouts: mergeLspTimeouts(globalLayer, projectLayer),
|
|
261
|
-
warnings,
|
|
341
|
+
warnings: [...globalLayer.warnings, ...projectLayer.warnings],
|
|
262
342
|
};
|
|
263
343
|
}
|