@mrclrchtr/supi-lsp 0.1.0 → 1.0.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.
Files changed (71) hide show
  1. package/README.md +112 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +18 -9
  19. package/{capabilities.ts → src/capabilities.ts} +8 -0
  20. package/src/client/client-refresh.ts +229 -0
  21. package/{client.ts → src/client/client.ts} +178 -30
  22. package/{transport.ts → src/client/transport.ts} +10 -6
  23. package/src/config.ts +143 -0
  24. package/src/defaults.json +82 -0
  25. package/src/diagnostics/diagnostic-augmentation.ts +82 -0
  26. package/src/diagnostics/diagnostic-display.ts +68 -0
  27. package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
  28. package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
  29. package/src/diagnostics/stale-diagnostics.ts +47 -0
  30. package/src/diagnostics/suppression-diagnostics.ts +58 -0
  31. package/src/format.ts +359 -0
  32. package/src/guidance.ts +163 -0
  33. package/src/index.ts +17 -0
  34. package/src/lsp-state.ts +82 -0
  35. package/src/lsp.ts +470 -0
  36. package/src/manager/manager-client-state.ts +34 -0
  37. package/src/manager/manager-diagnostics.ts +139 -0
  38. package/src/manager/manager-helpers.ts +39 -0
  39. package/src/manager/manager-project-info.ts +46 -0
  40. package/src/manager/manager-types.ts +39 -0
  41. package/src/manager/manager-workspace-recovery.ts +83 -0
  42. package/src/manager/manager-workspace-symbol.ts +18 -0
  43. package/src/manager/manager.ts +550 -0
  44. package/src/overrides.ts +173 -0
  45. package/src/pattern-matcher.ts +197 -0
  46. package/src/renderer.ts +120 -0
  47. package/src/scanner.ts +153 -0
  48. package/src/search-fallback.ts +98 -0
  49. package/src/service-registry.ts +153 -0
  50. package/src/settings-registration.ts +292 -0
  51. package/{summary.ts → src/summary.ts} +44 -9
  52. package/src/tool-actions.ts +430 -0
  53. package/src/tree-persist.ts +48 -0
  54. package/src/tsconfig-scope.ts +156 -0
  55. package/{types.ts → src/types.ts} +123 -0
  56. package/src/ui.ts +358 -0
  57. package/{utils.ts → src/utils.ts} +8 -25
  58. package/src/workspace-sentinels.ts +114 -0
  59. package/bash-guard.ts +0 -58
  60. package/config.ts +0 -99
  61. package/defaults.json +0 -40
  62. package/format.ts +0 -190
  63. package/guidance.ts +0 -140
  64. package/lsp.ts +0 -375
  65. package/manager.ts +0 -396
  66. package/overrides.ts +0 -95
  67. package/recent-paths.ts +0 -126
  68. package/runtime-state.ts +0 -113
  69. package/tool-actions.ts +0 -211
  70. package/tsconfig.json +0 -5
  71. package/ui.ts +0 -303
@@ -1,5 +1,6 @@
1
1
  // LSP protocol types — minimal subset needed for our client.
2
2
  // Based on the Language Server Protocol specification.
3
+ // biome-ignore-all lint/nursery/noExcessiveLinesPerFile: protocol types are intentionally centralized in one catalog file.
3
4
 
4
5
  // ── Positions & Ranges ────────────────────────────────────────────────
5
6
 
@@ -130,6 +131,15 @@ export interface SymbolInformation {
130
131
  containerName?: string;
131
132
  }
132
133
 
134
+ export interface WorkspaceSymbol {
135
+ name: string;
136
+ kind: SymbolKind;
137
+ location: Location;
138
+ containerName?: string;
139
+ /** LSP 3.17+ extra data for resolve support */
140
+ data?: unknown;
141
+ }
142
+
133
143
  // ── Code Actions ──────────────────────────────────────────────────────
134
144
 
135
145
  export interface CodeActionContext {
@@ -160,6 +170,73 @@ export interface PublishDiagnosticsParams {
160
170
  diagnostics: Diagnostic[];
161
171
  }
162
172
 
173
+ export const FileChangeType = {
174
+ Created: 1,
175
+ Changed: 2,
176
+ Deleted: 3,
177
+ } as const;
178
+ export type FileChangeType = (typeof FileChangeType)[keyof typeof FileChangeType];
179
+
180
+ export interface FileEvent {
181
+ uri: string;
182
+ type: FileChangeType;
183
+ }
184
+
185
+ export interface DidChangeWatchedFilesParams {
186
+ changes: FileEvent[];
187
+ }
188
+
189
+ // ── LSP 3.17 Pull Diagnostics ─────────────────────────────────────────
190
+
191
+ export interface DocumentDiagnosticParams {
192
+ textDocument: TextDocumentIdentifier;
193
+ identifier?: string;
194
+ previousResultId?: string;
195
+ workDoneToken?: unknown;
196
+ partialResultToken?: unknown;
197
+ }
198
+
199
+ /** LSP 3.17 document diagnostic report shape used by textDocument/diagnostic. */
200
+ export type DocumentDiagnosticReport =
201
+ | RelatedFullDocumentDiagnosticReport
202
+ | RelatedUnchangedDocumentDiagnosticReport;
203
+
204
+ /** Full document diagnostic report, optionally carrying related document reports. */
205
+ export interface RelatedFullDocumentDiagnosticReport extends FullDocumentDiagnosticReport {
206
+ relatedDocuments?: Record<
207
+ string,
208
+ FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport
209
+ >;
210
+ }
211
+
212
+ /** Unchanged document diagnostic report, optionally carrying related document reports. */
213
+ export interface RelatedUnchangedDocumentDiagnosticReport
214
+ extends UnchangedDocumentDiagnosticReport {
215
+ relatedDocuments?: Record<
216
+ string,
217
+ FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport
218
+ >;
219
+ }
220
+
221
+ /** Full diagnostic payload for a document. */
222
+ export interface FullDocumentDiagnosticReport {
223
+ kind: "full";
224
+ resultId?: string;
225
+ items: Diagnostic[];
226
+ }
227
+
228
+ /** Result-id-only report indicating a document's diagnostics are unchanged. */
229
+ export interface UnchangedDocumentDiagnosticReport {
230
+ kind: "unchanged";
231
+ resultId: string;
232
+ }
233
+
234
+ /** Client capability for pull diagnostics. */
235
+ export interface ClientDiagnosticCapabilities {
236
+ dynamicRegistration?: boolean;
237
+ relatedDocumentSupport?: boolean;
238
+ }
239
+
163
240
  // ── Initialize ────────────────────────────────────────────────────────
164
241
 
165
242
  export interface InitializeParams {
@@ -202,10 +279,16 @@ export interface ClientCapabilities {
202
279
  };
203
280
  publishDiagnostics?: {
204
281
  relatedInformation?: boolean;
282
+ versionSupport?: boolean;
205
283
  };
284
+ /** LSP 3.17+ pull diagnostic capability */
285
+ diagnostic?: ClientDiagnosticCapabilities;
206
286
  };
207
287
  workspace?: {
208
288
  workspaceFolders?: boolean;
289
+ diagnostics?: {
290
+ refreshSupport?: boolean;
291
+ };
209
292
  };
210
293
  }
211
294
 
@@ -219,8 +302,25 @@ export interface ServerCapabilities {
219
302
  definitionProvider?: boolean;
220
303
  referencesProvider?: boolean;
221
304
  documentSymbolProvider?: boolean;
305
+ workspaceSymbolProvider?: boolean;
222
306
  renameProvider?: boolean | { prepareProvider?: boolean };
223
307
  codeActionProvider?: boolean | { codeActionKinds?: string[] };
308
+ implementationProvider?: boolean;
309
+ /** LSP 3.17+ pull diagnostic support */
310
+ diagnosticProvider?:
311
+ | boolean
312
+ | {
313
+ /** Document diagnostic provider */
314
+ documentIdentifierProvider?: boolean | { workDoneProgress?: boolean };
315
+ /** Workspace diagnostic provider */
316
+ workspaceDiagnostics?: boolean | { workDoneProgress?: boolean };
317
+ /** Identifier for result sets */
318
+ identifierSet?: boolean;
319
+ /** Inter-file dependency support */
320
+ interFileDependencies?: boolean;
321
+ /** Workspace-wide multi-file support */
322
+ workspaceDiagnosticsSupport?: boolean;
323
+ };
224
324
  }
225
325
 
226
326
  // ── Text Document Items ───────────────────────────────────────────────
@@ -281,6 +381,29 @@ export interface ServerConfig {
281
381
  initializationOptions?: unknown;
282
382
  }
283
383
 
384
+ /** LSP configuration keyed by language name (e.g. `typescript`, `python`). */
284
385
  export interface LspConfig {
285
386
  servers: Record<string, ServerConfig>;
286
387
  }
388
+
389
+ export interface DetectedProjectServer {
390
+ name: string;
391
+ root: string;
392
+ fileTypes: string[];
393
+ }
394
+
395
+ export interface ProjectServerInfo extends DetectedProjectServer {
396
+ status: "running" | "error" | "unavailable";
397
+ supportedActions: string[];
398
+ openFiles: string[];
399
+ }
400
+
401
+ /** A language whose source files are present but the server binary is missing. */
402
+ export interface MissingServer {
403
+ /** Language name (e.g. "python", "rust"). */
404
+ name: string;
405
+ /** Server command that was not found on PATH. */
406
+ command: string;
407
+ /** File extensions found in the project (subset of server.fileTypes). */
408
+ foundExtensions: string[];
409
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,358 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
+ import type { OverlayHandle } from "@earendil-works/pi-tui";
4
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
5
+ import type { LspManager } from "./manager/manager.ts";
6
+ import type { OutstandingDiagnosticSummaryEntry } from "./manager/manager-types.ts";
7
+ import type { Diagnostic, ProjectServerInfo } from "./types.ts";
8
+ import { DiagnosticSeverity } from "./types.ts";
9
+
10
+ export interface LspInspectorState {
11
+ handle: OverlayHandle | null;
12
+ close: (() => void) | null;
13
+ }
14
+
15
+ export function updateLspUi(
16
+ ctx: ExtensionContext,
17
+ manager: LspManager,
18
+ inlineSeverity: number,
19
+ servers: ProjectServerInfo[],
20
+ ): void {
21
+ const diagnostics = manager.getOutstandingDiagnosticSummary(inlineSeverity);
22
+
23
+ ctx.ui.setStatus("lsp", buildLspStatus(ctx, servers, diagnostics));
24
+ ctx.ui.setWidget(
25
+ "lsp",
26
+ hasWidgetContent(diagnostics)
27
+ ? (_tui, theme) => buildLspWidgetComponent(theme, diagnostics)
28
+ : undefined,
29
+ { placement: "belowEditor" },
30
+ );
31
+ }
32
+
33
+ // biome-ignore lint/complexity/useMaxParams: overlay inputs travel together
34
+ export function toggleLspStatusOverlay(
35
+ ctx: ExtensionContext,
36
+ manager: LspManager,
37
+ inlineSeverity: number,
38
+ inspector: LspInspectorState,
39
+ servers: ProjectServerInfo[],
40
+ ): void {
41
+ if (inspector.handle && inspector.close) {
42
+ inspector.close();
43
+ return;
44
+ }
45
+
46
+ void ctx.ui
47
+ .custom<void>(
48
+ (_tui, theme, _kb, done) => {
49
+ inspector.close = () => done(undefined);
50
+ return createLspInspectorComponent(theme, manager, inlineSeverity, servers);
51
+ },
52
+ {
53
+ overlay: true,
54
+ overlayOptions: {
55
+ anchor: "right-center",
56
+ width: "60%",
57
+ minWidth: 72,
58
+ maxHeight: "90%",
59
+ margin: { right: 1, top: 1, bottom: 1 },
60
+ nonCapturing: true,
61
+ },
62
+ onHandle: (handle) => {
63
+ inspector.handle = handle;
64
+ },
65
+ },
66
+ )
67
+ .finally(() => {
68
+ inspector.handle = null;
69
+ inspector.close = null;
70
+ });
71
+ }
72
+
73
+ function createLspInspectorComponent(
74
+ theme: ExtensionContext["ui"]["theme"],
75
+ manager: LspManager,
76
+ inlineSeverity: number,
77
+ servers: ProjectServerInfo[],
78
+ ): { render: (width: number) => string[]; invalidate: () => void } {
79
+ return {
80
+ render: (width) =>
81
+ buildLspInspectorContainer({
82
+ theme,
83
+ manager,
84
+ inlineSeverity,
85
+ servers,
86
+ width,
87
+ }).render(width),
88
+ invalidate: () => {},
89
+ };
90
+ }
91
+
92
+ interface LspInspectorContainerInput {
93
+ theme: ExtensionContext["ui"]["theme"];
94
+ manager: LspManager;
95
+ inlineSeverity: number;
96
+ servers: ProjectServerInfo[];
97
+ width: number;
98
+ }
99
+
100
+ function buildLspInspectorContainer(input: LspInspectorContainerInput): Container {
101
+ const diagnostics = input.manager.getOutstandingDiagnosticSummary(input.inlineSeverity);
102
+ const detailedDiagnostics = input.manager.getOutstandingDiagnostics(input.inlineSeverity);
103
+ const container = new Container();
104
+
105
+ const { theme, servers, width } = input;
106
+
107
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
108
+ container.addChild(
109
+ new Text(
110
+ theme.fg("accent", theme.bold(" λ LSP")) + theme.fg("dim", " inspector /lsp-status toggles"),
111
+ 1,
112
+ 0,
113
+ ),
114
+ );
115
+
116
+ if (servers.length === 0 && diagnostics.length === 0) {
117
+ container.addChild(
118
+ new Text(theme.fg("dim", "no LSP servers available for this project"), 1, 0),
119
+ );
120
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
121
+ return container;
122
+ }
123
+
124
+ container.addChild(new Text(buildOverlaySummaryLine(theme, servers, diagnostics), 1, 0));
125
+ container.addChild(new Spacer(1));
126
+ container.addChild(
127
+ buildOverlaySection(theme, "Servers", buildOverlayServerLines(theme, servers)),
128
+ );
129
+ container.addChild(new Spacer(1));
130
+ container.addChild(
131
+ buildOverlaySection(
132
+ theme,
133
+ diagnostics.length > 0 ? "Problems" : "Diagnostics",
134
+ buildOverlayDiagnosticLines(theme, diagnostics, detailedDiagnostics, width),
135
+ ),
136
+ );
137
+ container.addChild(new Spacer(1));
138
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
139
+
140
+ return container;
141
+ }
142
+
143
+ function buildLspStatus(
144
+ ctx: ExtensionContext,
145
+ servers: ProjectServerInfo[],
146
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
147
+ ): string | undefined {
148
+ const runningServers = servers.filter((server) => server.status === "running").length;
149
+ const openFiles = servers.reduce((sum, server) => sum + server.openFiles.length, 0);
150
+ const totals = collectDiagnosticTotals(diagnostics);
151
+
152
+ if (runningServers === 0 && openFiles === 0 && diagnostics.length === 0) {
153
+ return undefined;
154
+ }
155
+
156
+ const { theme } = ctx.ui;
157
+ const parts = [theme.fg("accent", "λ lsp")];
158
+ if (runningServers > 0) parts.push(theme.fg("dim", pluralize(runningServers, "server")));
159
+ if (openFiles > 0) parts.push(theme.fg("dim", pluralize(openFiles, "open file")));
160
+ if (totals.errors > 0) parts.push(theme.fg("error", pluralize(totals.errors, "error")));
161
+ if (totals.warnings > 0) parts.push(theme.fg("warning", pluralize(totals.warnings, "warning")));
162
+ if (totals.information > 0) parts.push(theme.fg("accent", pluralize(totals.information, "info")));
163
+ if (totals.hints > 0) parts.push(theme.fg("dim", pluralize(totals.hints, "hint")));
164
+ return parts.join(theme.fg("dim", " • "));
165
+ }
166
+
167
+ function hasWidgetContent(diagnostics: OutstandingDiagnosticSummaryEntry[]): boolean {
168
+ return diagnostics.length > 0;
169
+ }
170
+
171
+ function buildLspWidgetComponent(
172
+ theme: ExtensionContext["ui"]["theme"],
173
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
174
+ ): Container {
175
+ const container = new Container();
176
+
177
+ for (const line of buildWidgetDiagnosticLines(theme, diagnostics)) {
178
+ container.addChild(new Text(line, 0, 0));
179
+ }
180
+
181
+ return container;
182
+ }
183
+
184
+ function buildWidgetDiagnosticLines(
185
+ theme: ExtensionContext["ui"]["theme"],
186
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
187
+ ): string[] {
188
+ if (diagnostics.length === 1) {
189
+ const [entry] = diagnostics;
190
+ if (!entry) return [];
191
+ return [
192
+ `${theme.fg("error", "●")} ${entry.file} ${theme.fg("dim", `— ${formatDiagnosticCounts(entry)}`)}`,
193
+ ];
194
+ }
195
+
196
+ const totals = collectDiagnosticTotals(diagnostics);
197
+ const counts: string[] = [];
198
+ if (totals.errors > 0) counts.push(theme.fg("error", pluralize(totals.errors, "error")));
199
+ if (totals.warnings > 0) counts.push(theme.fg("warning", pluralize(totals.warnings, "warning")));
200
+ if (totals.information > 0)
201
+ counts.push(theme.fg("accent", pluralize(totals.information, "info")));
202
+ if (totals.hints > 0) counts.push(theme.fg("dim", pluralize(totals.hints, "hint")));
203
+
204
+ const visibleFiles = diagnostics.slice(0, 2).map((entry) => entry.file);
205
+ const remaining = diagnostics.length - visibleFiles.length;
206
+ const suffix = remaining > 0 ? `${theme.fg("dim", ` +${remaining} more`)}` : "";
207
+
208
+ return [
209
+ `${theme.fg("accent", theme.bold("λ LSP diagnostics"))} ${theme.fg("dim", `— ${pluralize(diagnostics.length, "file")}`)} ${counts.join(theme.fg("dim", " • "))}`,
210
+ `${theme.fg("error", "↳")} ${visibleFiles.join(", ")}${suffix}`,
211
+ ];
212
+ }
213
+
214
+ function buildOverlaySummaryLine(
215
+ theme: ExtensionContext["ui"]["theme"],
216
+ servers: ProjectServerInfo[],
217
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
218
+ ): string {
219
+ const runningServers = servers.filter((server) => server.status === "running").length;
220
+ const openFiles = servers.reduce((sum, server) => sum + server.openFiles.length, 0);
221
+ const totals = collectDiagnosticTotals(diagnostics);
222
+
223
+ const parts = [
224
+ theme.fg(
225
+ "dim",
226
+ `${pluralize(runningServers, "server")} • ${pluralize(openFiles, "open file")}`,
227
+ ),
228
+ ];
229
+ if (totals.errors > 0) {
230
+ parts.push(theme.fg("error", pluralize(totals.errors, "error")));
231
+ } else if (totals.warnings > 0) {
232
+ parts.push(theme.fg("warning", pluralize(totals.warnings, "warning")));
233
+ } else if (totals.information > 0) {
234
+ parts.push(theme.fg("accent", pluralize(totals.information, "info")));
235
+ } else if (totals.hints > 0) {
236
+ parts.push(theme.fg("dim", pluralize(totals.hints, "hint")));
237
+ } else {
238
+ parts.push(theme.fg("success", "clean"));
239
+ }
240
+
241
+ return parts.join(theme.fg("dim", " "));
242
+ }
243
+
244
+ function buildOverlaySection(
245
+ theme: ExtensionContext["ui"]["theme"],
246
+ title: string,
247
+ lines: string[],
248
+ ): Container {
249
+ const container = new Container();
250
+ container.addChild(new Text(theme.fg("accent", theme.bold(` ${title}`)), 1, 0));
251
+ for (const line of lines) {
252
+ container.addChild(new Text(line, 2, 0));
253
+ }
254
+ return container;
255
+ }
256
+
257
+ function buildOverlayServerLines(
258
+ theme: ExtensionContext["ui"]["theme"],
259
+ servers: ProjectServerInfo[],
260
+ ): string[] {
261
+ if (servers.length === 0) {
262
+ return [theme.fg("dim", "no LSP servers available for this project")];
263
+ }
264
+
265
+ return servers.flatMap((server) => {
266
+ const statusColor =
267
+ server.status === "running" ? "success" : server.status === "error" ? "error" : "warning";
268
+ const actions =
269
+ server.supportedActions.length > 0 ? server.supportedActions.join(", ") : "none";
270
+ const fileTypes = server.fileTypes.map((entry) => `.${entry}`).join(", ");
271
+ const openFiles =
272
+ server.openFiles.length > 0 ? server.openFiles.slice(0, 3).join(", ") : "none";
273
+ const remaining = server.openFiles.length - Math.min(server.openFiles.length, 3);
274
+ const suffix = remaining > 0 ? theme.fg("dim", ` +${remaining} more`) : "";
275
+
276
+ return [
277
+ `${theme.fg("accent", "◆")} ${server.name} ${theme.fg(statusColor, server.status)} ${theme.fg("dim", `— root: ${server.root}`)}`,
278
+ `${theme.fg("dim", "↳")} files: ${fileTypes}`,
279
+ `${theme.fg("dim", "↳")} actions: ${actions}`,
280
+ `${theme.fg("dim", "↳")} open: ${openFiles}${suffix}`,
281
+ ];
282
+ });
283
+ }
284
+
285
+ function buildOverlayDiagnosticLines(
286
+ theme: ExtensionContext["ui"]["theme"],
287
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
288
+ detailedDiagnostics: Array<{ file: string; diagnostics: Diagnostic[] }>,
289
+ width: number,
290
+ ): string[] {
291
+ if (diagnostics.length === 0) {
292
+ return [theme.fg("success", "✓ no outstanding diagnostics")];
293
+ }
294
+
295
+ const lines: string[] = [];
296
+ const maxFiles = 5;
297
+ const maxMessagesPerFile = 5;
298
+ // Budget for prefix: 2 spaces + tree char + space + line:col + space + Text paddingX(2)*2
299
+ const maxMessageLen = Math.max(24, width - 18);
300
+
301
+ for (const entry of detailedDiagnostics.slice(0, maxFiles)) {
302
+ const summary = diagnostics.find((d) => d.file === entry.file);
303
+ const countLabel = summary ? formatDiagnosticCounts(summary) : "";
304
+ lines.push(`${theme.fg("error", "●")} ${entry.file} ${theme.fg("dim", `— ${countLabel}`)}`);
305
+
306
+ for (const diag of entry.diagnostics.slice(0, maxMessagesPerFile)) {
307
+ const line = diag.range.start.line + 1;
308
+ const col = diag.range.start.character + 1;
309
+ const sevColor = diag.severity === DiagnosticSeverity.Error ? "error" : "warning";
310
+ const message = truncate(diag.message, maxMessageLen);
311
+ lines.push(` ${theme.fg(sevColor, "└")} ${line}:${col} ${theme.fg("dim", message)}`);
312
+ }
313
+
314
+ const remainingMessages = entry.diagnostics.length - maxMessagesPerFile;
315
+ if (remainingMessages > 0) {
316
+ lines.push(` ${theme.fg("dim", `└ +${remainingMessages} more`)}`);
317
+ }
318
+ }
319
+
320
+ const remainingFiles = diagnostics.length - Math.min(diagnostics.length, maxFiles);
321
+ if (remainingFiles > 0) {
322
+ lines.push(theme.fg("dim", `↳ +${remainingFiles} more file${remainingFiles === 1 ? "" : "s"}`));
323
+ }
324
+
325
+ return lines;
326
+ }
327
+
328
+ function truncate(text: string, maxLength: number): string {
329
+ if (text.length <= maxLength) return text;
330
+ return `${text.slice(0, maxLength - 1)}…`;
331
+ }
332
+
333
+ function formatDiagnosticCounts(entry: OutstandingDiagnosticSummaryEntry): string {
334
+ const counts: string[] = [];
335
+ if (entry.errors > 0) counts.push(pluralize(entry.errors, "error"));
336
+ if (entry.warnings > 0) counts.push(pluralize(entry.warnings, "warning"));
337
+ if (entry.information > 0) counts.push(pluralize(entry.information, "info"));
338
+ if (entry.hints > 0) counts.push(pluralize(entry.hints, "hint"));
339
+ return counts.join(", ");
340
+ }
341
+
342
+ function collectDiagnosticTotals(
343
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
344
+ ): Pick<OutstandingDiagnosticSummaryEntry, "errors" | "warnings" | "information" | "hints"> {
345
+ return diagnostics.reduce(
346
+ (totals, entry) => ({
347
+ errors: totals.errors + entry.errors,
348
+ warnings: totals.warnings + entry.warnings,
349
+ information: totals.information + entry.information,
350
+ hints: totals.hints + entry.hints,
351
+ }),
352
+ { errors: 0, warnings: 0, information: 0, hints: 0 },
353
+ );
354
+ }
355
+
356
+ function pluralize(count: number, word: string): string {
357
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
358
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
-
6
5
  // ── URI Handling ──────────────────────────────────────────────────────
7
6
 
8
7
  /** Convert a file path to a file:// URI. */
@@ -58,16 +57,24 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
58
57
  yml: "yaml",
59
58
  md: "markdown",
60
59
  html: "html",
60
+ htm: "html",
61
+ xhtml: "html",
61
62
  css: "css",
62
63
  scss: "scss",
63
64
  sh: "shellscript",
64
65
  bash: "shellscript",
66
+ zsh: "shellscript",
67
+ ksh: "shellscript",
65
68
  toml: "toml",
66
69
  xml: "xml",
67
70
  sql: "sql",
71
+ r: "r",
68
72
  rb: "ruby",
73
+ erb: "ruby",
74
+ gemspec: "ruby",
69
75
  java: "java",
70
76
  kt: "kotlin",
77
+ kts: "kotlin",
71
78
  swift: "swift",
72
79
  lua: "lua",
73
80
  zig: "zig",
@@ -84,30 +91,6 @@ export function getFileExtension(filePath: string): string {
84
91
  return path.extname(filePath).slice(1).toLowerCase();
85
92
  }
86
93
 
87
- // ── Root Marker Detection ─────────────────────────────────────────────
88
-
89
- /**
90
- * Search upward from `startDir` for any of the `markers` files/dirs.
91
- * Returns the directory containing the first found marker, or `fallback`.
92
- */
93
- export function findProjectRoot(startDir: string, markers: string[], fallback: string): string {
94
- let dir = path.resolve(startDir);
95
- const root = path.parse(dir).root;
96
-
97
- while (dir !== root) {
98
- for (const marker of markers) {
99
- if (fs.existsSync(path.join(dir, marker))) {
100
- return dir;
101
- }
102
- }
103
- const parent = path.dirname(dir);
104
- if (parent === dir) break;
105
- dir = parent;
106
- }
107
-
108
- return fallback;
109
- }
110
-
111
94
  // ── PATH Validation ───────────────────────────────────────────────────
112
95
 
113
96
  /**
@@ -0,0 +1,114 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { FileChangeType, type FileEvent } from "./types.ts";
4
+ import { fileToUri } from "./utils.ts";
5
+
6
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".pnpm", ".git", "dist", "coverage"]);
7
+ const ROOT_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];
8
+
9
+ /** Build a fresh snapshot of workspace sentinel files and their mtimes. */
10
+ export function scanWorkspaceSentinels(cwd: string): Map<string, number> {
11
+ const resolvedCwd = path.resolve(cwd);
12
+ const snapshot = new Map<string, number>();
13
+
14
+ if (!fs.existsSync(resolvedCwd)) return snapshot;
15
+
16
+ try {
17
+ walkWorkspace(resolvedCwd, resolvedCwd, snapshot);
18
+ } catch {
19
+ return snapshot;
20
+ }
21
+
22
+ return snapshot;
23
+ }
24
+
25
+ /** Diff two sentinel snapshots into LSP file change events. */
26
+ export function diffWorkspaceSentinelSnapshot(
27
+ previous: Map<string, number>,
28
+ next: Map<string, number>,
29
+ ): FileEvent[] {
30
+ const changes: FileEvent[] = [];
31
+
32
+ for (const [filePath, mtime] of next) {
33
+ const previousMtime = previous.get(filePath);
34
+ if (previousMtime === undefined) {
35
+ changes.push({ uri: fileToUri(filePath), type: FileChangeType.Created });
36
+ continue;
37
+ }
38
+ if (previousMtime !== mtime) {
39
+ changes.push({ uri: fileToUri(filePath), type: FileChangeType.Changed });
40
+ }
41
+ }
42
+
43
+ for (const filePath of previous.keys()) {
44
+ if (next.has(filePath)) continue;
45
+ changes.push({ uri: fileToUri(filePath), type: FileChangeType.Deleted });
46
+ }
47
+
48
+ return changes.sort((a, b) => a.uri.localeCompare(b.uri));
49
+ }
50
+
51
+ /** Refresh a previous snapshot and return the new snapshot plus change events. */
52
+ export function syncWorkspaceSentinelSnapshot(
53
+ cwd: string,
54
+ previous: Map<string, number>,
55
+ ): { snapshot: Map<string, number>; changes: FileEvent[] } {
56
+ const snapshot = scanWorkspaceSentinels(cwd);
57
+ return {
58
+ snapshot,
59
+ changes: diffWorkspaceSentinelSnapshot(previous, snapshot),
60
+ };
61
+ }
62
+
63
+ /** Determine whether a file path should trigger workspace recovery. */
64
+ export function isWorkspaceRecoveryTrigger(filePath: string, cwd: string): boolean {
65
+ const root = path.resolve(cwd);
66
+ return isWorkspaceSentinelPath(path.resolve(root, filePath), root);
67
+ }
68
+
69
+ function walkWorkspace(root: string, directory: string, snapshot: Map<string, number>): void {
70
+ let entries: fs.Dirent[];
71
+ try {
72
+ entries = fs.readdirSync(directory, { withFileTypes: true });
73
+ } catch {
74
+ // Permission error or deleted directory — skip it rather than
75
+ // failing the entire scan. A partial snapshot still detects
76
+ // changes in accessible subtrees.
77
+ return;
78
+ }
79
+
80
+ for (const entry of entries) {
81
+ if (entry.isDirectory()) {
82
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
83
+ walkWorkspace(root, path.join(directory, entry.name), snapshot);
84
+ continue;
85
+ }
86
+
87
+ if (!entry.isFile()) continue;
88
+
89
+ const filePath = path.join(directory, entry.name);
90
+ if (!isWorkspaceSentinelPath(filePath, root)) continue;
91
+ try {
92
+ snapshot.set(filePath, fs.statSync(filePath).mtimeMs);
93
+ } catch {
94
+ // File deleted between readdir and stat — skip.
95
+ }
96
+ }
97
+ }
98
+
99
+ function isWorkspaceSentinelPath(filePath: string, root: string): boolean {
100
+ const name = path.basename(filePath);
101
+
102
+ if (name === "package.json") return true;
103
+ if (name === "jsconfig.json") return true;
104
+ if (name === "tsconfig.json") return true;
105
+ if (name.startsWith("tsconfig.") && name.endsWith(".json")) return true;
106
+ if (filePath.endsWith(".d.ts")) return true;
107
+
108
+ return isRootLockfile(filePath, root);
109
+ }
110
+
111
+ function isRootLockfile(filePath: string, root: string): boolean {
112
+ if (path.dirname(filePath) !== root) return false;
113
+ return ROOT_LOCKFILES.includes(path.basename(filePath));
114
+ }