decorated-pi 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.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/extensions/extend-model.ts +410 -0
- package/extensions/guidance.ts +21 -0
- package/extensions/index.ts +24 -0
- package/extensions/lsp/client.ts +525 -0
- package/extensions/lsp/env.ts +12 -0
- package/extensions/lsp/format.ts +349 -0
- package/extensions/lsp/index.ts +14 -0
- package/extensions/lsp/prompt.ts +39 -0
- package/extensions/lsp/server-manager.ts +303 -0
- package/extensions/lsp/servers.ts +229 -0
- package/extensions/lsp/tools.ts +530 -0
- package/extensions/lsp/trust.ts +39 -0
- package/extensions/safety.ts +370 -0
- package/extensions/session-title.ts +40 -0
- package/extensions/settings.ts +62 -0
- package/extensions/slash.ts +67 -0
- package/extensions/smart-at.ts +220 -0
- package/extensions/subdir-agents.ts +121 -0
- package/index.ts +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { LspDiagnostic, LspHover, LspLocation, LspDocumentSymbol } from "./client.js";
|
|
3
|
+
import { LspClientStartError } from "./client.js";
|
|
4
|
+
import { get_server_config, list_supported_languages } from "./servers.js";
|
|
5
|
+
|
|
6
|
+
export interface LspToolErrorDetail {
|
|
7
|
+
kind: string;
|
|
8
|
+
file?: string;
|
|
9
|
+
language?: string;
|
|
10
|
+
workspace_root?: string;
|
|
11
|
+
command?: string;
|
|
12
|
+
install_hint?: string;
|
|
13
|
+
message: string;
|
|
14
|
+
code?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class LspToolError extends Error {
|
|
18
|
+
details: LspToolErrorDetail;
|
|
19
|
+
constructor(details: LspToolErrorDetail) {
|
|
20
|
+
super(details.message);
|
|
21
|
+
this.name = "LspToolError";
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SYMBOL_KIND_LABELS: Record<number, string> = {
|
|
27
|
+
2: "module",
|
|
28
|
+
3: "namespace",
|
|
29
|
+
5: "class",
|
|
30
|
+
6: "method",
|
|
31
|
+
7: "property",
|
|
32
|
+
8: "field",
|
|
33
|
+
9: "constructor",
|
|
34
|
+
11: "interface",
|
|
35
|
+
12: "function",
|
|
36
|
+
13: "variable",
|
|
37
|
+
14: "constant",
|
|
38
|
+
23: "struct",
|
|
39
|
+
24: "event",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SYMBOL_KIND_NAMES = Object.values(SYMBOL_KIND_LABELS);
|
|
43
|
+
|
|
44
|
+
export function format_status_lines(
|
|
45
|
+
cwd: string,
|
|
46
|
+
clients_by_server: Map<string, any>,
|
|
47
|
+
failed_servers: Map<string, any>
|
|
48
|
+
): string[] {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
const active_languages = new Set<string>();
|
|
51
|
+
|
|
52
|
+
const running_states = Array.from(clients_by_server.values()).sort(
|
|
53
|
+
(a: any, b: any) =>
|
|
54
|
+
a.language.localeCompare(b.language) ||
|
|
55
|
+
a.workspace_root.localeCompare(b.workspace_root)
|
|
56
|
+
);
|
|
57
|
+
for (const running of running_states) {
|
|
58
|
+
active_languages.add(running.language);
|
|
59
|
+
lines.push(
|
|
60
|
+
`${running.language}: running (ready=${running.client.is_ready()}) — ${running.command} [workspace ${running.workspace_root}]`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const failures = Array.from(failed_servers.values()).sort(
|
|
65
|
+
(a: any, b: any) =>
|
|
66
|
+
(a.language ?? "").localeCompare(b.language ?? "") ||
|
|
67
|
+
(a.workspace_root ?? "").localeCompare(b.workspace_root ?? "")
|
|
68
|
+
);
|
|
69
|
+
for (const failure of failures) {
|
|
70
|
+
if (failure.language) active_languages.add(failure.language);
|
|
71
|
+
const workspace = failure.workspace_root
|
|
72
|
+
? ` [workspace ${failure.workspace_root}]`
|
|
73
|
+
: "";
|
|
74
|
+
const language = failure.language ?? "unknown";
|
|
75
|
+
lines.push(`${language}: failed — ${failure.message}${workspace}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const language of list_supported_languages()) {
|
|
79
|
+
if (active_languages.has(language)) continue;
|
|
80
|
+
const config = get_server_config(language, cwd);
|
|
81
|
+
if (config) {
|
|
82
|
+
lines.push(`${language}: idle — ${config.command}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return lines.length > 0 ? lines : ["No language servers configured for this project."];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function to_lsp_tool_error(
|
|
90
|
+
file: string,
|
|
91
|
+
language: string,
|
|
92
|
+
workspace_root: string | undefined,
|
|
93
|
+
command: string,
|
|
94
|
+
install_hint: string | undefined,
|
|
95
|
+
error: unknown
|
|
96
|
+
): LspToolErrorDetail {
|
|
97
|
+
if (error instanceof LspToolError) {
|
|
98
|
+
return error.details;
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof LspClientStartError) {
|
|
101
|
+
const missing_binary = error.code === "ENOENT";
|
|
102
|
+
return {
|
|
103
|
+
kind: "server_start_failed",
|
|
104
|
+
file,
|
|
105
|
+
language,
|
|
106
|
+
workspace_root,
|
|
107
|
+
command,
|
|
108
|
+
install_hint,
|
|
109
|
+
code: error.code,
|
|
110
|
+
message: missing_binary
|
|
111
|
+
? `command "${command}" not found`
|
|
112
|
+
: error.message,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const err = error as Record<string, unknown> | undefined;
|
|
116
|
+
return {
|
|
117
|
+
kind: "tool_execution_failed",
|
|
118
|
+
file,
|
|
119
|
+
language,
|
|
120
|
+
workspace_root,
|
|
121
|
+
command,
|
|
122
|
+
install_hint,
|
|
123
|
+
message: error instanceof Error ? error.message : String(error),
|
|
124
|
+
code:
|
|
125
|
+
err && typeof err.code === "string" ? err.code : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function format_tool_error(details: LspToolErrorDetail): string {
|
|
130
|
+
if (details.kind === "unsupported_language") {
|
|
131
|
+
return details.message;
|
|
132
|
+
}
|
|
133
|
+
const lines = [
|
|
134
|
+
details.language
|
|
135
|
+
? `${details.language} LSP unavailable for ${details.file}`
|
|
136
|
+
: `LSP request failed for ${details.file}`,
|
|
137
|
+
`Reason: ${details.message}`,
|
|
138
|
+
];
|
|
139
|
+
if (details.command) lines.push(`Command: ${details.command}`);
|
|
140
|
+
if (details.workspace_root) lines.push(`Workspace: ${details.workspace_root}`);
|
|
141
|
+
if (details.install_hint) lines.push(`Hint: ${details.install_hint}`);
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function severity_label(severity: number): string {
|
|
146
|
+
switch (severity) {
|
|
147
|
+
case 1: return "error";
|
|
148
|
+
case 2: return "warning";
|
|
149
|
+
case 3: return "info";
|
|
150
|
+
case 4: return "hint";
|
|
151
|
+
default: return "info";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type SeverityFilter = "error" | "warning" | "info" | "hint";
|
|
156
|
+
|
|
157
|
+
const SEVERITY_MAP: Record<SeverityFilter, number> = {
|
|
158
|
+
error: 1,
|
|
159
|
+
warning: 2,
|
|
160
|
+
info: 3,
|
|
161
|
+
hint: 4,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export function filter_diagnostics(
|
|
165
|
+
diagnostics: LspDiagnostic[],
|
|
166
|
+
severities?: SeverityFilter[]
|
|
167
|
+
): LspDiagnostic[] {
|
|
168
|
+
if (!severities || severities.length === 0) return diagnostics;
|
|
169
|
+
// 取最小的 severity 值(error=1 < warning=2 < info=3 < hint=4)
|
|
170
|
+
const minSeverity = Math.min(...severities.map((s) => SEVERITY_MAP[s]));
|
|
171
|
+
// 显示 severity <= minSeverity(更严重 + 自身)
|
|
172
|
+
return diagnostics.filter((d) => (d.severity ?? 1) <= minSeverity);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function format_diagnostics(
|
|
176
|
+
file: string,
|
|
177
|
+
diagnostics: LspDiagnostic[],
|
|
178
|
+
severities?: SeverityFilter[]
|
|
179
|
+
): string {
|
|
180
|
+
const filtered = filter_diagnostics(diagnostics, severities);
|
|
181
|
+
if (filtered.length === 0) return `${file}: no diagnostics`;
|
|
182
|
+
const lines = [`${file}: ${filtered.length} diagnostic(s)`];
|
|
183
|
+
for (const d of filtered) {
|
|
184
|
+
const position = `${d.range.start.line + 1}:${d.range.start.character + 1}`;
|
|
185
|
+
const source = d.source ? ` [${d.source}]` : "";
|
|
186
|
+
const code = d.code != null ? ` (${d.code})` : "";
|
|
187
|
+
lines.push(
|
|
188
|
+
` ${position} ${severity_label(d.severity ?? 1)}${source}${code}: ${d.message}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function format_hover(hover: LspHover | null): string {
|
|
195
|
+
if (!hover) return "No hover info.";
|
|
196
|
+
const contents = hover.contents;
|
|
197
|
+
const extract = (item: unknown): string =>
|
|
198
|
+
typeof item === "string" ? item : ((item as { value?: string })?.value ?? "");
|
|
199
|
+
if (Array.isArray(contents)) {
|
|
200
|
+
return contents.map(extract).join("\n\n").trim() || "No hover info.";
|
|
201
|
+
}
|
|
202
|
+
return extract(contents).trim() || "No hover info.";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function format_locations(
|
|
206
|
+
locations: LspLocation[],
|
|
207
|
+
empty_message: string
|
|
208
|
+
): string {
|
|
209
|
+
if (locations.length === 0) return empty_message;
|
|
210
|
+
return locations
|
|
211
|
+
.map((loc) => {
|
|
212
|
+
const path = file_url_to_path_or_value(loc.uri);
|
|
213
|
+
return `${path}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
|
|
214
|
+
})
|
|
215
|
+
.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function format_document_symbols(
|
|
219
|
+
file: string,
|
|
220
|
+
symbols: LspDocumentSymbol[]
|
|
221
|
+
): string {
|
|
222
|
+
if (symbols.length === 0) return `${file}: no symbols`;
|
|
223
|
+
const lines = [`${file}: ${symbols.length} top-level symbol(s)`];
|
|
224
|
+
append_symbol_lines(lines, symbols, 1);
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface SymbolMatchOptions {
|
|
229
|
+
max_results: number;
|
|
230
|
+
top_level_only: boolean;
|
|
231
|
+
exact_match: boolean;
|
|
232
|
+
kinds: Set<string>;
|
|
233
|
+
language: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface SymbolMatch {
|
|
237
|
+
symbol: LspDocumentSymbol;
|
|
238
|
+
depth: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function find_symbol_matches(
|
|
242
|
+
symbols: LspDocumentSymbol[],
|
|
243
|
+
query: string,
|
|
244
|
+
options: SymbolMatchOptions
|
|
245
|
+
): SymbolMatch[] {
|
|
246
|
+
const normalized = query.trim().toLowerCase();
|
|
247
|
+
if (!normalized) return [];
|
|
248
|
+
|
|
249
|
+
const matches: SymbolMatch[] = [];
|
|
250
|
+
|
|
251
|
+
const expand_exact_name_values = (name: string): string[] => {
|
|
252
|
+
const trimmed = name.trim().toLowerCase();
|
|
253
|
+
if (!trimmed) return [];
|
|
254
|
+
const expanded = new Set([trimmed]);
|
|
255
|
+
if (options.language === "cpp" && trimmed.includes("::")) {
|
|
256
|
+
const parts = trimmed
|
|
257
|
+
.split("::")
|
|
258
|
+
.map((part) => part.trim())
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
if (parts.length > 0) expanded.add(parts[parts.length - 1]!);
|
|
261
|
+
}
|
|
262
|
+
return Array.from(expanded);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const matches_query = (symbol: LspDocumentSymbol): boolean => {
|
|
266
|
+
const raw_name = symbol.name.trim().toLowerCase();
|
|
267
|
+
const raw_detail = (symbol.detail ?? "").trim().toLowerCase();
|
|
268
|
+
if (options.exact_match) {
|
|
269
|
+
const exact_values = [
|
|
270
|
+
...expand_exact_name_values(symbol.name),
|
|
271
|
+
...(raw_detail ? [raw_detail] : []),
|
|
272
|
+
];
|
|
273
|
+
return exact_values.some((value) => value === normalized);
|
|
274
|
+
}
|
|
275
|
+
const fuzzy_values = [raw_name, raw_detail].filter(Boolean);
|
|
276
|
+
return fuzzy_values.some((value) => value.includes(normalized));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const matches_kind = (symbol: LspDocumentSymbol): boolean => {
|
|
280
|
+
if (options.kinds.size === 0) return true;
|
|
281
|
+
return options.kinds.has(symbol_kind_label(symbol.kind));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const visit = (entries: LspDocumentSymbol[], depth: number): void => {
|
|
285
|
+
for (const symbol of entries) {
|
|
286
|
+
if (matches_kind(symbol) && matches_query(symbol)) {
|
|
287
|
+
matches.push({ symbol, depth });
|
|
288
|
+
if (matches.length >= options.max_results) return;
|
|
289
|
+
}
|
|
290
|
+
if (!options.top_level_only && symbol.children?.length) {
|
|
291
|
+
visit(symbol.children, depth + 1);
|
|
292
|
+
if (matches.length >= options.max_results) return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
visit(symbols, 1);
|
|
298
|
+
return matches;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function format_symbol_matches(
|
|
302
|
+
file: string,
|
|
303
|
+
query: string,
|
|
304
|
+
matches: SymbolMatch[]
|
|
305
|
+
): string {
|
|
306
|
+
if (matches.length === 0) {
|
|
307
|
+
return `${file}: no symbols matching "${query}"`;
|
|
308
|
+
}
|
|
309
|
+
const lines = [`${file}: ${matches.length} symbol match(es) for "${query}"`];
|
|
310
|
+
for (const { symbol, depth } of matches) {
|
|
311
|
+
const indent = " ".repeat(depth);
|
|
312
|
+
const detail = symbol.detail ? ` — ${symbol.detail}` : "";
|
|
313
|
+
const range = `${symbol.range.start.line + 1}:${symbol.range.start.character + 1}`;
|
|
314
|
+
lines.push(
|
|
315
|
+
`${indent}${symbol_kind_label(symbol.kind)} ${symbol.name}${detail} @ ${range}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return lines.join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function append_symbol_lines(
|
|
322
|
+
lines: string[],
|
|
323
|
+
symbols: LspDocumentSymbol[],
|
|
324
|
+
depth: number
|
|
325
|
+
): void {
|
|
326
|
+
for (const symbol of symbols) {
|
|
327
|
+
const indent = " ".repeat(depth);
|
|
328
|
+
const detail = symbol.detail ? ` — ${symbol.detail}` : "";
|
|
329
|
+
const range = `${symbol.range.start.line + 1}:${symbol.range.start.character + 1}`;
|
|
330
|
+
lines.push(
|
|
331
|
+
`${indent}${symbol_kind_label(symbol.kind)} ${symbol.name}${detail} @ ${range}`
|
|
332
|
+
);
|
|
333
|
+
if (symbol.children?.length) {
|
|
334
|
+
append_symbol_lines(lines, symbol.children, depth + 1);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function symbol_kind_label(kind: number): string {
|
|
340
|
+
return SYMBOL_KIND_LABELS[kind] ?? "symbol";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function file_url_to_path_or_value(uri: string): string {
|
|
344
|
+
try {
|
|
345
|
+
return uri.startsWith("file:") ? fileURLToPath(uri) : uri;
|
|
346
|
+
} catch {
|
|
347
|
+
return uri;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LspServerManager } from "./server-manager.js";
|
|
2
|
+
import { register_lsp_tools } from "./tools.js";
|
|
3
|
+
import { setup_lsp_prompt } from "./prompt.js";
|
|
4
|
+
|
|
5
|
+
export function setupLsp(pi: any) {
|
|
6
|
+
const manager = new LspServerManager();
|
|
7
|
+
|
|
8
|
+
setup_lsp_prompt(pi);
|
|
9
|
+
register_lsp_tools(pi, manager);
|
|
10
|
+
|
|
11
|
+
pi.on("session_shutdown", async () => {
|
|
12
|
+
await manager.clear_language_state();
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { list_supported_languages } from "./servers.js";
|
|
3
|
+
|
|
4
|
+
export const LSP_TOOL_NAMES = new Set([
|
|
5
|
+
"lsp_diagnostics",
|
|
6
|
+
"lsp_find_symbol",
|
|
7
|
+
"lsp_hover",
|
|
8
|
+
"lsp_definition",
|
|
9
|
+
"lsp_references",
|
|
10
|
+
"lsp_document_symbols",
|
|
11
|
+
"lsp_rename",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const LSP_GUIDANCE_MARKER = "### LSP Guidance";
|
|
15
|
+
|
|
16
|
+
export function setup_lsp_prompt(pi: ExtensionAPI) {
|
|
17
|
+
pi.on("before_agent_start", async (event) => {
|
|
18
|
+
if (!should_inject_lsp_prompt(event.systemPromptOptions)) return;
|
|
19
|
+
if (event.systemPrompt.includes(LSP_GUIDANCE_MARKER)) return;
|
|
20
|
+
|
|
21
|
+
const languages = list_supported_languages().join(", ");
|
|
22
|
+
const guidance = [
|
|
23
|
+
LSP_GUIDANCE_MARKER,
|
|
24
|
+
"",
|
|
25
|
+
`Consider using LSP tools when reading or editing source files. Supported languages: ${languages}. Use them when debugging language-server-supported errors, checking types, symbol definitions or API documentation from code, finding references more precisely than text search, and renaming symbols across a project.`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
systemPrompt: `${event.systemPrompt}\n\n${guidance}`,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function should_inject_lsp_prompt(options?: {
|
|
35
|
+
selectedTools?: string[];
|
|
36
|
+
}): boolean {
|
|
37
|
+
const tools = options?.selectedTools;
|
|
38
|
+
return !tools || tools.some((tool) => LSP_TOOL_NAMES.has(tool));
|
|
39
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { resolve_project_trust } from "@spences10/pi-project-trust";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
file_path_to_uri,
|
|
6
|
+
LspClient,
|
|
7
|
+
type LspClientOptions,
|
|
8
|
+
} from "./client.js";
|
|
9
|
+
import {
|
|
10
|
+
LspToolError,
|
|
11
|
+
to_lsp_tool_error,
|
|
12
|
+
} from "./format.js";
|
|
13
|
+
import {
|
|
14
|
+
detect_language,
|
|
15
|
+
find_workspace_root,
|
|
16
|
+
get_server_config,
|
|
17
|
+
language_id_for_file,
|
|
18
|
+
type LanguageConfig,
|
|
19
|
+
} from "./servers.js";
|
|
20
|
+
import {
|
|
21
|
+
create_lsp_binary_trust_subject,
|
|
22
|
+
default_lsp_trust_store_path,
|
|
23
|
+
is_lsp_binary_trusted,
|
|
24
|
+
} from "./trust.js";
|
|
25
|
+
|
|
26
|
+
const LSP_PROJECT_BINARY_ENV = "MY_PI_LSP_PROJECT_BINARY";
|
|
27
|
+
|
|
28
|
+
class LspStartupCancelledError extends Error {
|
|
29
|
+
constructor(language: string, workspace_root: string) {
|
|
30
|
+
super(`Startup cancelled for ${language} LSP in ${workspace_root}`);
|
|
31
|
+
this.name = "LspStartupCancelledError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FileState {
|
|
36
|
+
client: LspClient;
|
|
37
|
+
language: string;
|
|
38
|
+
workspace_root: string;
|
|
39
|
+
root_uri: string;
|
|
40
|
+
command: string;
|
|
41
|
+
install_hint: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolvedFileState {
|
|
45
|
+
abs: string;
|
|
46
|
+
uri: string;
|
|
47
|
+
state: FileState;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Startup {
|
|
51
|
+
cancelled: boolean;
|
|
52
|
+
promise: Promise<FileState | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LspServerManagerOptions {
|
|
56
|
+
cwd?: () => string;
|
|
57
|
+
create_client?: (options: LspClientOptions) => LspClient;
|
|
58
|
+
read_file?: (path: string) => Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class LspServerManager {
|
|
62
|
+
cwd: string;
|
|
63
|
+
clients_by_server = new Map<string, FileState>();
|
|
64
|
+
failed_servers = new Map<string, any>();
|
|
65
|
+
#create_client: (options: LspClientOptions) => LspClient;
|
|
66
|
+
#read_file: (path: string) => Promise<string>;
|
|
67
|
+
#starting_servers = new Map<string, Startup>();
|
|
68
|
+
|
|
69
|
+
constructor(options: LspServerManagerOptions = {}) {
|
|
70
|
+
this.cwd = options.cwd?.() ?? process.cwd();
|
|
71
|
+
this.#create_client =
|
|
72
|
+
options.create_client ??
|
|
73
|
+
((client_options) => new LspClient(client_options));
|
|
74
|
+
this.#read_file =
|
|
75
|
+
options.read_file ?? ((path) => readFile(path, "utf-8"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
resolve_abs(file: string): string {
|
|
79
|
+
return isAbsolute(file) ? file : resolve(this.cwd, file);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async clear_language_state(language?: string): Promise<void> {
|
|
83
|
+
const states = language
|
|
84
|
+
? Array.from(this.clients_by_server.entries()).filter(
|
|
85
|
+
([, state]) => state.language === language
|
|
86
|
+
)
|
|
87
|
+
: Array.from(this.clients_by_server.entries());
|
|
88
|
+
|
|
89
|
+
const starting = language
|
|
90
|
+
? Array.from(this.#starting_servers.entries()).filter(([key]) =>
|
|
91
|
+
key.startsWith(`${language}\u0000`)
|
|
92
|
+
)
|
|
93
|
+
: Array.from(this.#starting_servers.entries());
|
|
94
|
+
|
|
95
|
+
for (const [key, startup] of starting) {
|
|
96
|
+
startup.cancelled = true;
|
|
97
|
+
this.#starting_servers.delete(key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await Promise.allSettled(states.map(([, state]) => state.client.stop()));
|
|
101
|
+
|
|
102
|
+
for (const [key] of states) {
|
|
103
|
+
this.clients_by_server.delete(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!language) {
|
|
107
|
+
this.failed_servers.clear();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const [key, failure] of this.failed_servers.entries()) {
|
|
112
|
+
if (failure.language === language) {
|
|
113
|
+
this.failed_servers.delete(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async resolve_file_state(
|
|
119
|
+
file: string,
|
|
120
|
+
ctx?: any
|
|
121
|
+
): Promise<
|
|
122
|
+
| { ok: true; result: ResolvedFileState }
|
|
123
|
+
| { ok: false; error: any }
|
|
124
|
+
> {
|
|
125
|
+
const abs = this.resolve_abs(file);
|
|
126
|
+
try {
|
|
127
|
+
const result = await this.#get_file_state(abs, ctx);
|
|
128
|
+
if (!result) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: {
|
|
132
|
+
kind: "unsupported_language",
|
|
133
|
+
file: abs,
|
|
134
|
+
message: `No language server configured for ${abs}`,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, result };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof LspToolError) {
|
|
141
|
+
return { ok: false, error: error.details };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: {
|
|
146
|
+
kind: "tool_execution_failed",
|
|
147
|
+
file: abs,
|
|
148
|
+
message: error instanceof Error ? error.message : String(error),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async #get_file_state(
|
|
155
|
+
file: string,
|
|
156
|
+
ctx?: any
|
|
157
|
+
): Promise<ResolvedFileState | undefined> {
|
|
158
|
+
const abs = this.resolve_abs(file);
|
|
159
|
+
const state = await this.#get_or_start_client(abs, ctx);
|
|
160
|
+
if (!state) return undefined;
|
|
161
|
+
const uri = await this.#open_file(state, abs);
|
|
162
|
+
return { abs, uri, state };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #get_or_start_client(
|
|
166
|
+
file_path: string,
|
|
167
|
+
ctx?: any
|
|
168
|
+
): Promise<FileState | undefined> {
|
|
169
|
+
const language = detect_language(file_path);
|
|
170
|
+
if (!language) return undefined;
|
|
171
|
+
|
|
172
|
+
const workspace_root = find_workspace_root(file_path, this.cwd);
|
|
173
|
+
const key = `${language}\u0000${workspace_root}`;
|
|
174
|
+
|
|
175
|
+
const existing = this.clients_by_server.get(key);
|
|
176
|
+
if (existing) return existing;
|
|
177
|
+
|
|
178
|
+
const failed = this.failed_servers.get(key);
|
|
179
|
+
if (failed) {
|
|
180
|
+
throw new LspToolError(failed);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const in_flight = this.#starting_servers.get(key);
|
|
184
|
+
if (in_flight) return in_flight.promise;
|
|
185
|
+
|
|
186
|
+
let server_config = get_server_config(language, workspace_root);
|
|
187
|
+
if (!server_config) return undefined;
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
server_config.is_project_local &&
|
|
191
|
+
!(await should_use_project_lsp_binary(server_config, ctx))
|
|
192
|
+
) {
|
|
193
|
+
server_config = get_server_config(language, "/");
|
|
194
|
+
if (!server_config) return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const root_uri = file_path_to_uri(workspace_root);
|
|
198
|
+
|
|
199
|
+
const startup: Startup = {
|
|
200
|
+
cancelled: false,
|
|
201
|
+
promise: Promise.resolve(undefined),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const start_promise = (async () => {
|
|
205
|
+
const client = this.#create_client({
|
|
206
|
+
command: server_config!.command,
|
|
207
|
+
args: server_config!.args,
|
|
208
|
+
root_uri,
|
|
209
|
+
language_id_for_uri: (uri) => language_id_for_file(uri),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await client.start();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (startup.cancelled) {
|
|
216
|
+
throw new LspStartupCancelledError(language, workspace_root);
|
|
217
|
+
}
|
|
218
|
+
const failure = to_lsp_tool_error(
|
|
219
|
+
file_path,
|
|
220
|
+
language,
|
|
221
|
+
workspace_root,
|
|
222
|
+
server_config!.command,
|
|
223
|
+
server_config!.install_hint,
|
|
224
|
+
error
|
|
225
|
+
);
|
|
226
|
+
this.failed_servers.set(key, failure);
|
|
227
|
+
throw new LspToolError(failure);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (startup.cancelled) {
|
|
231
|
+
await Promise.allSettled([client.stop()]);
|
|
232
|
+
throw new LspStartupCancelledError(language, workspace_root);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const state: FileState = {
|
|
236
|
+
client,
|
|
237
|
+
language,
|
|
238
|
+
workspace_root,
|
|
239
|
+
root_uri,
|
|
240
|
+
command: server_config!.command,
|
|
241
|
+
install_hint: server_config!.install_hint,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
this.clients_by_server.set(key, state);
|
|
245
|
+
this.failed_servers.delete(key);
|
|
246
|
+
return state;
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
startup.promise = start_promise;
|
|
250
|
+
this.#starting_servers.set(key, startup);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
return await start_promise;
|
|
254
|
+
} finally {
|
|
255
|
+
if (this.#starting_servers.get(key) === startup) {
|
|
256
|
+
this.#starting_servers.delete(key);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async #open_file(state: FileState, abs_path: string): Promise<string> {
|
|
262
|
+
const text = await this.#read_file(abs_path);
|
|
263
|
+
const uri = file_path_to_uri(abs_path);
|
|
264
|
+
await state.client.ensure_document_open(uri, text);
|
|
265
|
+
return uri;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function should_use_project_lsp_binary(
|
|
270
|
+
server_config: LanguageConfig,
|
|
271
|
+
ctx?: any
|
|
272
|
+
): Promise<boolean> {
|
|
273
|
+
if (!server_config.is_project_local) return true;
|
|
274
|
+
|
|
275
|
+
if (is_lsp_binary_trusted(server_config.command)) return true;
|
|
276
|
+
|
|
277
|
+
const subject = {
|
|
278
|
+
...create_lsp_binary_trust_subject(server_config.command),
|
|
279
|
+
prompt_title:
|
|
280
|
+
"Project-local language server binaries can execute code.\nTrust this LSP binary?",
|
|
281
|
+
summary_lines: [
|
|
282
|
+
`Language: ${server_config.language}`,
|
|
283
|
+
`Binary: ${server_config.command}`,
|
|
284
|
+
],
|
|
285
|
+
headless_warning: `Skipping untrusted project-local LSP binary: ${server_config.command}. Set ${LSP_PROJECT_BINARY_ENV}=allow to enable it for this run.`,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const decision = await resolve_project_trust(subject, {
|
|
289
|
+
env: process.env,
|
|
290
|
+
has_ui: ctx?.hasUI,
|
|
291
|
+
select: ctx?.hasUI
|
|
292
|
+
? async (message: string, choices: any) =>
|
|
293
|
+
(await ctx.ui.select(message, choices)) ?? ""
|
|
294
|
+
: undefined,
|
|
295
|
+
warn: console.warn,
|
|
296
|
+
trust_store_path: default_lsp_trust_store_path(),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
decision.action === "allow-once" ||
|
|
301
|
+
decision.action === "trust-persisted"
|
|
302
|
+
);
|
|
303
|
+
}
|