pi-lsp-adapter 0.1.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.
@@ -0,0 +1,241 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { LspExtensionState } from "../state.js";
4
+ import { setLspStatusLine } from "../statusLine.js";
5
+ import {
6
+ failure,
7
+ formatDefinition,
8
+ formatDiagnostics,
9
+ formatDocumentSymbols,
10
+ formatHover,
11
+ formatReferences,
12
+ formatWorkspaceSymbols,
13
+ toLspPosition,
14
+ } from "./lspFormat.js";
15
+ import { LSP_RESULT_ID_LENGTH, LSP_RESULT_ID_PATTERN } from "./resultCache.js";
16
+
17
+ export type GetLspToolState = () => LspExtensionState | null;
18
+
19
+ const FilePathParams = Type.Object({
20
+ filePath: Type.String({ description: "Path to the source file to inspect." }),
21
+ });
22
+
23
+ const FilePositionParams = Type.Object({
24
+ filePath: Type.String({ description: "Path to the source file to inspect." }),
25
+ line: Type.Integer({ minimum: 1, description: "1-based line number." }),
26
+ column: Type.Integer({
27
+ minimum: 1,
28
+ description: "1-based column/character number. For symbol queries, place it on the identifier token.",
29
+ }),
30
+ });
31
+
32
+ const ReferencesParams = Type.Object({
33
+ filePath: Type.String({ description: "Path to the source file to inspect." }),
34
+ line: Type.Integer({ minimum: 1, description: "1-based line number." }),
35
+ column: Type.Integer({
36
+ minimum: 1,
37
+ description: "1-based column/character number. For symbol queries, place it on the identifier token.",
38
+ }),
39
+ includeDeclaration: Type.Optional(
40
+ Type.Boolean({ description: "Whether to include the symbol declaration in the result." }),
41
+ ),
42
+ });
43
+
44
+ const WorkspaceSymbolsParams = Type.Object({
45
+ query: Type.String({ description: "Workspace symbol query string." }),
46
+ serverId: Type.Optional(Type.String({ description: "Optional LSP server id to query, e.g. pyright or vtsls." })),
47
+ });
48
+
49
+ const MoreParams = Type.Object({
50
+ resultId: Type.String({
51
+ description: "Exact cached LSP resultId returned by a previous paginated LSP tool result.",
52
+ pattern: LSP_RESULT_ID_PATTERN,
53
+ minLength: LSP_RESULT_ID_LENGTH,
54
+ maxLength: LSP_RESULT_ID_LENGTH,
55
+ }),
56
+ });
57
+
58
+ export function registerLspTools(pi: ExtensionAPI, getState: GetLspToolState): void {
59
+ pi.registerTool<typeof FilePathParams, unknown>({
60
+ name: "lsp_diagnostics",
61
+ label: "LSP Diagnostics",
62
+ description: "Read diagnostics for a file using its configured language server.",
63
+ promptSnippet: "Inspect compiler/type/lint diagnostics from configured language servers for a file.",
64
+ promptGuidelines: [
65
+ "Use lsp_diagnostics after reading or editing code when semantic errors, type errors, or language-server diagnostics would help.",
66
+ ],
67
+ parameters: FilePathParams,
68
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
69
+ const state = getState();
70
+ if (!state) return failure("lsp_diagnostics", "LSP extension is not initialized.");
71
+ try {
72
+ const result = formatDiagnostics(await state.runtimeManager.diagnostics(params.filePath), state.resultCache);
73
+ refreshStatus(ctx, state);
74
+ return result;
75
+ } catch (error) {
76
+ refreshStatus(ctx, state);
77
+ return failure("lsp_diagnostics", error);
78
+ }
79
+ },
80
+ });
81
+
82
+ pi.registerTool<typeof FilePositionParams, unknown>({
83
+ name: "lsp_hover",
84
+ label: "LSP Hover",
85
+ description: "Get hover/type information at a source position. line and column are 1-based.",
86
+ promptSnippet: "Fetch hover/type information for a symbol at a 1-based line and column.",
87
+ promptGuidelines: [
88
+ "Use lsp_hover when symbol type, signature, documentation, or inferred information would reduce guesswork. Put line/column on the identifier token itself, not whitespace or surrounding syntax.",
89
+ ],
90
+ parameters: FilePositionParams,
91
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
92
+ const state = getState();
93
+ if (!state) return failure("lsp_hover", "LSP extension is not initialized.");
94
+ try {
95
+ const position = toLspPosition(params);
96
+ const result = formatHover(
97
+ await state.runtimeManager.hover(params.filePath, position.line, position.character),
98
+ );
99
+ refreshStatus(ctx, state);
100
+ return result;
101
+ } catch (error) {
102
+ refreshStatus(ctx, state);
103
+ return failure("lsp_hover", error);
104
+ }
105
+ },
106
+ });
107
+
108
+ pi.registerTool<typeof FilePositionParams, unknown>({
109
+ name: "lsp_definition",
110
+ label: "LSP Definition",
111
+ description: "Find definition locations for the symbol at a source position. line and column are 1-based.",
112
+ promptSnippet: "Jump to definitions for a symbol at a 1-based line and column.",
113
+ promptGuidelines: [
114
+ "Use lsp_definition before changing unfamiliar call sites, types, or imported symbols. Put line/column on the identifier token itself, not an import path string or surrounding syntax.",
115
+ ],
116
+ parameters: FilePositionParams,
117
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
118
+ const state = getState();
119
+ if (!state) return failure("lsp_definition", "LSP extension is not initialized.");
120
+ try {
121
+ const position = toLspPosition(params);
122
+ const result = formatDefinition(
123
+ await state.runtimeManager.definition(params.filePath, position.line, position.character),
124
+ state.resultCache,
125
+ );
126
+ refreshStatus(ctx, state);
127
+ return result;
128
+ } catch (error) {
129
+ refreshStatus(ctx, state);
130
+ return failure("lsp_definition", error);
131
+ }
132
+ },
133
+ });
134
+
135
+ pi.registerTool<typeof ReferencesParams, unknown>({
136
+ name: "lsp_references",
137
+ label: "LSP References",
138
+ description: "Find references for the symbol at a source position. line and column are 1-based.",
139
+ promptSnippet: "Find references for a symbol at a 1-based line and column.",
140
+ promptGuidelines: [
141
+ "Use lsp_references to assess impact before renames, API changes, or behavior changes. Put line/column on the identifier token itself; set includeDeclaration when you need the declaration included in the results.",
142
+ ],
143
+ parameters: ReferencesParams,
144
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
145
+ const state = getState();
146
+ if (!state) return failure("lsp_references", "LSP extension is not initialized.");
147
+ try {
148
+ const position = toLspPosition(params);
149
+ const result = formatReferences(
150
+ await state.runtimeManager.references(
151
+ params.filePath,
152
+ position.line,
153
+ position.character,
154
+ params.includeDeclaration ?? false,
155
+ ),
156
+ state.resultCache,
157
+ );
158
+ refreshStatus(ctx, state);
159
+ return result;
160
+ } catch (error) {
161
+ refreshStatus(ctx, state);
162
+ return failure("lsp_references", error);
163
+ }
164
+ },
165
+ });
166
+
167
+ pi.registerTool<typeof FilePathParams, unknown>({
168
+ name: "lsp_document_symbols",
169
+ label: "LSP Document Symbols",
170
+ description: "List symbols in a source file using its configured language server.",
171
+ promptSnippet: "List functions, classes, variables, and other symbols in a source file.",
172
+ promptGuidelines: [
173
+ "Use lsp_document_symbols to understand a file's semantic structure before broad edits or before reading a large unfamiliar file end-to-end.",
174
+ ],
175
+ parameters: FilePathParams,
176
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
177
+ const state = getState();
178
+ if (!state) return failure("lsp_document_symbols", "LSP extension is not initialized.");
179
+ try {
180
+ const result = formatDocumentSymbols(
181
+ await state.runtimeManager.documentSymbols(params.filePath),
182
+ state.resultCache,
183
+ );
184
+ refreshStatus(ctx, state);
185
+ return result;
186
+ } catch (error) {
187
+ refreshStatus(ctx, state);
188
+ return failure("lsp_document_symbols", error);
189
+ }
190
+ },
191
+ });
192
+
193
+ pi.registerTool<typeof WorkspaceSymbolsParams, unknown>({
194
+ name: "lsp_workspace_symbols",
195
+ label: "LSP Workspace Symbols",
196
+ description:
197
+ "Search symbols across active LSP workspaces. Optionally provide a server id to start/query that server for the current cwd.",
198
+ promptSnippet: "Search workspace symbols by name across active language-server sessions.",
199
+ promptGuidelines: [
200
+ "Use lsp_workspace_symbols to locate definitions or related symbols by name when file paths are unknown. Omit serverId to query active servers only; provide a configured serverId, such as vtsls or pyright, when you want to start/query a specific server for the current cwd.",
201
+ ],
202
+ parameters: WorkspaceSymbolsParams,
203
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
204
+ const state = getState();
205
+ if (!state) return failure("lsp_workspace_symbols", "LSP extension is not initialized.");
206
+ try {
207
+ const result = formatWorkspaceSymbols(
208
+ await state.runtimeManager.workspaceSymbols(params.query, params.serverId),
209
+ state.resultCache,
210
+ { query: params.query },
211
+ );
212
+ refreshStatus(ctx, state);
213
+ return result;
214
+ } catch (error) {
215
+ refreshStatus(ctx, state);
216
+ return failure("lsp_workspace_symbols", error);
217
+ }
218
+ },
219
+ });
220
+
221
+ pi.registerTool<typeof MoreParams, unknown>({
222
+ name: "lsp_more",
223
+ label: "LSP More Results",
224
+ description: "Return the next cached page from a previous paginated LSP result.",
225
+ promptSnippet:
226
+ "Fetch the next sequential page for a previous LSP resultId without re-querying the language server.",
227
+ promptGuidelines: [
228
+ "Use lsp_more only when the previous LSP result explicitly says 'More available' and provides a resultId. Cached pages are sequential and may expire after cache eviction or session restart; if a resultId is missing or expired, re-run the original LSP query.",
229
+ ],
230
+ parameters: MoreParams,
231
+ async execute(_toolCallId, params) {
232
+ const state = getState();
233
+ if (!state) return failure("lsp_more", "LSP extension is not initialized.");
234
+ return state.resultCache.next(params.resultId);
235
+ },
236
+ });
237
+ }
238
+
239
+ function refreshStatus(ctx: ExtensionContext | undefined, state: LspExtensionState): void {
240
+ if (ctx) setLspStatusLine(ctx, state);
241
+ }
@@ -0,0 +1,25 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
3
+ import type { LspExtensionState } from "../state.js";
4
+ import { setLspStatusLine } from "../statusLine.js";
5
+
6
+ export type GetLspWarmupState = () => LspExtensionState | null;
7
+
8
+ export function registerLspWarmup(pi: ExtensionAPI, getState: GetLspWarmupState): void {
9
+ pi.on("tool_call", (event, ctx) => {
10
+ const state = getState();
11
+ if (!state?.config.warmup) return;
12
+ if (!isToolCallEventType("read", event)) return;
13
+
14
+ const filePath = event.input.path;
15
+ if (typeof filePath !== "string" || filePath.length === 0) return;
16
+
17
+ const warmupState = state;
18
+ void warmupState.runtimeManager
19
+ .warmupFile(filePath)
20
+ .then((warmed) => {
21
+ if (warmed && getState() === warmupState) setLspStatusLine(ctx, warmupState);
22
+ })
23
+ .catch(() => undefined);
24
+ });
25
+ }
@@ -0,0 +1,162 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export const MAX_LSP_RESULT_CACHE_BYTES = 64 * 1024 * 1024;
4
+ export const MAX_LSP_RESULT_CACHE_ENTRIES = 128;
5
+ export const LSP_RESULT_CACHE_TTL_MS = 15 * 60 * 1000;
6
+ export const LSP_RESULT_ID_PREFIX = "lspres_";
7
+ export const LSP_RESULT_ID_LENGTH = LSP_RESULT_ID_PREFIX.length + 36;
8
+ export const LSP_RESULT_ID_PATTERN = `^${LSP_RESULT_ID_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`;
9
+
10
+ const LSP_RESULT_ID_REGEX = new RegExp(LSP_RESULT_ID_PATTERN, "u");
11
+
12
+ export interface LspCachedPage {
13
+ text: string;
14
+ details: unknown;
15
+ }
16
+
17
+ export interface StoreLspResultInput {
18
+ label: string;
19
+ pages: LspCachedPage[];
20
+ }
21
+
22
+ export interface LspResultCacheOptions {
23
+ maxBytes?: number;
24
+ maxEntries?: number;
25
+ ttlMs?: number;
26
+ now?: () => number;
27
+ }
28
+
29
+ export interface LspResultCacheStats {
30
+ entries: number;
31
+ bytes: number;
32
+ maxBytes: number;
33
+ }
34
+
35
+ interface CacheEntry {
36
+ id: string;
37
+ label: string;
38
+ pages: LspCachedPage[];
39
+ nextPageIndex: number;
40
+ bytes: number;
41
+ createdAt: number;
42
+ accessedAt: number;
43
+ }
44
+
45
+ export class LspResultCache {
46
+ private readonly maxBytes: number;
47
+ private readonly maxEntries: number;
48
+ private readonly ttlMs: number;
49
+ private readonly now: () => number;
50
+ private readonly entries = new Map<string, CacheEntry>();
51
+ private bytes = 0;
52
+
53
+ constructor(options: LspResultCacheOptions = {}) {
54
+ this.maxBytes = options.maxBytes ?? MAX_LSP_RESULT_CACHE_BYTES;
55
+ this.maxEntries = options.maxEntries ?? MAX_LSP_RESULT_CACHE_ENTRIES;
56
+ this.ttlMs = options.ttlMs ?? LSP_RESULT_CACHE_TTL_MS;
57
+ this.now = options.now ?? Date.now;
58
+ }
59
+
60
+ store(input: StoreLspResultInput): string | undefined {
61
+ this.evictExpired();
62
+ if (input.pages.length <= 1) return undefined;
63
+
64
+ const timestamp = this.now();
65
+ const entry: CacheEntry = {
66
+ id: `${LSP_RESULT_ID_PREFIX}${randomUUID()}`,
67
+ label: input.label,
68
+ pages: input.pages,
69
+ nextPageIndex: 1,
70
+ bytes: estimateBytes(input),
71
+ createdAt: timestamp,
72
+ accessedAt: timestamp,
73
+ };
74
+
75
+ if (entry.bytes > this.maxBytes) return undefined;
76
+
77
+ this.entries.set(entry.id, entry);
78
+ this.bytes += entry.bytes;
79
+ this.evictToBudget();
80
+ return this.entries.has(entry.id) ? entry.id : undefined;
81
+ }
82
+
83
+ next(resultId: string): { content: [{ type: "text"; text: string }]; details: unknown } {
84
+ if (!isLspResultId(resultId)) {
85
+ return toolResult("Invalid LSP resultId format. Use the exact resultId from a previous paginated LSP result.", {
86
+ ok: false,
87
+ error: "invalid-result-id",
88
+ });
89
+ }
90
+
91
+ this.evictExpired();
92
+ const entry = this.entries.get(resultId);
93
+ if (!entry) {
94
+ return toolResult(
95
+ "Cached LSP result not found or expired. Re-run the original LSP query to get a fresh resultId.",
96
+ {
97
+ ok: false,
98
+ resultId,
99
+ error: "not-found",
100
+ },
101
+ );
102
+ }
103
+
104
+ entry.accessedAt = this.now();
105
+ const page = entry.pages[entry.nextPageIndex];
106
+ if (!page) {
107
+ this.delete(resultId);
108
+ return toolResult(`No more cached LSP pages for ${resultId}.`, {
109
+ ok: true,
110
+ resultId,
111
+ done: true,
112
+ });
113
+ }
114
+
115
+ entry.nextPageIndex += 1;
116
+ return toolResult(page.text, page.details);
117
+ }
118
+
119
+ clear(): void {
120
+ this.entries.clear();
121
+ this.bytes = 0;
122
+ }
123
+
124
+ stats(): LspResultCacheStats {
125
+ this.evictExpired();
126
+ return { entries: this.entries.size, bytes: this.bytes, maxBytes: this.maxBytes };
127
+ }
128
+
129
+ private evictExpired(): void {
130
+ const cutoff = this.now() - this.ttlMs;
131
+ for (const [id, entry] of this.entries) {
132
+ if (entry.accessedAt < cutoff) this.delete(id);
133
+ }
134
+ }
135
+
136
+ private evictToBudget(): void {
137
+ while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
138
+ const oldest = [...this.entries.values()].sort((a, b) => a.accessedAt - b.accessedAt)[0];
139
+ if (!oldest) return;
140
+ this.delete(oldest.id);
141
+ }
142
+ }
143
+
144
+ private delete(id: string): void {
145
+ const entry = this.entries.get(id);
146
+ if (!entry) return;
147
+ this.entries.delete(id);
148
+ this.bytes = Math.max(0, this.bytes - entry.bytes);
149
+ }
150
+ }
151
+
152
+ export function isLspResultId(value: string): boolean {
153
+ return value.length === LSP_RESULT_ID_LENGTH && LSP_RESULT_ID_REGEX.test(value);
154
+ }
155
+
156
+ function estimateBytes(value: unknown): number {
157
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
158
+ }
159
+
160
+ function toolResult(text: string, details: unknown): { content: [{ type: "text"; text: string }]; details: unknown } {
161
+ return { content: [{ type: "text", text }], details };
162
+ }
@@ -0,0 +1,123 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { LspStatusSnapshot } from "../commands/status.js";
4
+
5
+ export type LspPanelAction =
6
+ | { type: "doctor"; serverId: string }
7
+ | { type: "install"; serverId: string }
8
+ | { type: "update"; serverId: string }
9
+ | { type: "uninstall"; serverId: string }
10
+ | { type: "stop"; serverId?: string }
11
+ | { type: "refresh" }
12
+ | { type: "close" };
13
+
14
+ export class LspPanel {
15
+ private selected = 0;
16
+ private readonly serverIds: string[];
17
+
18
+ constructor(
19
+ private readonly snapshot: LspStatusSnapshot,
20
+ private readonly theme: Theme,
21
+ private readonly done: (action: LspPanelAction) => void,
22
+ ) {
23
+ this.serverIds = Object.keys(snapshot.config.catalog.servers).sort();
24
+ }
25
+
26
+ handleInput(data: string): void {
27
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
28
+ this.done({ type: "close" });
29
+ return;
30
+ }
31
+
32
+ if (matchesKey(data, "up")) {
33
+ this.selected = Math.max(0, this.selected - 1);
34
+ return;
35
+ }
36
+
37
+ if (matchesKey(data, "down")) {
38
+ this.selected = Math.min(Math.max(0, this.serverIds.length - 1), this.selected + 1);
39
+ return;
40
+ }
41
+
42
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
43
+ this.withSelectedServer((serverId) => this.done({ type: "doctor", serverId }));
44
+ return;
45
+ }
46
+
47
+ if (data === "i") this.withSelectedServer((serverId) => this.done({ type: "install", serverId }));
48
+ if (data === "u") this.withSelectedServer((serverId) => this.done({ type: "update", serverId }));
49
+ if (data === "x") this.withSelectedServer((serverId) => this.done({ type: "uninstall", serverId }));
50
+ if (data === "s") this.withSelectedServer((serverId) => this.done({ type: "stop", serverId }));
51
+ if (data === "S") this.done({ type: "stop" });
52
+ if (data === "r") this.done({ type: "refresh" });
53
+ }
54
+
55
+ render(width: number): string[] {
56
+ const panelWidth = Math.min(Math.max(60, width), 100);
57
+ const innerWidth = panelWidth - 2;
58
+ const lines: string[] = [];
59
+ const border = (text: string) => this.theme.fg("border", text);
60
+ const row = (content = "") => border("│") + padVisible(` ${content}`, innerWidth) + border("│");
61
+
62
+ lines.push(border(`╭${centerTitle(" LSP ", innerWidth)}╮`));
63
+ lines.push(
64
+ row(
65
+ `installMode: ${this.snapshot.config.installMode} warmup: ${this.snapshot.config.warmup ? "on" : "off"} tracked pids: ${this.snapshot.processes.length}`,
66
+ ),
67
+ );
68
+
69
+ if (this.snapshot.config.warnings.length > 0) {
70
+ lines.push(row(this.theme.fg("warning", `${this.snapshot.config.warnings.length} config warning(s)`)));
71
+ }
72
+
73
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
74
+
75
+ if (this.serverIds.length === 0) {
76
+ lines.push(row(this.theme.fg("dim", "No LSP servers configured")));
77
+ } else {
78
+ for (const [index, serverId] of this.serverIds.entries()) {
79
+ const server = this.snapshot.config.catalog.servers[serverId]!;
80
+ const installed = this.snapshot.lockfile.servers[serverId]
81
+ ? this.theme.fg("success", "installed")
82
+ : this.theme.fg("warning", "missing");
83
+ const pidCount = this.snapshot.processes.filter((process) => process.serverId === serverId).length;
84
+ const pointer = index === this.selected ? this.theme.fg("accent", "▶") : " ";
85
+ const label = index === this.selected ? this.theme.fg("accent", serverId) : serverId;
86
+ lines.push(
87
+ row(`${pointer} ${label} ${this.theme.fg("dim", server.displayName)} ${installed} pids:${pidCount}`),
88
+ );
89
+ }
90
+ }
91
+
92
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
93
+ lines.push(
94
+ row(
95
+ this.theme.fg(
96
+ "dim",
97
+ "↑↓ select • enter doctor • i install • u update • x uninstall • s stop • r refresh • esc close",
98
+ ),
99
+ ),
100
+ );
101
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
102
+
103
+ return lines.map((line) => truncateToWidth(line, width));
104
+ }
105
+
106
+ invalidate(): void {}
107
+
108
+ private withSelectedServer(callback: (serverId: string) => void): void {
109
+ const serverId = this.serverIds[this.selected];
110
+ if (serverId) callback(serverId);
111
+ }
112
+ }
113
+
114
+ function centerTitle(title: string, width: number): string {
115
+ const left = Math.max(0, Math.floor((width - visibleWidth(title)) / 2));
116
+ const right = Math.max(0, width - visibleWidth(title) - left);
117
+ return `${"─".repeat(left)}${title}${"─".repeat(right)}`;
118
+ }
119
+
120
+ function padVisible(value: string, width: number): string {
121
+ const truncated = truncateToWidth(value, width, "…", true);
122
+ return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
123
+ }
@@ -0,0 +1,38 @@
1
+ import { isPlainObject } from "./helpers.js";
2
+
3
+ export function deepClone<T>(value: T): T {
4
+ if (Array.isArray(value)) {
5
+ return value.map((item) => deepClone(item)) as T;
6
+ }
7
+
8
+ if (isPlainObject(value)) {
9
+ const cloned: Record<string, unknown> = {};
10
+ for (const [key, item] of Object.entries(value)) {
11
+ cloned[key] = deepClone(item);
12
+ }
13
+ return cloned as T;
14
+ }
15
+
16
+ return value;
17
+ }
18
+
19
+ export function deepMerge<T>(base: T, override: unknown): T {
20
+ if (override === undefined) {
21
+ return deepClone(base);
22
+ }
23
+
24
+ if (Array.isArray(override) || !isPlainObject(override)) {
25
+ return deepClone(override) as T;
26
+ }
27
+
28
+ if (!isPlainObject(base)) {
29
+ return deepClone(override) as T;
30
+ }
31
+
32
+ const merged: Record<string, unknown> = deepClone(base);
33
+ for (const [key, value] of Object.entries(override)) {
34
+ merged[key] = deepMerge(merged[key], value);
35
+ }
36
+
37
+ return merged as T;
38
+ }
@@ -0,0 +1,22 @@
1
+ export class ConfigError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = new.target.name;
5
+ }
6
+ }
7
+
8
+ export class MissingEnvironmentVariableError extends ConfigError {
9
+ constructor(variableName: string, field?: string) {
10
+ super(
11
+ field
12
+ ? `Missing environment variable ${variableName} referenced by ${field}.`
13
+ : `Missing environment variable ${variableName}.`,
14
+ );
15
+ }
16
+ }
17
+
18
+ export class MissingBinaryError extends ConfigError {
19
+ constructor(serverId: string, command: string) {
20
+ super(`${serverId} binary not found: ${command}`);
21
+ }
22
+ }
@@ -0,0 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+
4
+ export function stableHash(value: string, length = 16): string {
5
+ return createHash("sha256").update(value).digest("hex").slice(0, length);
6
+ }
7
+
8
+ export function hashPath(path: string, length = 16): string {
9
+ return stableHash(resolve(path), length);
10
+ }
@@ -0,0 +1,41 @@
1
+ import { access } from "node:fs/promises";
2
+
3
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
8
+ return typeof error === "object" && error !== null && "code" in error;
9
+ }
10
+
11
+ export function messageFromError(error: unknown): string {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+
15
+ export function delay(ms: number): Promise<void> {
16
+ if (ms <= 0) return Promise.resolve();
17
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
18
+ }
19
+
20
+ export function normalizeProcessEnv(env: NodeJS.ProcessEnv): Record<string, string> {
21
+ const normalized: Record<string, string> = {};
22
+ for (const [key, value] of Object.entries(env)) {
23
+ if (typeof value === "string") {
24
+ normalized[key] = value;
25
+ }
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ export async function pathExists(path: string): Promise<boolean> {
31
+ try {
32
+ await access(path);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function isPythonServerId(serverId: string): boolean {
40
+ return serverId === "pyright" || serverId === "basedpyright";
41
+ }