@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
package/test/config.test.ts
CHANGED
|
@@ -56,6 +56,40 @@ describe('config loader', () => {
|
|
|
56
56
|
expect(again).toBe(false);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('scaffolded starter config does not enable typescript (or any) server by default', async () => {
|
|
60
|
+
const home = await makeTempDir('pi-lsp-home-');
|
|
61
|
+
const cwd = await makeTempDir('pi-lsp-cwd-');
|
|
62
|
+
process.env.HOME = home;
|
|
63
|
+
|
|
64
|
+
await scaffoldGlobalConfig(cwd);
|
|
65
|
+
|
|
66
|
+
const globalConfigPath = join(home, '.pi', 'agent', 'extensions', 'lsp', 'config.json');
|
|
67
|
+
const parsed = JSON.parse(await readFile(globalConfigPath, 'utf8'));
|
|
68
|
+
// Every example server is opt-in (disabled), so typescript is not a default.
|
|
69
|
+
for (const server of Object.values(parsed.lsp as Record<string, { disabled?: boolean }>)) {
|
|
70
|
+
expect(server.disabled).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const config = await loadConfig(cwd);
|
|
74
|
+
expect(config.globalDisabled).toBe(false);
|
|
75
|
+
expect(config.servers).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('disabled servers are excluded even when their command exists', async () => {
|
|
79
|
+
const home = await makeTempDir('pi-lsp-home-');
|
|
80
|
+
const cwd = await makeTempDir('pi-lsp-cwd-');
|
|
81
|
+
process.env.HOME = home;
|
|
82
|
+
|
|
83
|
+
await writeJson(join(cwd, '.pi', 'lsp.json'), {
|
|
84
|
+
lsp: {
|
|
85
|
+
node: { command: ['node'], extensions: ['.js'], disabled: true },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const config = await loadConfig(cwd);
|
|
90
|
+
expect(config.servers).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
59
93
|
test('does not scaffold when project config exists', async () => {
|
|
60
94
|
const home = await makeTempDir('pi-lsp-home-');
|
|
61
95
|
const cwd = await makeTempDir('pi-lsp-cwd-');
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Effect, Layer } from 'effect';
|
|
3
|
+
|
|
4
|
+
import { loadConfigEffect, scaffoldGlobalConfigEffect } from '../extensions/lsp/config';
|
|
5
|
+
import { CommandResolver, type CommandAvailability } from '../extensions/lsp/effects/command';
|
|
6
|
+
import { FileSystem, type FileSystemService } from '../extensions/lsp/effects/filesystem';
|
|
7
|
+
import { ConfigReadError } from '../extensions/lsp/errors';
|
|
8
|
+
|
|
9
|
+
/** Build an in-memory FileSystem service over a path→content map. */
|
|
10
|
+
function fakeFs(files: Record<string, string>, writes?: Record<string, string>): FileSystemService {
|
|
11
|
+
return {
|
|
12
|
+
readTextFile: (path) =>
|
|
13
|
+
path in files
|
|
14
|
+
? Effect.succeed(files[path])
|
|
15
|
+
: Effect.fail(new ConfigReadError({ path, cause: new Error('ENOENT') })),
|
|
16
|
+
fileExists: (path) => Effect.succeed(path in files),
|
|
17
|
+
writeTextFile: (path, content) =>
|
|
18
|
+
Effect.sync(() => {
|
|
19
|
+
if (writes) writes[path] = content;
|
|
20
|
+
files[path] = content;
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fakeResolver(availability: CommandAvailability) {
|
|
26
|
+
return Layer.succeed(CommandResolver, { resolve: () => Effect.succeed(availability) });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('effect services', () => {
|
|
30
|
+
test('loadConfigEffect resolves servers via injected services (no disk, no shell)', async () => {
|
|
31
|
+
const home = process.env.HOME ?? '';
|
|
32
|
+
const globalPath = `${home}/.pi/agent/extensions/lsp/config.json`;
|
|
33
|
+
const fs = fakeFs({
|
|
34
|
+
[globalPath]: JSON.stringify({
|
|
35
|
+
lsp: { ts: { command: ['ts-ls', '--stdio'], extensions: ['.ts'] } },
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const layer = Layer.merge(Layer.succeed(FileSystem, fs), fakeResolver('global'));
|
|
40
|
+
const config = await Effect.runPromise(
|
|
41
|
+
loadConfigEffect('/workspace').pipe(Effect.provide(layer)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(config.globalDisabled).toBe(false);
|
|
45
|
+
expect(config.servers).toEqual([
|
|
46
|
+
{
|
|
47
|
+
name: 'ts',
|
|
48
|
+
command: 'ts-ls',
|
|
49
|
+
args: ['--stdio'],
|
|
50
|
+
extensions: ['.ts'],
|
|
51
|
+
env: {},
|
|
52
|
+
initializationOptions: {},
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('loadConfigEffect rewrites command to npx when only available via npx', async () => {
|
|
58
|
+
const home = process.env.HOME ?? '';
|
|
59
|
+
const globalPath = `${home}/.pi/agent/extensions/lsp/config.json`;
|
|
60
|
+
const fs = fakeFs({
|
|
61
|
+
[globalPath]: JSON.stringify({
|
|
62
|
+
lsp: { ts: { command: ['ts-ls', '--stdio'], extensions: ['.ts'] } },
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const layer = Layer.merge(Layer.succeed(FileSystem, fs), fakeResolver('npx'));
|
|
67
|
+
const config = await Effect.runPromise(
|
|
68
|
+
loadConfigEffect('/workspace').pipe(Effect.provide(layer)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(config.servers[0]?.command).toBe('npx');
|
|
72
|
+
expect(config.servers[0]?.args).toEqual(['--yes', 'ts-ls', '--stdio']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('loadConfigEffect drops servers whose command cannot be resolved', async () => {
|
|
76
|
+
const home = process.env.HOME ?? '';
|
|
77
|
+
const globalPath = `${home}/.pi/agent/extensions/lsp/config.json`;
|
|
78
|
+
const fs = fakeFs({
|
|
79
|
+
[globalPath]: JSON.stringify({
|
|
80
|
+
lsp: { ts: { command: ['missing-ls'], extensions: ['.ts'] } },
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const layer = Layer.merge(Layer.succeed(FileSystem, fs), fakeResolver(null));
|
|
85
|
+
const config = await Effect.runPromise(
|
|
86
|
+
loadConfigEffect('/workspace').pipe(Effect.provide(layer)),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(config.servers).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('scaffoldGlobalConfigEffect writes the starter template when nothing exists', async () => {
|
|
93
|
+
const writes: Record<string, string> = {};
|
|
94
|
+
const fs = fakeFs({}, writes);
|
|
95
|
+
|
|
96
|
+
const created = await Effect.runPromise(
|
|
97
|
+
scaffoldGlobalConfigEffect('/workspace').pipe(Effect.provideService(FileSystem, fs)),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(created).toBe(true);
|
|
101
|
+
const written = Object.values(writes)[0] ?? '';
|
|
102
|
+
const parsed = JSON.parse(written);
|
|
103
|
+
for (const server of Object.values(parsed.lsp as Record<string, { disabled?: boolean }>)) {
|
|
104
|
+
expect(server.disabled).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
package/test/formatting.test.ts
CHANGED
|
@@ -96,8 +96,8 @@ describe('formatting', () => {
|
|
|
96
96
|
'/repo',
|
|
97
97
|
);
|
|
98
98
|
expect(docSymbols).toContain('Symbols in src/foo.ts');
|
|
99
|
-
expect(docSymbols).toContain('Foo (class)');
|
|
100
|
-
expect(docSymbols).toContain('bar (method)');
|
|
99
|
+
expect(docSymbols).toContain('Foo (class) line 1:7');
|
|
100
|
+
expect(docSymbols).toContain('bar (method) line 3:3');
|
|
101
101
|
|
|
102
102
|
const wsSymbols = formatWorkspaceSymbols(
|
|
103
103
|
[
|
|
@@ -115,7 +115,7 @@ describe('formatting', () => {
|
|
|
115
115
|
'/repo',
|
|
116
116
|
);
|
|
117
117
|
expect(wsSymbols).toContain('Workspace symbols matching "Foo"');
|
|
118
|
-
expect(wsSymbols).toContain('FooService (class) src/service.ts:12 in services');
|
|
118
|
+
expect(wsSymbols).toContain('FooService (class) src/service.ts:12:1 in services');
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
test('formats call hierarchy and code actions', () => {
|
package/test/index.test.ts
CHANGED
|
@@ -107,8 +107,9 @@ describe('extension/session layer', () => {
|
|
|
107
107
|
|
|
108
108
|
const globalConfigPath = join(home, '.pi', 'agent', 'extensions', 'lsp', 'config.json');
|
|
109
109
|
expect(await fileExists(globalConfigPath)).toBe(true);
|
|
110
|
-
const
|
|
111
|
-
|
|
110
|
+
const parsed = JSON.parse(await readFile(globalConfigPath, 'utf8'));
|
|
111
|
+
// typescript is present only as a disabled example — never auto-enabled.
|
|
112
|
+
expect(parsed.lsp.typescript.disabled).toBe(true);
|
|
112
113
|
expect(ui.notifications.some((n) => n.includes('created starter config'))).toBe(true);
|
|
113
114
|
});
|
|
114
115
|
|
package/test/mock-lsp-server.ts
CHANGED
|
@@ -243,20 +243,27 @@ function handleRequest(req: JsonRpcRequest) {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const text =
|
|
251
|
-
msg.method === 'textDocument/didOpen'
|
|
252
|
-
? (msg.params?.textDocument?.text ?? '')
|
|
253
|
-
: (msg.params?.contentChanges?.[0]?.text ?? '');
|
|
254
|
-
lastUri = textDocument?.uri ?? lastUri;
|
|
255
|
-
sendDiagnostics(lastUri, text);
|
|
256
|
-
}
|
|
246
|
+
function handleDocSync(uri: string, text: string) {
|
|
247
|
+
lastUri = uri;
|
|
248
|
+
sendDiagnostics(lastUri, text);
|
|
249
|
+
}
|
|
257
250
|
|
|
258
|
-
|
|
259
|
-
|
|
251
|
+
function handleNotification(msg: JsonRpcNotification) {
|
|
252
|
+
switch (msg.method) {
|
|
253
|
+
case 'textDocument/didOpen':
|
|
254
|
+
handleDocSync(msg.params?.textDocument?.uri ?? lastUri, msg.params?.textDocument?.text ?? '');
|
|
255
|
+
break;
|
|
256
|
+
case 'textDocument/didChange':
|
|
257
|
+
handleDocSync(
|
|
258
|
+
msg.params?.textDocument?.uri ?? lastUri,
|
|
259
|
+
msg.params?.contentChanges?.[0]?.text ?? '',
|
|
260
|
+
);
|
|
261
|
+
break;
|
|
262
|
+
case 'textDocument/didSave':
|
|
263
|
+
handleDocSync(msg.params?.textDocument?.uri ?? lastUri, msg.params?.text ?? '');
|
|
264
|
+
break;
|
|
265
|
+
case 'exit':
|
|
266
|
+
process.exit(0);
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { withRetry } from '../extensions/lsp/retry';
|
|
4
|
+
|
|
5
|
+
describe('withRetry', () => {
|
|
6
|
+
test('returns immediately when result is not empty', async () => {
|
|
7
|
+
let callCount = 0;
|
|
8
|
+
const result = await withRetry(
|
|
9
|
+
async () => {
|
|
10
|
+
callCount++;
|
|
11
|
+
return [1, 2, 3];
|
|
12
|
+
},
|
|
13
|
+
(r) => r.length === 0,
|
|
14
|
+
{ maxRetries: 2, delayMs: 10 },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(result).toEqual([1, 2, 3]);
|
|
18
|
+
expect(callCount).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('retries until result is not empty', async () => {
|
|
22
|
+
let callCount = 0;
|
|
23
|
+
const result = await withRetry(
|
|
24
|
+
async () => {
|
|
25
|
+
callCount++;
|
|
26
|
+
return callCount >= 3 ? ['found'] : [];
|
|
27
|
+
},
|
|
28
|
+
(r) => r.length === 0,
|
|
29
|
+
{ maxRetries: 3, delayMs: 10 },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual(['found']);
|
|
33
|
+
expect(callCount).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('stops at maxRetries and returns last empty result', async () => {
|
|
37
|
+
let callCount = 0;
|
|
38
|
+
const result = await withRetry(
|
|
39
|
+
async () => {
|
|
40
|
+
callCount++;
|
|
41
|
+
return [];
|
|
42
|
+
},
|
|
43
|
+
(r) => r.length === 0,
|
|
44
|
+
{ maxRetries: 2, delayMs: 10 },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(result).toEqual([]);
|
|
48
|
+
expect(callCount).toBe(3); // 1 initial + 2 retries
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('works with null isEmpty check', async () => {
|
|
52
|
+
let callCount = 0;
|
|
53
|
+
const result = await withRetry(
|
|
54
|
+
async () => {
|
|
55
|
+
callCount++;
|
|
56
|
+
return callCount >= 2 ? { value: 'ok' } : null;
|
|
57
|
+
},
|
|
58
|
+
(r) => r === null,
|
|
59
|
+
{ maxRetries: 3, delayMs: 10 },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(result).toEqual({ value: 'ok' });
|
|
63
|
+
expect(callCount).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('uses default options when not provided', async () => {
|
|
67
|
+
let callCount = 0;
|
|
68
|
+
const result = await withRetry(
|
|
69
|
+
async () => {
|
|
70
|
+
callCount++;
|
|
71
|
+
return 'immediate';
|
|
72
|
+
},
|
|
73
|
+
(r) => r === '',
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(result).toBe('immediate');
|
|
77
|
+
expect(callCount).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/test/tools.test.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { describe, expect, test } from 'bun:test';
|
|
|
3
3
|
import { registerLspTool } from '../extensions/lsp/tools';
|
|
4
4
|
import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
5
5
|
|
|
6
|
+
function emptyManager(overrides?: Record<string, any>) {
|
|
7
|
+
return {
|
|
8
|
+
clientsForFile: () => [],
|
|
9
|
+
clientForFileWithCapability: () => null,
|
|
10
|
+
anyClient: () => null,
|
|
11
|
+
getRootPath: () => '/repo',
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
function captureTool() {
|
|
7
17
|
let tool: ToolDefinition<any, any> | null = null;
|
|
8
18
|
const fakePi = {
|
|
@@ -20,14 +30,22 @@ function captureTool() {
|
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
describe('unified lsp tool dispatch', () => {
|
|
33
|
+
test('all promptGuidelines mention lsp tool by name', () => {
|
|
34
|
+
const { register } = captureTool();
|
|
35
|
+
const tool = register(emptyManager());
|
|
36
|
+
|
|
37
|
+
const guidelines = (tool as any).promptGuidelines as string[] | undefined;
|
|
38
|
+
expect(guidelines).toBeTruthy();
|
|
39
|
+
expect(guidelines!.length).toBeGreaterThan(0);
|
|
40
|
+
|
|
41
|
+
for (const guideline of guidelines!) {
|
|
42
|
+
expect(guideline.toLowerCase()).toContain('lsp');
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
23
46
|
test('validates required params by operation', async () => {
|
|
24
47
|
const { register } = captureTool();
|
|
25
|
-
const tool = register(
|
|
26
|
-
clientsForFile: () => [],
|
|
27
|
-
clientForFileWithCapability: () => null,
|
|
28
|
-
anyClient: () => null,
|
|
29
|
-
getRootPath: () => '/repo',
|
|
30
|
-
});
|
|
48
|
+
const tool = register(emptyManager());
|
|
31
49
|
|
|
32
50
|
await expect(
|
|
33
51
|
tool.execute('1', { operation: 'hover' }, undefined as any, undefined, {} as any),
|
|
@@ -40,35 +58,34 @@ describe('unified lsp tool dispatch', () => {
|
|
|
40
58
|
|
|
41
59
|
test('aggregates diagnostics from all matching clients', async () => {
|
|
42
60
|
const { register } = captureTool();
|
|
43
|
-
const tool = register(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
61
|
+
const tool = register(
|
|
62
|
+
emptyManager({
|
|
63
|
+
clientsForFile: () => [
|
|
64
|
+
{
|
|
65
|
+
config: { name: 'ts' },
|
|
66
|
+
getDiagnostics: async () => [
|
|
67
|
+
{
|
|
68
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
|
|
69
|
+
severity: 1,
|
|
70
|
+
source: 'ts',
|
|
71
|
+
message: 'Type error',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
config: { name: 'eslint' },
|
|
77
|
+
getDiagnostics: async () => [
|
|
78
|
+
{
|
|
79
|
+
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } },
|
|
80
|
+
severity: 2,
|
|
81
|
+
source: 'eslint',
|
|
82
|
+
message: 'Lint warning',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
72
89
|
|
|
73
90
|
const result = await tool.execute(
|
|
74
91
|
'1',
|
|
@@ -91,17 +108,16 @@ describe('unified lsp tool dispatch', () => {
|
|
|
91
108
|
test('routes hover to first capable server and converts positions to zero-indexed', async () => {
|
|
92
109
|
const calls: any[] = [];
|
|
93
110
|
const { register } = captureTool();
|
|
94
|
-
const tool = register(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
const tool = register(
|
|
112
|
+
emptyManager({
|
|
113
|
+
clientForFileWithCapability: () => ({
|
|
114
|
+
hover: async (filePath: string, pos: { line: number; character: number }) => {
|
|
115
|
+
calls.push({ filePath, pos });
|
|
116
|
+
return { contents: { kind: 'markdown', value: 'mock hover' } };
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
101
119
|
}),
|
|
102
|
-
|
|
103
|
-
getRootPath: () => '/repo',
|
|
104
|
-
});
|
|
120
|
+
);
|
|
105
121
|
|
|
106
122
|
const result = await tool.execute(
|
|
107
123
|
'1',
|
|
@@ -119,24 +135,23 @@ describe('unified lsp tool dispatch', () => {
|
|
|
119
135
|
|
|
120
136
|
test('routes workspaceSymbol through anyClient', async () => {
|
|
121
137
|
const { register } = captureTool();
|
|
122
|
-
const tool = register(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
const tool = register(
|
|
139
|
+
emptyManager({
|
|
140
|
+
anyClient: () => ({
|
|
141
|
+
workspaceSymbol: async (query: string) => [
|
|
142
|
+
{
|
|
143
|
+
name: `${query}Service`,
|
|
144
|
+
kind: 5,
|
|
145
|
+
location: {
|
|
146
|
+
uri: 'file:///repo/src/service.ts',
|
|
147
|
+
range: { start: { line: 9, character: 0 }, end: { line: 9, character: 5 } },
|
|
148
|
+
},
|
|
149
|
+
containerName: 'services',
|
|
133
150
|
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
],
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
137
153
|
}),
|
|
138
|
-
|
|
139
|
-
});
|
|
154
|
+
);
|
|
140
155
|
|
|
141
156
|
const result = await tool.execute(
|
|
142
157
|
'1',
|
|
@@ -154,12 +169,7 @@ describe('unified lsp tool dispatch', () => {
|
|
|
154
169
|
|
|
155
170
|
test('errors when no capable server is found', async () => {
|
|
156
171
|
const { register } = captureTool();
|
|
157
|
-
const tool = register(
|
|
158
|
-
clientsForFile: () => [],
|
|
159
|
-
clientForFileWithCapability: () => null,
|
|
160
|
-
anyClient: () => null,
|
|
161
|
-
getRootPath: () => '/repo',
|
|
162
|
-
});
|
|
172
|
+
const tool = register(emptyManager());
|
|
163
173
|
|
|
164
174
|
await expect(
|
|
165
175
|
tool.execute(
|
|
@@ -175,29 +185,28 @@ describe('unified lsp tool dispatch', () => {
|
|
|
175
185
|
test('codeActions filters diagnostics to the requested line', async () => {
|
|
176
186
|
const { register } = captureTool();
|
|
177
187
|
const seenContexts: any[] = [];
|
|
178
|
-
const tool = register(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
const tool = register(
|
|
189
|
+
emptyManager({
|
|
190
|
+
clientForFileWithCapability: () => ({
|
|
191
|
+
getDiagnostics: async () => [
|
|
192
|
+
{
|
|
193
|
+
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
|
|
194
|
+
severity: 1,
|
|
195
|
+
message: 'line 3 issue',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
range: { start: { line: 5, character: 0 }, end: { line: 5, character: 10 } },
|
|
199
|
+
severity: 1,
|
|
200
|
+
message: 'line 6 issue',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
codeActions: async (_filePath: string, range: any, context: any) => {
|
|
204
|
+
seenContexts.push({ range, context });
|
|
205
|
+
return [{ title: 'Fix it', kind: 'quickfix' }];
|
|
191
206
|
},
|
|
192
|
-
|
|
193
|
-
codeActions: async (_filePath: string, range: any, context: any) => {
|
|
194
|
-
seenContexts.push({ range, context });
|
|
195
|
-
return [{ title: 'Fix it', kind: 'quickfix' }];
|
|
196
|
-
},
|
|
207
|
+
}),
|
|
197
208
|
}),
|
|
198
|
-
|
|
199
|
-
getRootPath: () => '/repo',
|
|
200
|
-
});
|
|
209
|
+
);
|
|
201
210
|
|
|
202
211
|
const result = await tool.execute(
|
|
203
212
|
'1',
|
|
@@ -6,6 +6,19 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
|
|
7
7
|
import { LspClient } from '../extensions/lsp/client';
|
|
8
8
|
|
|
9
|
+
/** Resolve a package bin using Node/Bun module resolution (handles hoisting). */
|
|
10
|
+
function resolveBin(pkg: string): string | null {
|
|
11
|
+
try {
|
|
12
|
+
const pkgJsonPath = fileURLToPath(import.meta.resolve(`${pkg}/package.json`));
|
|
13
|
+
const pkgJson = require(pkgJsonPath);
|
|
14
|
+
const bin = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.[pkg];
|
|
15
|
+
if (!bin) return null;
|
|
16
|
+
return resolve(dirname(pkgJsonPath), bin);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
const cleanup: string[] = [];
|
|
10
23
|
|
|
11
24
|
async function makeFixtureWorkspace() {
|
|
@@ -80,8 +93,11 @@ afterEach(async () => {
|
|
|
80
93
|
describe('real TypeScript integration', () => {
|
|
81
94
|
test('typescript-language-server resolves diagnostics and navigation in a fixture workspace', async () => {
|
|
82
95
|
const workspace = await makeFixtureWorkspace();
|
|
83
|
-
const
|
|
84
|
-
|
|
96
|
+
const tsServerBin = resolveBin('typescript-language-server');
|
|
97
|
+
if (!tsServerBin) {
|
|
98
|
+
console.log('Skipping: typescript-language-server not found');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
85
101
|
|
|
86
102
|
const client = new LspClient(
|
|
87
103
|
{
|