@ian-pascoe/pi-lsp 0.1.0 → 0.1.1

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 CHANGED
@@ -45,6 +45,7 @@ Pi LSP reads only the `lsp` key from Pi's global `settings.json` and trusted pro
45
45
  "languageId": "typescriptreact"
46
46
  }
47
47
  ],
48
+ "requireRootMarker": true,
48
49
  "rootMarkers": ["tsconfig.json", "package.json", ".git"],
49
50
  "initializationOptions": {},
50
51
  "settings": {},
@@ -58,14 +59,18 @@ Pi LSP reads only the `lsp` key from Pi's global `settings.json` and trusted pro
58
59
  Every field is optional except an enabled server's non-empty `command` and `languages`. Each
59
60
  language needs a non-empty `languageId` and at least one extension or exact filename. Extensions
60
61
  include their leading period. `rootMarkers` are basename glob patterns; the nearest matching
61
- ancestor becomes the server root and Pi's working directory is the fallback.
62
+ ancestor becomes the server root and Pi's working directory is the fallback. Set
63
+ `requireRootMarker` to `true` to exclude the server for files without any matching ancestor; it
64
+ defaults to `false`. A required empty `rootMarkers` list is invalid. Explicit requests naming an
65
+ otherwise compatible excluded server report that its required root marker was not found.
62
66
 
63
67
  Global and project timeouts merge by field. A project server replaces the complete global server
64
68
  with the same ID; set a project server to `null` to remove it. `initializationOptions` is sent only
65
69
  during initialization. `settings` is used for `workspace/didChangeConfiguration` and
66
70
  `workspace/configuration`. Environment strings override `process.env`; `null` removes a variable.
67
- A malformed layer or unknown field disables LSP startup and remains visible through `status`.
68
- Untrusted project settings are ignored.
71
+ Invalid server definitions and timeout fields are quarantined individually and remain visible
72
+ through `status`; unrelated valid settings continue to work. An invalid project server replacement
73
+ still shadows the global definition. Untrusted project settings are ignored.
69
74
 
70
75
  Pi's `/reload` reloads configuration. Servers start on first use, live for one Pi session, and stay
71
76
  unavailable after a process or protocol failure until `restart` or `/reload`.
@@ -164,6 +169,9 @@ Changed, created, and renamed destination files are diagnosed; deleted files are
164
169
  recognized result gets an explicit outcome, including `no diagnostics`, `no configured server`,
165
170
  timeout, unavailable server, or an `apply_patch` adapter-version warning. Diagnostics preserve
166
171
  duplicates from independent servers and never change the original tool's success or error state.
172
+ Only servers that advertise document diagnostics participate; formatting-only servers remain
173
+ available for explicit LSP formatting operations without appearing in Post-edit Diagnostics.
174
+ Files excluded by every matching server's Activation Gate are skipped silently.
167
175
 
168
176
  Findings, matched-server failures, timeouts, and adapter warnings also appear in one expandable
169
177
  Post-edit Diagnostics Entry after the current tool batch. Clean results and files without a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-lsp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "description": "Configured language-server tools and post-edit diagnostics for Pi",
6
6
  "keywords": [
@@ -30,10 +30,6 @@
30
30
  "access": "public",
31
31
  "provenance": true
32
32
  },
33
- "scripts": {
34
- "test": "vitest run --config ../../vitest.config.ts --root .",
35
- "typecheck": "tsc --noEmit -p tsconfig.json"
36
- },
37
33
  "dependencies": {
38
34
  "cross-spawn": "^7.0.6",
39
35
  "vscode-languageserver-protocol": "^3.17.5"
@@ -53,5 +49,9 @@
53
49
  "extensions": [
54
50
  "./src/index.ts"
55
51
  ]
52
+ },
53
+ "scripts": {
54
+ "test": "vitest run --config ../../vitest.config.ts --root .",
55
+ "typecheck": "tsc --noEmit -p tsconfig.json"
56
56
  }
57
- }
57
+ }
@@ -273,6 +273,7 @@ export async function appendPostEditDiagnostics(
273
273
  })),
274
274
  ...(await diagnostics.runPostEditDiagnostics(extracted.paths)),
275
275
  ];
276
+ if (outcomes.length === 0) return undefined;
276
277
  const patch: PostEditDiagnosticsResultPatch = {
277
278
  content: [...event.content, { type: "text", text: formatPostEditDiagnostics(outcomes) }],
278
279
  details: event.details,
@@ -18,6 +18,8 @@ export interface LspServerRoutingDefinition {
18
18
  readonly serverId: string;
19
19
  /** Languages and file patterns accepted by this server. */
20
20
  readonly languages: readonly LspServerLanguage[];
21
+ /** Exclude this server when none of its root markers exists above the requested file. */
22
+ readonly requireRootMarker?: boolean;
21
23
  /** Basename glob patterns that select this server instance's nearest workspace root. */
22
24
  readonly rootMarkers?: readonly string[];
23
25
  }
@@ -71,6 +73,7 @@ export type LspServerFailureCode =
71
73
  | "no-capable-server"
72
74
  | "no-matching-server"
73
75
  | "request-failed"
76
+ | "root-marker-not-found"
74
77
  | "server-unavailable";
75
78
 
76
79
  /** Preserves one matching server's failure without discarding sibling successes. */
@@ -167,8 +170,11 @@ function findNearestLspRoot(
167
170
  rootMarkers: readonly string[] | undefined,
168
171
  ancestorDirectories: readonly LspAncestorDirectory[],
169
172
  cwd: string,
170
- ): string {
171
- if (rootMarkers === undefined || rootMarkers.length === 0) return resolve(cwd);
173
+ requireRootMarker: boolean,
174
+ ): string | undefined {
175
+ if (rootMarkers === undefined || rootMarkers.length === 0) {
176
+ return requireRootMarker ? undefined : resolve(cwd);
177
+ }
172
178
  for (const directory of ancestorDirectories) {
173
179
  if (
174
180
  directory.entryNames.some((entryName) =>
@@ -178,7 +184,7 @@ function findNearestLspRoot(
178
184
  return directory.path;
179
185
  }
180
186
  }
181
- return resolve(cwd);
187
+ return requireRootMarker ? undefined : resolve(cwd);
182
188
  }
183
189
 
184
190
  /** Route one file to every matching configured server in stable settings-map order. */
@@ -196,10 +202,17 @@ export function routeLspServersForFile(
196
202
  languageMatchesFile(candidate, normalizedFilePath),
197
203
  );
198
204
  if (language === undefined) continue;
205
+ const rootPath = findNearestLspRoot(
206
+ serverDefinition.rootMarkers,
207
+ ancestorDirectories,
208
+ cwd,
209
+ serverDefinition.requireRootMarker ?? false,
210
+ );
211
+ if (rootPath === undefined) continue;
199
212
  routes.push({
200
213
  serverId: serverDefinition.serverId,
201
214
  language,
202
- rootPath: findNearestLspRoot(serverDefinition.rootMarkers, ancestorDirectories, cwd),
215
+ rootPath,
203
216
  });
204
217
  }
205
218
 
@@ -288,12 +301,21 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
288
301
  const ancestors = await readLspAncestorDirectories(absolutePath);
289
302
  const definitions = [...this.input.settings.servers.values()].map((definition) => ({
290
303
  languages: definition.languages,
304
+ requireRootMarker: definition.requireRootMarker,
291
305
  rootMarkers: definition.rootMarkers,
292
306
  serverId: definition.id,
293
307
  }));
294
308
  return routeLspServersForFile(definitions, absolutePath, this.input.cwd, ancestors);
295
309
  }
296
310
 
311
+ /** Report whether any Server Definition accepts the file language before activation gating. */
312
+ hasConfiguredLanguageServerForFile(filePath: string): boolean {
313
+ const absolutePath = resolve(this.input.cwd, normalizeLspFilePath(filePath));
314
+ return [...this.input.settings.servers.values()].some((definition) =>
315
+ definition.languages.some((language) => languageMatchesFile(language, absolutePath)),
316
+ );
317
+ }
318
+
297
319
  /** Query every matching capable instance while retaining independent successes and failures. */
298
320
  async runRead<T>(
299
321
  filePath: string,
@@ -446,10 +468,23 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
446
468
  }
447
469
 
448
470
  private noMatchingFailure(serverId: string | undefined, filePath: string): LspServerFailure {
471
+ const normalizedFilePath = normalizeLspFilePath(filePath);
472
+ const definition =
473
+ serverId === undefined ? undefined : this.input.settings.servers.get(serverId);
474
+ if (
475
+ definition?.requireRootMarker === true &&
476
+ definition.languages.some((language) => languageMatchesFile(language, normalizedFilePath))
477
+ ) {
478
+ return {
479
+ code: "root-marker-not-found",
480
+ message: `Pi LSP: required root marker not found for server ${definition.id} and ${normalizedFilePath}; expected one of: ${definition.rootMarkers.join(", ")}`,
481
+ serverId: definition.id,
482
+ };
483
+ }
449
484
  const requestedServer = serverId === undefined ? "any configured server" : `server ${serverId}`;
450
485
  return {
451
486
  code: "no-matching-server",
452
- message: `Pi LSP: ${requestedServer} does not match ${normalizeLspFilePath(filePath)}`,
487
+ message: `Pi LSP: ${requestedServer} does not match ${normalizedFilePath}`,
453
488
  serverId: serverId ?? "*",
454
489
  };
455
490
  }
@@ -14,7 +14,11 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { type Static, Type } from "typebox";
16
16
  import { Value } from "typebox/value";
17
- import { PositionEncodingKind, type Diagnostic } from "vscode-languageserver-protocol/node";
17
+ import {
18
+ DocumentDiagnosticRequest,
19
+ PositionEncodingKind,
20
+ type Diagnostic,
21
+ } from "vscode-languageserver-protocol/node";
18
22
  import {
19
23
  appendPiPostEditDiagnostics,
20
24
  type PostEditDiagnosticOutcome,
@@ -154,7 +158,7 @@ class ManagerPostEditDiagnosticsRunner implements PostEditDiagnosticsRunner {
154
158
  const result = await this.session.manager.runRead(
155
159
  filePath,
156
160
  undefined,
157
- () => true,
161
+ (client) => client.hasCapability(DocumentDiagnosticRequest.method),
158
162
  async (client, route): Promise<readonly PostEditDiagnosticOutcome[]> => {
159
163
  const diagnostics = await client.documentDiagnostics(
160
164
  filePath,
@@ -185,7 +189,13 @@ class ManagerPostEditDiagnosticsRunner implements PostEditDiagnosticsRunner {
185
189
  const successfulOutcomes = result.successes.flatMap(({ value }) => value);
186
190
  outcomes.push(...successfulOutcomes);
187
191
  outcomes.push(
188
- ...result.failures.map((failure) => failureDiagnosticOutcome(filePath, failure)),
192
+ ...result.failures.flatMap((failure) =>
193
+ failure.code === "no-capable-server" ||
194
+ (failure.code === "no-matching-server" &&
195
+ this.session.manager.hasConfiguredLanguageServerForFile(filePath))
196
+ ? []
197
+ : [failureDiagnosticOutcome(filePath, failure)],
198
+ ),
189
199
  );
190
200
  }
191
201
  return outcomes;
@@ -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,7 +94,7 @@ 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
99
  readonly enabled: boolean;
99
100
  readonly servers: ReadonlyMap<string, LspServerDefinition>;
@@ -113,58 +114,150 @@ type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
113
114
  /** Pi's core Settings type or a test/boundary document carrying the extension-owned `lsp` value. */
114
115
  export type LspSettingsDocumentInput = PiSettingsDocument | { readonly lsp?: JsonValue };
115
116
 
116
- type ParsedLspLayer =
117
- | { readonly kind: "absent" }
118
- | { readonly kind: "invalid"; readonly warning: string }
119
- | { readonly kind: "valid"; readonly value: LspLayerWire };
117
+ interface ParsedLspLayer {
118
+ readonly servers: ReadonlyMap<string, ParsedLspServerDefinition>;
119
+ readonly timeouts: LspTimeoutsWire;
120
+ readonly warnings: readonly string[];
121
+ }
122
+
123
+ function isJsonObject(value: JsonValue): value is JsonObject {
124
+ return Value.Check(JsonObjectSchema, value);
125
+ }
120
126
 
121
- function lspValidationWarning(value: JsonValue, scope: "global" | "project"): string {
122
- const error = Value.Errors(LspLayerSchema, value)[0];
127
+ function schemaValidationWarning(
128
+ schema: typeof LspServerDefinitionSchema | typeof PositiveMillisecondsSchema,
129
+ value: JsonValue,
130
+ prefix: string,
131
+ ): string {
132
+ const error = Value.Errors(schema, value)[0];
123
133
  const path = error?.instancePath.replaceAll("/", ".") ?? "";
124
134
  const unknownField =
125
135
  error?.keyword === "additionalProperties" ? error.params.additionalProperties[0] : undefined;
126
- return `${scope} lsp${path}${unknownField === undefined ? "" : `.${unknownField}`}: ${error?.message ?? "invalid settings"}`;
136
+ const unknownFieldSuffix = unknownField === undefined ? "" : `.${String(unknownField)}`;
137
+ return `${prefix}${path}${unknownFieldSuffix}: ${error?.message ?? "invalid settings"}`;
127
138
  }
128
139
 
129
- function readLspLayer(
130
- settings: LspSettingsDocumentInput,
140
+ function parseLspServerDefinitions(
141
+ value: JsonValue | undefined,
131
142
  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) };
143
+ ): Pick<ParsedLspLayer, "servers" | "warnings"> {
144
+ const warnings: string[] = [];
145
+ const servers = new Map<string, ParsedLspServerDefinition>();
146
+ if (value === undefined) return { servers, warnings };
147
+ if (!isJsonObject(value)) {
148
+ return { servers, warnings: [`${scope} lsp.servers: expected a JSON object`] };
139
149
  }
140
- for (const [serverId, server] of Object.entries(settings.lsp.servers ?? {})) {
141
- if (server === null) continue;
150
+ for (const [id, server] of Object.entries(value)) {
151
+ if (!Value.Check(NonEmptyStringSchema, id)) {
152
+ warnings.push(`${scope} lsp.servers.${String(id)}: server ID must be a non-empty string`);
153
+ continue;
154
+ }
155
+ if (server === null) {
156
+ servers.set(id, { kind: "excluded" });
157
+ continue;
158
+ }
159
+ if (!Value.Check(LspServerDefinitionSchema, server)) {
160
+ warnings.push(
161
+ schemaValidationWarning(LspServerDefinitionSchema, server, `${scope} lsp.servers.${id}`),
162
+ );
163
+ servers.set(id, { kind: "excluded" });
164
+ continue;
165
+ }
142
166
  if (server.command === undefined || server.languages === undefined) {
143
- return {
144
- kind: "invalid",
145
- warning: `${scope} lsp.servers.${serverId}: command and languages are required`,
146
- };
167
+ warnings.push(`${scope} lsp.servers.${id}: command and languages are required`);
168
+ servers.set(id, { kind: "excluded" });
169
+ continue;
147
170
  }
148
171
  if (
149
172
  server.languages.some(
150
173
  (language) => language.extensions === undefined && language.fileNames === undefined,
151
174
  )
152
175
  ) {
153
- return {
154
- kind: "invalid",
155
- warning: `${scope} lsp.servers.${serverId}.languages: each language needs extensions or fileNames`,
156
- };
176
+ warnings.push(
177
+ `${scope} lsp.servers.${id}.languages: each language needs extensions or fileNames`,
178
+ );
179
+ servers.set(id, { kind: "excluded" });
180
+ continue;
181
+ }
182
+ if (server.requireRootMarker === true && (server.rootMarkers?.length ?? 0) === 0) {
183
+ warnings.push(
184
+ `${scope} lsp.servers.${id}.rootMarkers: at least one root marker is required when requireRootMarker is true`,
185
+ );
186
+ servers.set(id, { kind: "excluded" });
187
+ continue;
188
+ }
189
+ servers.set(id, { kind: "valid", value: server });
190
+ }
191
+ return { servers, warnings };
192
+ }
193
+
194
+ function isLspTimeoutName(name: string): name is LspTimeoutName {
195
+ return LSP_TIMEOUT_NAMES.some((timeoutName) => timeoutName === name);
196
+ }
197
+
198
+ function parseLspTimeouts(
199
+ value: JsonValue | undefined,
200
+ scope: "global" | "project",
201
+ ): Pick<ParsedLspLayer, "timeouts" | "warnings"> {
202
+ const warnings: string[] = [];
203
+ const timeouts: LspTimeoutsWire = {};
204
+ if (value === undefined) return { timeouts, warnings };
205
+ if (!isJsonObject(value)) {
206
+ return { timeouts, warnings: [`${scope} lsp.timeouts: expected a JSON object`] };
207
+ }
208
+ for (const [name, timeout] of Object.entries(value)) {
209
+ if (!isLspTimeoutName(name)) {
210
+ warnings.push(`${scope} lsp.timeouts.${name}: unknown field`);
211
+ continue;
157
212
  }
213
+ if (!Value.Check(PositiveMillisecondsSchema, timeout)) {
214
+ warnings.push(
215
+ schemaValidationWarning(
216
+ PositiveMillisecondsSchema,
217
+ timeout,
218
+ `${scope} lsp.timeouts.${name}`,
219
+ ),
220
+ );
221
+ continue;
222
+ }
223
+ timeouts[name] = timeout;
158
224
  }
159
- return { kind: "valid", value: settings.lsp };
225
+ return { timeouts, warnings };
226
+ }
227
+
228
+ function readLspLayer(
229
+ settings: LspSettingsDocumentInput,
230
+ scope: "global" | "project",
231
+ ): ParsedLspLayer {
232
+ if (!Value.Check(SettingsDocumentSchema, settings)) {
233
+ return {
234
+ servers: new Map(),
235
+ timeouts: {},
236
+ warnings: [`${scope} settings: expected a JSON object`],
237
+ };
238
+ }
239
+ if (settings.lsp === undefined) return { servers: new Map(), timeouts: {}, warnings: [] };
240
+ if (!isJsonObject(settings.lsp)) {
241
+ return {
242
+ servers: new Map(),
243
+ timeouts: {},
244
+ warnings: [`${scope} lsp: expected a JSON object`],
245
+ };
246
+ }
247
+ const warnings = Object.keys(settings.lsp)
248
+ .filter((field) => field !== "servers" && field !== "timeouts")
249
+ .map((field) => `${scope} lsp.${field}: unknown field`);
250
+ const parsedServers = parseLspServerDefinitions(settings.lsp.servers, scope);
251
+ const parsedTimeouts = parseLspTimeouts(settings.lsp.timeouts, scope);
252
+ return {
253
+ servers: parsedServers.servers,
254
+ timeouts: parsedTimeouts.timeouts,
255
+ warnings: [...warnings, ...parsedServers.warnings, ...parsedTimeouts.warnings],
256
+ };
160
257
  }
161
258
 
162
259
  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);
260
+ return Object.assign({}, DEFAULT_LSP_TIMEOUTS, globalLayer.timeouts, projectLayer.timeouts);
168
261
  }
169
262
 
170
263
  function resolveLspEnvironment(
@@ -199,6 +292,7 @@ function resolveLspServerDefinition(
199
292
  environment: resolveLspEnvironment(server.environment),
200
293
  id,
201
294
  languages,
295
+ requireRootMarker: server.requireRootMarker ?? false,
202
296
  rootMarkers: server.rootMarkers ?? [],
203
297
  };
204
298
  const initializationOptions = server.initializationOptions;
@@ -224,18 +318,13 @@ function mergeLspServers(
224
318
  projectLayer: ParsedLspLayer,
225
319
  ): ReadonlyMap<string, LspServerDefinition> {
226
320
  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);
321
+ for (const [id, server] of globalLayer.servers) {
322
+ if (server.kind === "valid") serverDefinitions.set(id, server.value);
323
+ }
324
+ for (const [id, server] of projectLayer.servers) {
325
+ serverDefinitions.delete(id);
326
+ if (server.kind === "valid") serverDefinitions.set(id, server.value);
327
+ }
239
328
  return new Map(
240
329
  [...serverDefinitions]
241
330
  .sort(([left], [right]) => left.localeCompare(right))
@@ -243,21 +332,14 @@ function mergeLspServers(
243
332
  );
244
333
  }
245
334
 
246
- /** Resolve global and trusted-project LSP settings without adding an `lsp` field to Pi's Settings type. */
335
+ /** Resolve global and trusted-project LSP settings, quarantining invalid entries instead of disabling valid settings. */
247
336
  export function resolveLspSettings(reader: LspSettingsReader): ResolvedLspSettings {
248
337
  const globalLayer = readLspLayer(reader.getGlobalSettings(), "global");
249
338
  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
339
  return {
258
- enabled,
259
- servers: enabled ? mergeLspServers(globalLayer, projectLayer) : new Map(),
340
+ enabled: true,
341
+ servers: mergeLspServers(globalLayer, projectLayer),
260
342
  timeouts: mergeLspTimeouts(globalLayer, projectLayer),
261
- warnings,
343
+ warnings: [...globalLayer.warnings, ...projectLayer.warnings],
262
344
  };
263
345
  }