@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.
@@ -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
+ });
@@ -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', () => {
@@ -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 text = await readFile(globalConfigPath, 'utf8');
111
- expect(text).toContain('typescript-language-server');
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
 
@@ -243,20 +243,27 @@ function handleRequest(req: JsonRpcRequest) {
243
243
  }
244
244
  }
245
245
 
246
- function handleNotification(msg: JsonRpcNotification) {
247
- if (msg.method === 'textDocument/didOpen' || msg.method === 'textDocument/didChange') {
248
- const textDocument =
249
- msg.method === 'textDocument/didOpen' ? msg.params?.textDocument : msg.params?.textDocument;
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
- if (msg.method === 'exit') {
259
- process.exit(0);
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
+ });
@@ -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
- clientsForFile: () => [
45
- {
46
- config: { name: 'ts' },
47
- getDiagnostics: async () => [
48
- {
49
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
50
- severity: 1,
51
- source: 'ts',
52
- message: 'Type error',
53
- },
54
- ],
55
- },
56
- {
57
- config: { name: 'eslint' },
58
- getDiagnostics: async () => [
59
- {
60
- range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } },
61
- severity: 2,
62
- source: 'eslint',
63
- message: 'Lint warning',
64
- },
65
- ],
66
- },
67
- ],
68
- clientForFileWithCapability: () => null,
69
- anyClient: () => null,
70
- getRootPath: () => '/repo',
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
- clientsForFile: () => [],
96
- clientForFileWithCapability: () => ({
97
- hover: async (filePath: string, pos: { line: number; character: number }) => {
98
- calls.push({ filePath, pos });
99
- return { contents: { kind: 'markdown', value: 'mock hover' } };
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
- anyClient: () => null,
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
- clientsForFile: () => [],
124
- clientForFileWithCapability: () => null,
125
- anyClient: () => ({
126
- workspaceSymbol: async (query: string) => [
127
- {
128
- name: `${query}Service`,
129
- kind: 5,
130
- location: {
131
- uri: 'file:///repo/src/service.ts',
132
- range: { start: { line: 9, character: 0 }, end: { line: 9, character: 5 } },
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
- containerName: 'services',
135
- },
136
- ],
151
+ ],
152
+ }),
137
153
  }),
138
- getRootPath: () => '/repo',
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
- clientsForFile: () => [],
180
- clientForFileWithCapability: () => ({
181
- getDiagnostics: async () => [
182
- {
183
- range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
184
- severity: 1,
185
- message: 'line 3 issue',
186
- },
187
- {
188
- range: { start: { line: 5, character: 0 }, end: { line: 5, character: 10 } },
189
- severity: 1,
190
- message: 'line 6 issue',
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
- anyClient: () => null,
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 packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
84
- const tsServerBin = join(packageRoot, 'node_modules', '.bin', 'typescript-language-server');
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
  {