@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.
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto";
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
- type FileSnapshot =
52
- | { readonly kind: "missing" }
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
- type CreateOperation = {
72
- readonly kind: "create";
73
- readonly named_path: string;
74
- readonly before: FileSnapshot;
75
- after_base64: string;
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 DeleteOperation = {
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
- export interface LspWorkspaceEditPreview {
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, ModifyOperation | CreateOperation>();
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: ModifyOperation = {
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: CreateOperation = {
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[]): LspWorkspaceEditReplayResult {
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, mutablePreview(record));
661
- accepted++;
604
+ this.previews.set(record.preview_id, structuredClone(record));
662
605
  }
663
- return { accepted, rejected };
606
+ return rejected;
664
607
  }
665
608
 
666
609
  /** Revalidate and apply one preview inside every sorted canonical mutation queue. */
@@ -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
- appendPiPostEditDiagnostics,
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 implements PostEditDiagnosticsRunner {
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 runPostEditDiagnostics(
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
- () => true,
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.map((failure) => failureDiagnosticOutcome(filePath, failure)),
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 appendPiPostEditDiagnostics(
201
- event,
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 = 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
- };
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.rejected > 0) {
276
+ if (replay > 0) {
274
277
  context.ui.notify(
275
- `Pi LSP ignored ${replay.rejected} invalid Workspace Edit Preview record${replay.rejected === 1 ? "" : "s"} on the active session branch.`,
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
  }
@@ -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 LspLayerWire = Static<typeof LspLayerSchema>;
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 enabled language server. */
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, or disabled startup when either layer is malformed. */
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
- type ParsedLspLayer =
117
- | { readonly kind: "absent" }
118
- | { readonly kind: "invalid"; readonly warning: string }
119
- | { readonly kind: "valid"; readonly value: LspLayerWire };
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 lspValidationWarning(value: JsonValue, scope: "global" | "project"): string {
122
- const error = Value.Errors(LspLayerSchema, value)[0];
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
- return `${scope} lsp${path}${unknownField === undefined ? "" : `.${unknownField}`}: ${error?.message ?? "invalid settings"}`;
135
+ const unknownFieldSuffix = unknownField === undefined ? "" : `.${String(unknownField)}`;
136
+ return `${prefix}${path}${unknownFieldSuffix}: ${error?.message ?? "invalid settings"}`;
127
137
  }
128
138
 
129
- function readLspLayer(
130
- settings: LspSettingsDocumentInput,
139
+ function parseLspServerDefinitions(
140
+ value: JsonValue | undefined,
131
141
  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) };
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 [serverId, server] of Object.entries(settings.lsp.servers ?? {})) {
141
- if (server === null) continue;
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
- return {
144
- kind: "invalid",
145
- warning: `${scope} lsp.servers.${serverId}: command and languages are required`,
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
- return {
154
- kind: "invalid",
155
- warning: `${scope} lsp.servers.${serverId}.languages: each language needs extensions or fileNames`,
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 { kind: "valid", value: settings.lsp };
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
- 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);
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
- 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);
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 without adding an `lsp` field to Pi's Settings type. */
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
- enabled,
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
  }