@dreki-gg/pi-lsp 0.2.1 → 0.4.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/CHANGELOG.md +34 -0
- package/README.md +6 -0
- package/extensions/lsp/client.ts +181 -85
- package/extensions/lsp/config.ts +133 -111
- package/extensions/lsp/effects/command.ts +42 -0
- package/extensions/lsp/effects/filesystem.ts +50 -0
- package/extensions/lsp/effects/runtime.ts +21 -0
- package/extensions/lsp/errors.ts +113 -0
- package/extensions/lsp/formatting.ts +6 -3
- package/extensions/lsp/index.ts +24 -8
- package/extensions/lsp/retry.ts +39 -0
- package/extensions/lsp/tools/programs.ts +293 -0
- package/extensions/lsp/tools.ts +36 -238
- package/package.json +4 -1
- package/test/config.test.ts +34 -0
- package/test/effects.test.ts +107 -0
- package/test/formatting.test.ts +3 -3
- package/test/index.test.ts +3 -2
- package/test/mock-lsp-server.ts +20 -13
- package/test/retry.test.ts +79 -0
- package/test/tools.test.ts +97 -88
- package/test/typescript.integration.test.ts +18 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry utility for LSP operations that may return empty results during server indexing.
|
|
3
|
+
*
|
|
4
|
+
* Only retries when the server was recently initialized (within a configurable window),
|
|
5
|
+
* avoiding unnecessary delays for servers that are already warmed up.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RetryOptions {
|
|
9
|
+
/** Maximum number of retry attempts. Default: 2 */
|
|
10
|
+
maxRetries?: number;
|
|
11
|
+
/** Delay between retries in milliseconds. Default: 2000 */
|
|
12
|
+
delayMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Retry an async operation if the result is considered "empty".
|
|
17
|
+
*
|
|
18
|
+
* Useful for LSP operations that return empty results while the server is still indexing
|
|
19
|
+
* (e.g. workspaceSymbol, hover, definition, references).
|
|
20
|
+
*/
|
|
21
|
+
export async function withRetry<T>(
|
|
22
|
+
operation: () => Promise<T>,
|
|
23
|
+
isEmpty: (result: T) => boolean,
|
|
24
|
+
options?: RetryOptions,
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
const maxRetries = options?.maxRetries ?? 2;
|
|
27
|
+
const delayMs = options?.delayMs ?? 2000;
|
|
28
|
+
|
|
29
|
+
let result = await operation();
|
|
30
|
+
let attempt = 0;
|
|
31
|
+
|
|
32
|
+
while (isEmpty(result) && attempt < maxRetries) {
|
|
33
|
+
attempt++;
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
35
|
+
result = await operation();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect programs backing the unified `lsp` tool.
|
|
3
|
+
*
|
|
4
|
+
* Each operation is an Effect that depends on the `ServerManager` service to
|
|
5
|
+
* locate the right client, then wraps the client's async LSP calls in
|
|
6
|
+
* `Effect.tryPromise` so transport failures surface as typed
|
|
7
|
+
* `LspOperationError`s. Routing/validation failures are typed too
|
|
8
|
+
* (`LspValidationError`, `NoCapableServerError`, `NoServerAvailableError`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Context, Effect } from 'effect';
|
|
12
|
+
|
|
13
|
+
import type { LspClient } from '../client';
|
|
14
|
+
import {
|
|
15
|
+
LspOperationError,
|
|
16
|
+
LspValidationError,
|
|
17
|
+
NoCapableServerError,
|
|
18
|
+
NoServerAvailableError,
|
|
19
|
+
type LspExtensionError,
|
|
20
|
+
} from '../errors';
|
|
21
|
+
import {
|
|
22
|
+
formatCallHierarchy,
|
|
23
|
+
formatCodeActions,
|
|
24
|
+
formatDiagnostics,
|
|
25
|
+
formatDocumentSymbols,
|
|
26
|
+
formatHover,
|
|
27
|
+
formatIncomingCalls,
|
|
28
|
+
formatLocations,
|
|
29
|
+
formatOutgoingCalls,
|
|
30
|
+
formatWorkspaceSymbols,
|
|
31
|
+
} from '../formatting';
|
|
32
|
+
import type { Diagnostic } from '../types';
|
|
33
|
+
import {
|
|
34
|
+
FILE_ONLY_OPERATIONS,
|
|
35
|
+
type LspOperation,
|
|
36
|
+
POSITION_OPERATIONS,
|
|
37
|
+
QUERY_OPERATIONS,
|
|
38
|
+
} from '../types';
|
|
39
|
+
|
|
40
|
+
// ── ServerManager service ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface ServerManagerService {
|
|
43
|
+
/** Get all LSP clients that handle a given file extension. */
|
|
44
|
+
clientsForFile: (filePath: string) => LspClient[];
|
|
45
|
+
/** Get the first LSP client that handles a file and has a capability. */
|
|
46
|
+
clientForFileWithCapability: (filePath: string, capability: string) => LspClient | null;
|
|
47
|
+
/** Get any initialized client (for workspace-wide ops). */
|
|
48
|
+
anyClient: () => LspClient | null;
|
|
49
|
+
/** Current root path. */
|
|
50
|
+
getRootPath: () => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ServerManager extends Context.Tag('Lsp/ServerManager')<
|
|
54
|
+
ServerManager,
|
|
55
|
+
ServerManagerService
|
|
56
|
+
>() {}
|
|
57
|
+
|
|
58
|
+
export interface ToolResult {
|
|
59
|
+
content: { type: 'text'; text: string }[];
|
|
60
|
+
details: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LspToolParams {
|
|
64
|
+
operation: LspOperation;
|
|
65
|
+
filePath?: string;
|
|
66
|
+
line?: number;
|
|
67
|
+
character?: number;
|
|
68
|
+
query?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Capability map ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const CAPABILITY_MAP: Record<LspOperation, string> = {
|
|
74
|
+
diagnostics: 'textDocumentSync',
|
|
75
|
+
hover: 'hoverProvider',
|
|
76
|
+
goToDefinition: 'definitionProvider',
|
|
77
|
+
findReferences: 'referencesProvider',
|
|
78
|
+
goToImplementation: 'implementationProvider',
|
|
79
|
+
documentSymbol: 'documentSymbolProvider',
|
|
80
|
+
workspaceSymbol: 'workspaceSymbolProvider',
|
|
81
|
+
prepareCallHierarchy: 'callHierarchyProvider',
|
|
82
|
+
incomingCalls: 'callHierarchyProvider',
|
|
83
|
+
outgoingCalls: 'callHierarchyProvider',
|
|
84
|
+
codeActions: 'codeActionProvider',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function cleanPath(path: string): string {
|
|
90
|
+
return path.replace(/^@/, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toZeroIndexed(oneIndexed: number): number {
|
|
94
|
+
return Math.max(0, oneIndexed - 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ok(text: string): ToolResult {
|
|
98
|
+
return { content: [{ type: 'text', text }], details: {} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Server name for error context — tolerates partially-shaped clients. */
|
|
102
|
+
function serverName(client: LspClient): string {
|
|
103
|
+
return client.config?.name ?? 'lsp';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Wrap an async LSP client call as a typed Effect. */
|
|
107
|
+
function call<A>(
|
|
108
|
+
operation: string,
|
|
109
|
+
server: string,
|
|
110
|
+
thunk: () => Promise<A>,
|
|
111
|
+
): Effect.Effect<A, LspOperationError> {
|
|
112
|
+
return Effect.tryPromise({
|
|
113
|
+
try: thunk,
|
|
114
|
+
catch: (cause) => new LspOperationError({ operation, server, cause }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validate(params: LspToolParams): Effect.Effect<void, LspValidationError> {
|
|
119
|
+
const { operation, filePath, line, character, query } = params;
|
|
120
|
+
const fail = (reason: string) => Effect.fail(new LspValidationError({ reason }));
|
|
121
|
+
|
|
122
|
+
if (POSITION_OPERATIONS.includes(operation)) {
|
|
123
|
+
if (!filePath) return fail(`Operation '${operation}' requires filePath`);
|
|
124
|
+
if (line === undefined) return fail(`Operation '${operation}' requires line`);
|
|
125
|
+
if (character === undefined) return fail(`Operation '${operation}' requires character`);
|
|
126
|
+
}
|
|
127
|
+
if (FILE_ONLY_OPERATIONS.includes(operation)) {
|
|
128
|
+
if (!filePath) return fail(`Operation '${operation}' requires filePath`);
|
|
129
|
+
}
|
|
130
|
+
if (QUERY_OPERATIONS.includes(operation)) {
|
|
131
|
+
if (!query) return fail(`Operation '${operation}' requires query`);
|
|
132
|
+
}
|
|
133
|
+
return Effect.void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Program ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export function lspToolProgram(
|
|
139
|
+
raw: LspToolParams,
|
|
140
|
+
): Effect.Effect<ToolResult, LspExtensionError, ServerManager> {
|
|
141
|
+
return Effect.gen(function* () {
|
|
142
|
+
yield* validate(raw);
|
|
143
|
+
|
|
144
|
+
const mgr = yield* ServerManager;
|
|
145
|
+
const operation = raw.operation;
|
|
146
|
+
const filePath = raw.filePath ? cleanPath(raw.filePath) : undefined;
|
|
147
|
+
const rootPath = mgr.getRootPath();
|
|
148
|
+
|
|
149
|
+
if (operation === 'diagnostics') {
|
|
150
|
+
return yield* diagnosticsProgram(mgr, filePath!);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (operation === 'workspaceSymbol') {
|
|
154
|
+
return yield* workspaceSymbolProgram(mgr, raw.query!, rootPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const capability = CAPABILITY_MAP[operation];
|
|
158
|
+
const client = mgr.clientForFileWithCapability(filePath!, capability);
|
|
159
|
+
if (!client) {
|
|
160
|
+
return yield* new NoCapableServerError({ operation, filePath: filePath! });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const server = serverName(client);
|
|
164
|
+
const line = raw.line!;
|
|
165
|
+
const character = raw.character!;
|
|
166
|
+
const pos = { line: toZeroIndexed(line), character: toZeroIndexed(character) };
|
|
167
|
+
|
|
168
|
+
switch (operation) {
|
|
169
|
+
case 'hover': {
|
|
170
|
+
const result = yield* call(operation, server, () => client.hover(filePath!, pos));
|
|
171
|
+
return ok(formatHover(result, filePath!, pos.line, pos.character));
|
|
172
|
+
}
|
|
173
|
+
case 'goToDefinition': {
|
|
174
|
+
const locs = yield* call(operation, server, () => client.definition(filePath!, pos));
|
|
175
|
+
return ok(
|
|
176
|
+
formatLocations(locs, 'Definition', filePath!, pos.line, pos.character, rootPath),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
case 'findReferences': {
|
|
180
|
+
const locs = yield* call(operation, server, () => client.references(filePath!, pos));
|
|
181
|
+
return ok(
|
|
182
|
+
formatLocations(locs, 'References', filePath!, pos.line, pos.character, rootPath),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
case 'goToImplementation': {
|
|
186
|
+
const locs = yield* call(operation, server, () => client.implementation(filePath!, pos));
|
|
187
|
+
return ok(
|
|
188
|
+
formatLocations(locs, 'Implementation', filePath!, pos.line, pos.character, rootPath),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
case 'documentSymbol': {
|
|
192
|
+
const symbols = yield* call(operation, server, () => client.documentSymbol(filePath!));
|
|
193
|
+
return ok(formatDocumentSymbols(symbols, filePath!, rootPath));
|
|
194
|
+
}
|
|
195
|
+
case 'prepareCallHierarchy': {
|
|
196
|
+
const items = yield* call(operation, server, () =>
|
|
197
|
+
client.prepareCallHierarchy(filePath!, pos),
|
|
198
|
+
);
|
|
199
|
+
return ok(formatCallHierarchy(items, filePath!, pos.line, pos.character, rootPath));
|
|
200
|
+
}
|
|
201
|
+
case 'incomingCalls': {
|
|
202
|
+
const items = yield* call(operation, server, () =>
|
|
203
|
+
client.prepareCallHierarchy(filePath!, pos),
|
|
204
|
+
);
|
|
205
|
+
if (items.length === 0) {
|
|
206
|
+
return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
|
|
207
|
+
}
|
|
208
|
+
const calls = yield* call(operation, server, () => client.incomingCalls(items[0]));
|
|
209
|
+
return ok(formatIncomingCalls(calls, items[0], rootPath));
|
|
210
|
+
}
|
|
211
|
+
case 'outgoingCalls': {
|
|
212
|
+
const items = yield* call(operation, server, () =>
|
|
213
|
+
client.prepareCallHierarchy(filePath!, pos),
|
|
214
|
+
);
|
|
215
|
+
if (items.length === 0) {
|
|
216
|
+
return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
|
|
217
|
+
}
|
|
218
|
+
const calls = yield* call(operation, server, () => client.outgoingCalls(items[0]));
|
|
219
|
+
return ok(formatOutgoingCalls(calls, items[0], rootPath));
|
|
220
|
+
}
|
|
221
|
+
case 'codeActions': {
|
|
222
|
+
const diagsForFile = yield* call(operation, server, () => client.getDiagnostics(filePath!));
|
|
223
|
+
const zeroLine = toZeroIndexed(line);
|
|
224
|
+
const lineDiags = diagsForFile.filter(
|
|
225
|
+
(d) => d.range.start.line <= zeroLine && d.range.end.line >= zeroLine,
|
|
226
|
+
);
|
|
227
|
+
const range = {
|
|
228
|
+
start: { line: zeroLine, character: 0 },
|
|
229
|
+
end: { line: zeroLine, character: Number.MAX_SAFE_INTEGER },
|
|
230
|
+
};
|
|
231
|
+
const actions = yield* call(operation, server, () =>
|
|
232
|
+
client.codeActions(filePath!, range, { diagnostics: lineDiags }),
|
|
233
|
+
);
|
|
234
|
+
return ok(formatCodeActions(actions, filePath!, zeroLine));
|
|
235
|
+
}
|
|
236
|
+
default:
|
|
237
|
+
return yield* new LspValidationError({ reason: `Unknown operation: ${operation}` });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function diagnosticsProgram(
|
|
243
|
+
mgr: ServerManagerService,
|
|
244
|
+
filePath: string,
|
|
245
|
+
): Effect.Effect<ToolResult, never> {
|
|
246
|
+
return Effect.gen(function* () {
|
|
247
|
+
const groups: { source: string; diagnostics: Diagnostic[] }[] = [];
|
|
248
|
+
const errors: string[] = [];
|
|
249
|
+
|
|
250
|
+
for (const client of mgr.clientsForFile(filePath)) {
|
|
251
|
+
const name = serverName(client);
|
|
252
|
+
const result = yield* call('diagnostics', name, () => client.getDiagnostics(filePath)).pipe(
|
|
253
|
+
Effect.either,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
if (result._tag === 'Right') {
|
|
257
|
+
if (result.right.length > 0) {
|
|
258
|
+
groups.push({ source: name, diagnostics: result.right });
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
errors.push(`${name}: ${result.left.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const text = formatDiagnostics(filePath, groups);
|
|
266
|
+
const errorNote = errors.length > 0 ? `\n\nNote: ${errors.join('; ')}` : '';
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: 'text' as const, text: text + errorNote }],
|
|
270
|
+
details: {
|
|
271
|
+
groups: groups.map((g) => ({ source: g.source, count: g.diagnostics.length })),
|
|
272
|
+
errors,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function workspaceSymbolProgram(
|
|
279
|
+
mgr: ServerManagerService,
|
|
280
|
+
query: string,
|
|
281
|
+
rootPath: string,
|
|
282
|
+
): Effect.Effect<ToolResult, LspExtensionError> {
|
|
283
|
+
return Effect.gen(function* () {
|
|
284
|
+
const client = mgr.anyClient();
|
|
285
|
+
if (!client) {
|
|
286
|
+
return yield* new NoServerAvailableError({ operation: 'workspace symbol search' });
|
|
287
|
+
}
|
|
288
|
+
const symbols = yield* call('workspaceSymbol', serverName(client), () =>
|
|
289
|
+
client.workspaceSymbol(query),
|
|
290
|
+
);
|
|
291
|
+
return ok(formatWorkspaceSymbols(symbols, query, rootPath));
|
|
292
|
+
});
|
|
293
|
+
}
|
package/extensions/lsp/tools.ts
CHANGED
|
@@ -1,97 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single unified `lsp` tool registration.
|
|
3
3
|
*
|
|
4
|
-
* 11 operations routed to the right server by file extension.
|
|
4
|
+
* 11 operations routed to the right server by file extension. The execution
|
|
5
|
+
* logic lives in `tools/programs.ts` as Effect programs; this module owns the
|
|
6
|
+
* tool schema/description and runs the program with the live `ServerManager`.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import { Effect } from 'effect';
|
|
8
11
|
import { Type } from 'typebox';
|
|
9
12
|
import { StringEnum } from '@earendil-works/pi-ai';
|
|
10
13
|
|
|
11
|
-
import
|
|
14
|
+
import { toNativeError } from './errors';
|
|
12
15
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
formatLocations,
|
|
20
|
-
formatOutgoingCalls,
|
|
21
|
-
formatWorkspaceSymbols,
|
|
22
|
-
} from './formatting';
|
|
23
|
-
import type { Diagnostic } from './types';
|
|
24
|
-
import {
|
|
25
|
-
FILE_ONLY_OPERATIONS,
|
|
26
|
-
LSP_OPERATIONS,
|
|
27
|
-
type LspOperation,
|
|
28
|
-
POSITION_OPERATIONS,
|
|
29
|
-
QUERY_OPERATIONS,
|
|
30
|
-
} from './types';
|
|
31
|
-
|
|
32
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
function cleanPath(path: string): string {
|
|
35
|
-
return path.replace(/^@/, '');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function toZeroIndexed(oneIndexed: number): number {
|
|
39
|
-
return Math.max(0, oneIndexed - 1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function validateParams(
|
|
43
|
-
operation: LspOperation,
|
|
44
|
-
filePath?: string,
|
|
45
|
-
line?: number,
|
|
46
|
-
character?: number,
|
|
47
|
-
query?: string,
|
|
48
|
-
): string | null {
|
|
49
|
-
if (POSITION_OPERATIONS.includes(operation)) {
|
|
50
|
-
if (!filePath) return `Operation '${operation}' requires filePath`;
|
|
51
|
-
if (line === undefined) return `Operation '${operation}' requires line`;
|
|
52
|
-
if (character === undefined) return `Operation '${operation}' requires character`;
|
|
53
|
-
}
|
|
54
|
-
if (FILE_ONLY_OPERATIONS.includes(operation)) {
|
|
55
|
-
if (!filePath) return `Operation '${operation}' requires filePath`;
|
|
56
|
-
}
|
|
57
|
-
if (QUERY_OPERATIONS.includes(operation)) {
|
|
58
|
-
if (!query) return `Operation '${operation}' requires query`;
|
|
59
|
-
}
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── Types ───────────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
export interface ServerManager {
|
|
66
|
-
/** Get all LSP clients that handle a given file extension. */
|
|
67
|
-
clientsForFile: (filePath: string) => LspClient[];
|
|
68
|
-
/** Get the first LSP client that handles a file and has a capability. */
|
|
69
|
-
clientForFileWithCapability: (filePath: string, capability: string) => LspClient | null;
|
|
70
|
-
/** Get any initialized client (for workspace-wide ops). */
|
|
71
|
-
anyClient: () => LspClient | null;
|
|
72
|
-
/** Current root path. */
|
|
73
|
-
getRootPath: () => string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Capability map ──────────────────────────────────────────────────────────
|
|
16
|
+
lspToolProgram,
|
|
17
|
+
ServerManager,
|
|
18
|
+
type LspToolParams,
|
|
19
|
+
type ServerManagerService,
|
|
20
|
+
} from './tools/programs';
|
|
21
|
+
import { LSP_OPERATIONS, type LspOperation } from './types';
|
|
77
22
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
hover: 'hoverProvider',
|
|
81
|
-
goToDefinition: 'definitionProvider',
|
|
82
|
-
findReferences: 'referencesProvider',
|
|
83
|
-
goToImplementation: 'implementationProvider',
|
|
84
|
-
documentSymbol: 'documentSymbolProvider',
|
|
85
|
-
workspaceSymbol: 'workspaceSymbolProvider',
|
|
86
|
-
prepareCallHierarchy: 'callHierarchyProvider',
|
|
87
|
-
incomingCalls: 'callHierarchyProvider',
|
|
88
|
-
outgoingCalls: 'callHierarchyProvider',
|
|
89
|
-
codeActions: 'codeActionProvider',
|
|
90
|
-
};
|
|
23
|
+
export type { ServerManagerService } from './tools/programs';
|
|
24
|
+
export { ServerManager } from './tools/programs';
|
|
91
25
|
|
|
92
26
|
// ── Registration ────────────────────────────────────────────────────────────
|
|
93
27
|
|
|
94
|
-
export function registerLspTool(pi: ExtensionAPI, mgr:
|
|
28
|
+
export function registerLspTool(pi: ExtensionAPI, mgr: ServerManagerService) {
|
|
95
29
|
pi.registerTool({
|
|
96
30
|
name: 'lsp',
|
|
97
31
|
label: 'LSP',
|
|
@@ -103,12 +37,12 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
|
|
|
103
37
|
' findReferences — find all references to a symbol',
|
|
104
38
|
' hover — get type info and documentation for a symbol',
|
|
105
39
|
' diagnostics — get type errors and lint warnings for a file',
|
|
106
|
-
' documentSymbol — get all symbols in a file',
|
|
40
|
+
' documentSymbol — get all symbols in a file (with line:column positions)',
|
|
107
41
|
' workspaceSymbol — search for symbols across the workspace',
|
|
108
42
|
' goToImplementation — find implementations of an interface/abstract method',
|
|
109
43
|
' prepareCallHierarchy — get call hierarchy item at a position',
|
|
110
|
-
' incomingCalls — find callers of a function/method',
|
|
111
|
-
' outgoingCalls — find callees of a function/method',
|
|
44
|
+
' incomingCalls — find callers of a function/method (auto-prepares hierarchy)',
|
|
45
|
+
' outgoingCalls — find callees of a function/method (auto-prepares hierarchy)',
|
|
112
46
|
' codeActions — get quick fixes and refactoring suggestions',
|
|
113
47
|
'',
|
|
114
48
|
'Parameters:',
|
|
@@ -117,14 +51,24 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
|
|
|
117
51
|
' line — line number, 1-indexed (required for position-based operations)',
|
|
118
52
|
' character — column number, 1-indexed (required for position-based operations)',
|
|
119
53
|
' query — search string (required for workspaceSymbol)',
|
|
54
|
+
'',
|
|
55
|
+
'Tips:',
|
|
56
|
+
' — Position the character in the middle of the symbol name for best results.',
|
|
57
|
+
' — Use hover before goToDefinition to quickly check signatures and docs.',
|
|
58
|
+
' — workspaceSymbol may need a retry if the server is still indexing.',
|
|
120
59
|
].join('\n'),
|
|
121
60
|
promptSnippet:
|
|
122
61
|
'Interact with LSP servers for code intelligence: definitions, references, hover, diagnostics, symbols, call hierarchy, code actions',
|
|
123
62
|
promptGuidelines: [
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
63
|
+
'lsp line and character params are 1-indexed — use the values from the read tool or rg output directly.',
|
|
64
|
+
'lsp `hover` is the fastest way to get a function signature, type params, and doc comment — prefer it over `goToDefinition` for quick type inspection.',
|
|
65
|
+
'lsp `documentSymbol` returns line:column positions for each symbol — use those values directly for follow-up lsp operations.',
|
|
66
|
+
'For lsp position-based operations, place the character in the **middle** of the symbol name, not at the first character.',
|
|
67
|
+
'lsp `incomingCalls` and `outgoingCalls` automatically prepare the call hierarchy — no need to call `prepareCallHierarchy` first.',
|
|
68
|
+
'lsp `workspaceSymbol` may return empty results while the LSP server is still indexing. If it returns nothing, wait a few seconds and retry.',
|
|
69
|
+
'lsp `diagnostics` relies on server-pushed notifications which may be slow for some servers. For compiled languages (Rust, Go, C++), prefer running the compiler directly (e.g. `cargo check`, `go build`) for reliable error checking.',
|
|
70
|
+
'Use lsp for type info, macro-generated symbols, and cross-module navigation. Use rg for simple text search and file discovery — it is faster and needs no server.',
|
|
71
|
+
'lsp servers are auto-detected by file extension. Use /lsp to check status.',
|
|
128
72
|
],
|
|
129
73
|
parameters: Type.Object({
|
|
130
74
|
operation: StringEnum(LSP_OPERATIONS),
|
|
@@ -134,158 +78,12 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
|
|
|
134
78
|
query: Type.Optional(Type.String({ description: 'Search query (for workspaceSymbol)' })),
|
|
135
79
|
}),
|
|
136
80
|
async execute(_toolCallId, params) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const query = params.query;
|
|
142
|
-
const rootPath = mgr.getRootPath();
|
|
143
|
-
|
|
144
|
-
// Validate required params
|
|
145
|
-
const validationError = validateParams(operation, filePath, line, character, query);
|
|
146
|
-
if (validationError) throw new Error(validationError);
|
|
147
|
-
|
|
148
|
-
// ── diagnostics (aggregate from all matching servers) ──
|
|
149
|
-
if (operation === 'diagnostics') {
|
|
150
|
-
return executeDiagnostics(mgr, filePath!, rootPath);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── workspaceSymbol (doesn't need a file-based server lookup) ──
|
|
154
|
-
if (operation === 'workspaceSymbol') {
|
|
155
|
-
return executeWorkspaceSymbol(mgr, query!, rootPath);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── all other operations: route to first capable server ──
|
|
159
|
-
const capability = CAPABILITY_MAP[operation];
|
|
160
|
-
const client = mgr.clientForFileWithCapability(filePath!, capability);
|
|
161
|
-
if (!client) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`No LSP server with '${operation}' capability found for ${filePath}. Check /lsp status.`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const pos = { line: toZeroIndexed(line!), character: toZeroIndexed(character!) };
|
|
168
|
-
|
|
169
|
-
switch (operation) {
|
|
170
|
-
case 'hover': {
|
|
171
|
-
const result = await client.hover(filePath!, pos);
|
|
172
|
-
return ok(formatHover(result, filePath!, pos.line, pos.character));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
case 'goToDefinition': {
|
|
176
|
-
const locs = await client.definition(filePath!, pos);
|
|
177
|
-
return ok(
|
|
178
|
-
formatLocations(locs, 'Definition', filePath!, pos.line, pos.character, rootPath),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
case 'findReferences': {
|
|
183
|
-
const locs = await client.references(filePath!, pos);
|
|
184
|
-
return ok(
|
|
185
|
-
formatLocations(locs, 'References', filePath!, pos.line, pos.character, rootPath),
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
case 'goToImplementation': {
|
|
190
|
-
const locs = await client.implementation(filePath!, pos);
|
|
191
|
-
return ok(
|
|
192
|
-
formatLocations(locs, 'Implementation', filePath!, pos.line, pos.character, rootPath),
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
case 'documentSymbol': {
|
|
197
|
-
const symbols = await client.documentSymbol(filePath!);
|
|
198
|
-
return ok(formatDocumentSymbols(symbols, filePath!, rootPath));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
case 'prepareCallHierarchy': {
|
|
202
|
-
const items = await client.prepareCallHierarchy(filePath!, pos);
|
|
203
|
-
return ok(formatCallHierarchy(items, filePath!, pos.line, pos.character, rootPath));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
case 'incomingCalls': {
|
|
207
|
-
const items = await client.prepareCallHierarchy(filePath!, pos);
|
|
208
|
-
if (items.length === 0) {
|
|
209
|
-
return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
|
|
210
|
-
}
|
|
211
|
-
const calls = await client.incomingCalls(items[0]);
|
|
212
|
-
return ok(formatIncomingCalls(calls, items[0], rootPath));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
case 'outgoingCalls': {
|
|
216
|
-
const items = await client.prepareCallHierarchy(filePath!, pos);
|
|
217
|
-
if (items.length === 0) {
|
|
218
|
-
return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
|
|
219
|
-
}
|
|
220
|
-
const calls = await client.outgoingCalls(items[0]);
|
|
221
|
-
return ok(formatOutgoingCalls(calls, items[0], rootPath));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
case 'codeActions': {
|
|
225
|
-
const diagsForFile = await client.getDiagnostics(filePath!);
|
|
226
|
-
const zeroLine = toZeroIndexed(line!);
|
|
227
|
-
const lineDiags = diagsForFile.filter(
|
|
228
|
-
(d) => d.range.start.line <= zeroLine && d.range.end.line >= zeroLine,
|
|
229
|
-
);
|
|
230
|
-
const range = {
|
|
231
|
-
start: { line: zeroLine, character: 0 },
|
|
232
|
-
end: { line: zeroLine, character: Number.MAX_SAFE_INTEGER },
|
|
233
|
-
};
|
|
234
|
-
const actions = await client.codeActions(filePath!, range, { diagnostics: lineDiags });
|
|
235
|
-
return ok(formatCodeActions(actions, filePath!, zeroLine));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
default:
|
|
239
|
-
throw new Error(`Unknown operation: ${operation}`);
|
|
240
|
-
}
|
|
81
|
+
const program = lspToolProgram(params as LspToolParams);
|
|
82
|
+
return Effect.runPromise(
|
|
83
|
+
program.pipe(Effect.provideService(ServerManager, mgr), Effect.mapError(toNativeError)),
|
|
84
|
+
);
|
|
241
85
|
},
|
|
242
86
|
});
|
|
243
87
|
}
|
|
244
88
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
async function executeDiagnostics(mgr: ServerManager, filePath: string, _rootPath: string) {
|
|
248
|
-
const groups: { source: string; diagnostics: Diagnostic[] }[] = [];
|
|
249
|
-
const errors: string[] = [];
|
|
250
|
-
|
|
251
|
-
// Gather from all LSP servers that handle this file
|
|
252
|
-
const clients = mgr.clientsForFile(filePath);
|
|
253
|
-
for (const client of clients) {
|
|
254
|
-
try {
|
|
255
|
-
const diags = await client.getDiagnostics(filePath);
|
|
256
|
-
if (diags.length > 0) {
|
|
257
|
-
groups.push({ source: client.config.name, diagnostics: diags });
|
|
258
|
-
}
|
|
259
|
-
} catch (err) {
|
|
260
|
-
errors.push(`${client.config.name}: ${(err as Error).message}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const text = formatDiagnostics(filePath, groups);
|
|
265
|
-
const errorNote = errors.length > 0 ? `\n\nNote: ${errors.join('; ')}` : '';
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
content: [{ type: 'text' as const, text: text + errorNote }],
|
|
269
|
-
details: {
|
|
270
|
-
groups: groups.map((g) => ({ source: g.source, count: g.diagnostics.length })),
|
|
271
|
-
errors,
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function executeWorkspaceSymbol(mgr: ServerManager, query: string, rootPath: string) {
|
|
277
|
-
const client = mgr.anyClient();
|
|
278
|
-
if (!client) throw new Error('No LSP server available for workspace symbol search.');
|
|
279
|
-
|
|
280
|
-
const symbols = await client.workspaceSymbol(query);
|
|
281
|
-
return ok(formatWorkspaceSymbols(symbols, query, rootPath));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
function ok(text: string) {
|
|
287
|
-
return {
|
|
288
|
-
content: [{ type: 'text' as const, text }],
|
|
289
|
-
details: {},
|
|
290
|
-
};
|
|
291
|
-
}
|
|
89
|
+
export type { LspOperation };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-lsp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Language-agnostic LSP code intelligence for pi — diagnostics, hover, definitions, references, symbols, and call hierarchy",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"./extensions/lsp"
|
|
26
26
|
]
|
|
27
27
|
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"effect": "^3.21.2"
|
|
30
|
+
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@earendil-works/pi-ai": "^0.74.0",
|
|
30
33
|
"@types/node": "24",
|