@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 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`.
@@ -97,8 +102,9 @@ UTF-32 encoding. `selection_ranges` accepts a `positions` array. `find_reference
97
102
  `workspace_symbols` requires `query` and a root-anchor `file_path`. `workspace_diagnostics`,
98
103
  `capabilities`, and `restart` require `server_id` and a root-anchor `file_path`. Other reads query
99
104
  every matching capable server unless narrowed by `server_id`; successful responses remain visible
100
- when another server fails. A mutation may omit `server_id` only when exactly one matching capable
101
- server exists.
105
+ when another server fails. Automatic reads omit matching incapable servers and fail once if none
106
+ are capable; explicitly selecting an incapable server reports that the operation is unsupported. A
107
+ mutation may omit `server_id` only when exactly one matching capable server exists.
102
108
 
103
109
  Formatting requires `tab_size` and `insert_spaces`. It optionally accepts
104
110
  `trim_trailing_whitespace`, `insert_final_newline`, and `trim_final_newlines`. Range formatting also
@@ -164,6 +170,9 @@ Changed, created, and renamed destination files are diagnosed; deleted files are
164
170
  recognized result gets an explicit outcome, including `no diagnostics`, `no configured server`,
165
171
  timeout, unavailable server, or an `apply_patch` adapter-version warning. Diagnostics preserve
166
172
  duplicates from independent servers and never change the original tool's success or error state.
173
+ Only servers that advertise document diagnostics participate; formatting-only servers remain
174
+ available for explicit LSP formatting operations without appearing in Post-edit Diagnostics.
175
+ Files excluded by every matching server's Activation Gate are skipped silently.
167
176
 
168
177
  Findings, matched-server failures, timeouts, and adapter warnings also appear in one expandable
169
178
  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.2.0",
4
4
  "private": false,
5
5
  "description": "Configured language-server tools and post-edit diagnostics for Pi",
6
6
  "keywords": [
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
+ "skills",
25
26
  "README.md",
26
27
  "LICENSE"
27
28
  ],
@@ -30,10 +31,6 @@
30
31
  "access": "public",
31
32
  "provenance": true
32
33
  },
33
- "scripts": {
34
- "test": "vitest run --config ../../vitest.config.ts --root .",
35
- "typecheck": "tsc --noEmit -p tsconfig.json"
36
- },
37
34
  "dependencies": {
38
35
  "cross-spawn": "^7.0.6",
39
36
  "vscode-languageserver-protocol": "^3.17.5"
@@ -52,6 +49,13 @@
52
49
  "pi": {
53
50
  "extensions": [
54
51
  "./src/index.ts"
52
+ ],
53
+ "skills": [
54
+ "./skills"
55
55
  ]
56
+ },
57
+ "scripts": {
58
+ "test": "vitest run --config ../../vitest.config.ts --root .",
59
+ "typecheck": "tsc --noEmit -p tsconfig.json"
56
60
  }
57
- }
61
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: pi-lsp
3
+ description: Configure or diagnose Pi LSP when a Server Definition fails, a file does not route, diagnostics are missing, or lsp settings need changing.
4
+ license: MIT
5
+ ---
6
+
7
+ # Pi LSP
8
+
9
+ 1. Read [`../../README.md`](../../README.md)'s Settings and `lsp` tool sections, then identify the effective settings scope.
10
+ 2. Call `lsp` with `{"operation":"status"}`.
11
+ 3. Test a representative file with `capabilities`, then `diagnostics`, supplying `server_id` when needed.
12
+ 4. Classify the result as settings, routing, process, capability, or Post-edit Diagnostics behavior.
13
+ 5. If settings changed, reload Pi; if settings are unchanged and the Server Instance is unavailable, use `restart`.
14
+ 6. Repeat status, capabilities, and diagnostics. Finish when the representative file reaches the intended Server Instance and operation, or an exact unsupported capability is evidenced.
15
+
16
+ `configured` has not routed a file yet. `unavailable` is sticky and retains stderr. Keep diagnosis read-only; stop before applying a Workspace Edit Preview.
@@ -24,7 +24,8 @@ export function normalizeLspPositionEncoding(encoding: string | undefined): LspP
24
24
  return "utf-16";
25
25
  }
26
26
 
27
- function documentLines(documentText: string): readonly string[] {
27
+ /** Split document text into lines on CRLF, LF, or CR separators. */
28
+ export function documentLines(documentText: string): readonly string[] {
28
29
  return documentText.split(/\r\n|[\n\r]/u);
29
30
  }
30
31
 
@@ -68,7 +68,8 @@ function severityColor(severity: DiagnosticSeverity): ThemeColor {
68
68
  }
69
69
  }
70
70
 
71
- function pluralizedCount(count: number, singular: string, plural = `${singular}s`): string {
71
+ /** Render one count with a pluralized noun, e.g. `3 files` or `1 file`. */
72
+ export function pluralizedCount(count: number, singular: string, plural = `${singular}s`): string {
72
73
  return `${count} ${count === 1 ? singular : plural}`;
73
74
  }
74
75
 
@@ -98,28 +98,9 @@ export const PostEditDiagnosticOutcomeSchema = Type.Union([
98
98
  export type PostEditDiagnosticOutcome = Static<typeof PostEditDiagnosticOutcomeSchema>;
99
99
 
100
100
  /** Runs fresh Post-edit Diagnostics for changed paths after a Supported Mutation Tool result. */
101
- export interface PostEditDiagnosticsRunner {
102
- /** Return every fresh diagnostic and explicit non-diagnostic outcome for the supplied paths. */
103
- runPostEditDiagnostics(
104
- paths: readonly PostEditDiagnosticPath[],
105
- ): Promise<readonly PostEditDiagnosticOutcome[]>;
106
- }
107
-
108
- /** Minimal Tool Result shape accepted by the structural post-edit adapters. */
109
- export interface PostEditToolResult {
110
- /** Tool name supplied by Pi's central tool-result event. */
111
- readonly toolName: string;
112
- /** Original tool arguments supplied to the central event. */
113
- readonly input: ToolResultEvent["input"];
114
- /** Tool-result details owned by the mutation implementation. */
115
- readonly details: ToolResultEvent["details"];
116
- /** Existing Pi content, retained verbatim before the appended LSP section. */
117
- readonly content: ToolResultEvent["content"];
118
- /** Existing tool failure state, which diagnostics must not change. */
119
- readonly isError: boolean;
120
- /** Existing usage accounting, which diagnostics must not change. */
121
- readonly usage?: ToolResultEvent["usage"];
122
- }
101
+ export type PostEditDiagnosticsRunner = (
102
+ paths: readonly PostEditDiagnosticPath[],
103
+ ) => Promise<readonly PostEditDiagnosticOutcome[]>;
123
104
 
124
105
  /** Tool-result fields returned by Post-edit Diagnostics middleware without changing mutation state. */
125
106
  export interface PostEditDiagnosticsResultPatch {
@@ -174,7 +155,7 @@ function pathsAfterMutation(result: ReturnType<typeof mutationResult>): PostEdit
174
155
 
175
156
  /** Extract exact changed destination paths from one Supported Mutation Tool result. */
176
157
  export function extractPostEditDiagnosticPaths(
177
- event: Pick<PostEditToolResult, "toolName" | "input" | "details" | "isError">,
158
+ event: Pick<ToolResultEvent, "toolName" | "input" | "details" | "isError">,
178
159
  ): ExtractedMutation | undefined {
179
160
  if (event.toolName === "edit" || event.toolName === "write") {
180
161
  if (event.isError || !Value.Check(NativeMutationInputSchema, event.input)) return undefined;
@@ -261,7 +242,7 @@ export function formatPostEditDiagnostics(outcomes: readonly PostEditDiagnosticO
261
242
 
262
243
  /** Append fresh Post-edit Diagnostics while preserving every mutation-result field Pi already owns. */
263
244
  export async function appendPostEditDiagnostics(
264
- event: PostEditToolResult,
245
+ event: ToolResultEvent,
265
246
  diagnostics: PostEditDiagnosticsRunner,
266
247
  ): Promise<PostEditDiagnosticsResultPatch | undefined> {
267
248
  const extracted = extractPostEditDiagnosticPaths(event);
@@ -271,8 +252,9 @@ export async function appendPostEditDiagnostics(
271
252
  kind: "warning",
272
253
  message,
273
254
  })),
274
- ...(await diagnostics.runPostEditDiagnostics(extracted.paths)),
255
+ ...(await diagnostics(extracted.paths)),
275
256
  ];
257
+ if (outcomes.length === 0) return undefined;
276
258
  const patch: PostEditDiagnosticsResultPatch = {
277
259
  content: [...event.content, { type: "text", text: formatPostEditDiagnostics(outcomes) }],
278
260
  details: event.details,
@@ -281,11 +263,3 @@ export async function appendPostEditDiagnostics(
281
263
  };
282
264
  return event.usage === undefined ? patch : { ...patch, usage: event.usage };
283
265
  }
284
-
285
- /** Adapt Pi's central ToolResultEvent shape to Post-edit Diagnostics middleware. */
286
- export async function appendPiPostEditDiagnostics(
287
- event: ToolResultEvent,
288
- diagnostics: PostEditDiagnosticsRunner,
289
- ): Promise<PostEditDiagnosticsResultPatch | undefined> {
290
- return appendPostEditDiagnostics(event, diagnostics);
291
- }
@@ -48,10 +48,12 @@ import {
48
48
  type WorkspaceEdit,
49
49
  } from "vscode-languageserver-protocol/node";
50
50
  import {
51
+ documentLines,
51
52
  measureLspPositionCharacters,
52
53
  normalizeLspPositionEncoding,
53
54
  type LspPositionEncoding,
54
55
  } from "./lsp-position-encoding.js";
56
+ import type { LspTimeouts } from "./pi-lsp-settings.js";
55
57
 
56
58
  const MAX_OPEN_DOCUMENTS = 100;
57
59
  const MAX_STDERR_BYTES = 1024 * 1024;
@@ -79,16 +81,7 @@ const PrepareRenameProviderSchema = Type.Object(
79
81
  );
80
82
 
81
83
  /** Time budgets, in milliseconds, for one language-server process. */
82
- export interface LspServerClientTimeouts {
83
- /** Initialize request budget. */
84
- readonly initializeMs: number;
85
- /** Ordinary request budget. */
86
- readonly requestMs: number;
87
- /** Fresh diagnostics budget. */
88
- readonly diagnosticsMs: number;
89
- /** Graceful shutdown budget before process termination. */
90
- readonly shutdownMs: number;
91
- }
84
+ export type LspServerClientTimeouts = LspTimeouts;
92
85
 
93
86
  /** Launch and protocol configuration for one language-server process. */
94
87
  export interface LspServerClientOptions {
@@ -118,8 +111,6 @@ export interface LspServerClientOptions {
118
111
 
119
112
  /** A valid UTF-8 document synchronized with one server instance. */
120
113
  export interface LspSynchronizedDocument {
121
- /** Absolute file path. */
122
- readonly filePath: string;
123
114
  /** File URI sent to the server. */
124
115
  readonly uri: string;
125
116
  /** Monotonic document version local to this server instance. */
@@ -148,8 +139,6 @@ export type LspWorkspaceDiagnosticResult =
148
139
 
149
140
  /** Classified process, protocol, timeout, cancellation, and UTF-8 client failure. */
150
141
  export class LspServerClientError extends Error {
151
- readonly _tag = "LspServerClientError" as const;
152
-
153
142
  /** Construct a stable Pi LSP client failure that always names the stderr capture. */
154
143
  constructor(
155
144
  readonly kind:
@@ -218,7 +207,7 @@ function serverWantsSave(capabilities: ServerCapabilities): boolean {
218
207
  }
219
208
 
220
209
  function protocolLineEndPosition(text: string, encoding: LspPositionEncoding): Position {
221
- const lines = text.split(/\r\n|\r|\n/);
210
+ const lines = documentLines(text);
222
211
  const lineText = lines.at(-1) ?? "";
223
212
  return {
224
213
  line: lines.length - 1,
@@ -268,13 +257,12 @@ export class LspServerClient {
268
257
  private positionEncodingValue: LspPositionEncoding = "utf-16";
269
258
  private textDocumentSyncKind: TextDocumentSyncKind = TextDocumentSyncKind.None;
270
259
  private readonly openDocuments = new Map<string, OpenDocumentState>();
260
+ private readonly documentSynchronizations = new Map<string, Promise<LspSynchronizedDocument>>();
271
261
  private readonly pushDiagnostics = new Map<string, PushDiagnosticsState>();
272
262
  private readonly pullDiagnostics = new Map<string, PullDiagnosticsState>();
273
263
  private readonly dynamicRegistrations = new Map<string, Registration>();
274
264
  private readonly diagnosticWaiters = new Map<string, Set<() => void>>();
275
- private readonly protocolMessages: string[] = [];
276
265
  private diagnosticsRevision = 0;
277
- private diagnosticsRefreshRevision = 0;
278
266
  private stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
279
267
  private stderrWrite = Promise.resolve();
280
268
  private closing = false;
@@ -381,11 +369,6 @@ export class LspServerClient {
381
369
  return this.options.stderrPath;
382
370
  }
383
371
 
384
- /** Most recent bounded protocol log/show messages, oldest first. */
385
- get recentProtocolMessages(): readonly string[] {
386
- return this.protocolMessages;
387
- }
388
-
389
372
  /** Whether a static or dynamically registered LSP method is available. */
390
373
  hasCapability(method: string): boolean {
391
374
  if (
@@ -525,8 +508,33 @@ export class LspServerClient {
525
508
  filePath: string,
526
509
  languageId: string,
527
510
  ): Promise<LspSynchronizedDocument> {
528
- this.throwIfUnavailable();
529
511
  const absolutePath = resolve(filePath);
512
+ const uri = pathToFileURL(absolutePath).href;
513
+ const activeSynchronization = this.documentSynchronizations.get(uri);
514
+ const waitForActiveSynchronization =
515
+ activeSynchronization?.then(
516
+ () => undefined,
517
+ () => undefined,
518
+ ) ?? Promise.resolve();
519
+ const synchronization = waitForActiveSynchronization.then(() =>
520
+ this.synchronizeDocumentOnce(absolutePath, uri, languageId),
521
+ );
522
+ this.documentSynchronizations.set(uri, synchronization);
523
+ try {
524
+ return await synchronization;
525
+ } finally {
526
+ if (this.documentSynchronizations.get(uri) === synchronization) {
527
+ this.documentSynchronizations.delete(uri);
528
+ }
529
+ }
530
+ }
531
+
532
+ private async synchronizeDocumentOnce(
533
+ absolutePath: string,
534
+ uri: string,
535
+ languageId: string,
536
+ ): Promise<LspSynchronizedDocument> {
537
+ this.throwIfUnavailable();
530
538
  let text: string;
531
539
  try {
532
540
  const bytes = await readFile(absolutePath);
@@ -544,10 +552,13 @@ export class LspServerClient {
544
552
  throw cause;
545
553
  }
546
554
 
547
- const uri = pathToFileURL(absolutePath).href;
548
555
  const existing = this.openDocuments.get(uri);
556
+ if (existing?.text === text && existing.languageId === languageId) {
557
+ this.openDocuments.delete(uri);
558
+ this.openDocuments.set(uri, existing);
559
+ return existing;
560
+ }
549
561
  const next: OpenDocumentState = {
550
- filePath: absolutePath,
551
562
  uri,
552
563
  version: (existing?.version ?? 0) + 1,
553
564
  text,
@@ -622,7 +633,12 @@ export class LspServerClient {
622
633
  }
623
634
 
624
635
  try {
625
- return await this.firstDiagnosticResult(candidates, signal);
636
+ return await this.raceBudget(
637
+ Promise.any(candidates),
638
+ this.options.timeouts.diagnosticsMs,
639
+ "diagnostics",
640
+ signal,
641
+ );
626
642
  } catch (cause) {
627
643
  if (cause instanceof LspServerClientError) {
628
644
  if (cause.kind === "cancelled") throw cause;
@@ -805,22 +821,13 @@ export class LspServerClient {
805
821
  }
806
822
  });
807
823
  this.connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
808
- this.connection.onRequest(DiagnosticRefreshRequest.type, () => {
809
- this.diagnosticsRefreshRevision++;
810
- });
824
+ this.connection.onRequest(DiagnosticRefreshRequest.type, () => undefined);
811
825
  this.connection.onRequest(ApplyWorkspaceEditRequest.type, async (parameters) =>
812
826
  this.rejectServerWorkspaceEdit(parameters),
813
827
  );
814
- this.connection.onRequest(ShowMessageRequest.type, (parameters) => {
815
- this.rememberProtocolMessage(parameters.message);
816
- return null;
817
- });
818
- this.connection.onNotification(LogMessageNotification.type, (parameters) => {
819
- this.rememberProtocolMessage(parameters.message);
820
- });
821
- this.connection.onNotification(ShowMessageNotification.type, (parameters) => {
822
- this.rememberProtocolMessage(parameters.message);
823
- });
828
+ this.connection.onRequest(ShowMessageRequest.type, () => null);
829
+ this.connection.onNotification(LogMessageNotification.type, () => undefined);
830
+ this.connection.onNotification(ShowMessageNotification.type, () => undefined);
824
831
  }
825
832
 
826
833
  private async initialize(): Promise<void> {
@@ -923,30 +930,19 @@ export class LspServerClient {
923
930
  if (signal?.aborted === true) throw abortError(this.options);
924
931
 
925
932
  const cancellation = new CancellationTokenSource();
926
- let timeout: NodeJS.Timeout | undefined;
927
- let abortListener: (() => void) | undefined;
928
- const request = this.connection.sendRequest<TResult>(method, parameters, cancellation.token);
929
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
930
- timeout = setTimeout(() => {
931
- cancellation.cancel();
932
- reject(timeoutError(this.options, operation));
933
- }, budgetMs);
934
- timeout.unref();
935
- });
936
- const abortPromise = new Promise<never>((_resolve, reject) => {
937
- if (signal === undefined) return;
938
- abortListener = () => {
939
- cancellation.cancel();
940
- reject(abortError(this.options));
941
- };
942
- signal.addEventListener("abort", abortListener, { once: true });
943
- });
944
- const terminalPromise = this.terminalErrorPromise.then((error) => {
945
- throw error;
946
- });
947
-
948
933
  try {
949
- return await Promise.race([request, timeoutPromise, abortPromise, terminalPromise]);
934
+ return await Promise.race([
935
+ this.raceBudget(
936
+ this.connection.sendRequest<TResult>(method, parameters, cancellation.token),
937
+ budgetMs,
938
+ operation,
939
+ signal,
940
+ () => cancellation.cancel(),
941
+ ),
942
+ this.terminalErrorPromise.then((error) => {
943
+ throw error;
944
+ }),
945
+ ]);
950
946
  } catch (cause) {
951
947
  if (cause instanceof LspServerClientError) throw cause;
952
948
  throw new LspServerClientError(
@@ -957,10 +953,6 @@ export class LspServerClient {
957
953
  { cause },
958
954
  );
959
955
  } finally {
960
- if (timeout !== undefined) clearTimeout(timeout);
961
- if (signal !== undefined && abortListener !== undefined) {
962
- signal.removeEventListener("abort", abortListener);
963
- }
964
956
  cancellation.dispose();
965
957
  }
966
958
  }
@@ -1047,34 +1039,29 @@ export class LspServerClient {
1047
1039
  return { status: "fresh", source: "document_pull", diagnostics: report.items };
1048
1040
  }
1049
1041
 
1050
- private async firstDiagnosticResult(
1051
- candidates: readonly Promise<LspDocumentDiagnosticResult>[],
1052
- signal?: AbortSignal,
1053
- ): Promise<LspDocumentDiagnosticResult> {
1054
- return this.raceBudget(
1055
- Promise.any(candidates),
1056
- this.options.timeouts.diagnosticsMs,
1057
- "diagnostics",
1058
- signal,
1059
- );
1060
- }
1061
-
1062
1042
  private async raceBudget<TResult>(
1063
1043
  value: Promise<TResult>,
1064
1044
  budgetMs: number,
1065
1045
  operation: string,
1066
1046
  signal?: AbortSignal,
1047
+ onCancel?: () => void,
1067
1048
  ): Promise<TResult> {
1068
1049
  if (signal?.aborted === true) throw abortError(this.options);
1069
1050
  let timeout: NodeJS.Timeout | undefined;
1070
1051
  let abortListener: (() => void) | undefined;
1071
1052
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
1072
- timeout = setTimeout(() => reject(timeoutError(this.options, operation)), budgetMs);
1053
+ timeout = setTimeout(() => {
1054
+ onCancel?.();
1055
+ reject(timeoutError(this.options, operation));
1056
+ }, budgetMs);
1073
1057
  timeout.unref();
1074
1058
  });
1075
1059
  const abortPromise = new Promise<never>((_resolve, reject) => {
1076
1060
  if (signal === undefined) return;
1077
- abortListener = () => reject(abortError(this.options));
1061
+ abortListener = () => {
1062
+ onCancel?.();
1063
+ reject(abortError(this.options));
1064
+ };
1078
1065
  signal.addEventListener("abort", abortListener, { once: true });
1079
1066
  });
1080
1067
  try {
@@ -1167,11 +1154,6 @@ export class LspServerClient {
1167
1154
  }
1168
1155
  }
1169
1156
 
1170
- private rememberProtocolMessage(message: string): void {
1171
- this.protocolMessages.push(message);
1172
- if (this.protocolMessages.length > 100) this.protocolMessages.shift();
1173
- }
1174
-
1175
1157
  private async evictOldDocuments(): Promise<void> {
1176
1158
  while (this.openDocuments.size > MAX_OPEN_DOCUMENTS) {
1177
1159
  const oldestUri = this.openDocuments.keys().next().value;