@dreki-gg/pi-lsp 0.3.0 → 0.4.1
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 +20 -0
- package/README.md +6 -0
- package/extensions/lsp/client.ts +2 -2
- 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/index.ts +24 -8
- package/extensions/lsp/tools/programs.ts +293 -0
- package/extensions/lsp/tools.ts +19 -231
- package/package.json +4 -1
- package/test/config.test.ts +34 -0
- package/test/effects.test.ts +107 -0
- package/test/index.test.ts +3 -2
- package/test/mock-lsp-server.ts +2 -8
- package/test/tools.test.ts +10 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @dreki-gg/pi-lsp
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6f7034b: Cross-platform fixes for Windows. PR Canvas now opens the browser via the
|
|
8
|
+
Windows `start` command instead of running `xdg-open` (which does not exist on
|
|
9
|
+
Windows), so `/pr-canvas start` and `/pr-canvas open` work there. The LSP client
|
|
10
|
+
derives the workspace folder name with `path.basename` instead of splitting on
|
|
11
|
+
`/`, fixing the name on Windows-style paths.
|
|
12
|
+
|
|
13
|
+
## 0.4.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- a5e800f: refactor(lsp): adopt Effect and stop enabling TypeScript by default
|
|
18
|
+
|
|
19
|
+
- **Effect-based architecture** — config loading, scaffolding, and the unified `lsp` tool now run as Effect programs against injectable services (`FileSystem`, `CommandResolver`, `ServerManager`). Failures are modeled as `Data.TaggedError` types (`ConfigReadError`, `ConfigWriteError`, `LspValidationError`, `NoCapableServerError`, `NoServerAvailableError`, `LspOperationError`), mirroring the firestore package's conventions. Promise-returning wrappers keep the public API stable.
|
|
20
|
+
- **TypeScript is no longer a default server** — the scaffolded starter config now ships TypeScript, Python, Rust, and Go as `disabled` examples. No language server is spawned until the user explicitly opts in, so the extension never auto-starts `typescript-language-server` on a fresh setup.
|
|
21
|
+
- New service-injection tests cover config resolution (global/npx/unavailable command paths) and scaffolding without touching disk or the shell.
|
|
22
|
+
|
|
3
23
|
## 0.3.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -38,6 +38,12 @@ Servers are configured via two config files (project overrides global):
|
|
|
38
38
|
| `~/.pi/agent/extensions/lsp/config.json` | Global defaults |
|
|
39
39
|
| `.pi/lsp.json` | Project-local overrides |
|
|
40
40
|
|
|
41
|
+
On first run a starter `config.json` is scaffolded with example servers
|
|
42
|
+
(TypeScript, Python, Rust, Go) — **all `disabled` by default**. No language
|
|
43
|
+
server is enabled out of the box; flip `"disabled": false` (or remove the flag)
|
|
44
|
+
on the ones you want. This keeps the extension from spawning a server you never
|
|
45
|
+
asked for.
|
|
46
|
+
|
|
41
47
|
### Example: TypeScript + oxlint
|
|
42
48
|
|
|
43
49
|
`.pi/lsp.json`:
|
package/extensions/lsp/client.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
|
-
import { resolve } from 'node:path';
|
|
9
|
+
import { basename, resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { LspConnection } from './protocol';
|
|
12
12
|
import type {
|
|
@@ -246,7 +246,7 @@ export class LspClient {
|
|
|
246
246
|
symbol: {},
|
|
247
247
|
},
|
|
248
248
|
},
|
|
249
|
-
workspaceFolders: [{ uri: rootUri, name: this.rootPath
|
|
249
|
+
workspaceFolders: [{ uri: rootUri, name: basename(this.rootPath) || 'workspace' }],
|
|
250
250
|
initializationOptions: this.config.initializationOptions,
|
|
251
251
|
})) as { capabilities?: Record<string, unknown> } | null;
|
|
252
252
|
|
package/extensions/lsp/config.ts
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LSP server configuration loader.
|
|
3
3
|
*
|
|
4
|
-
* Purely config-driven — no built-in servers
|
|
5
|
-
* in their config files:
|
|
4
|
+
* Purely config-driven — no built-in servers and no language enabled by
|
|
5
|
+
* default. Users define every server in their config files:
|
|
6
6
|
*
|
|
7
7
|
* ~/.pi/agent/extensions/lsp/config.json (global defaults)
|
|
8
8
|
* .pi/lsp.json (project overrides)
|
|
9
9
|
*
|
|
10
10
|
* Project config merges on top of global. `disabled: true` disables a server.
|
|
11
11
|
* `lsp: false` disables all LSP functionality.
|
|
12
|
+
*
|
|
13
|
+
* IO and command resolution are expressed as Effect programs against the
|
|
14
|
+
* FileSystem / CommandResolver services. The `*Effect` functions are the real
|
|
15
|
+
* implementations; the Promise-returning wrappers provide the live services and
|
|
16
|
+
* exist for the imperative call sites (extension entry, commands, tests).
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { dirname, join } from 'node:path';
|
|
19
|
+
import { Effect } from 'effect';
|
|
20
|
+
import { join } from 'node:path';
|
|
17
21
|
import { homedir } from 'node:os';
|
|
18
22
|
|
|
23
|
+
import { CommandResolver } from './effects/command';
|
|
24
|
+
import { FileSystem } from './effects/filesystem';
|
|
25
|
+
import { makeRuntimeLayer } from './effects/runtime';
|
|
26
|
+
import type { ConfigWriteError } from './errors';
|
|
19
27
|
import type { LspConfigFile, LspServerUserConfig, ResolvedServerConfig } from './types';
|
|
20
28
|
|
|
21
29
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
@@ -29,148 +37,162 @@ function projectConfigPath(cwd: string): string {
|
|
|
29
37
|
return join(cwd, '.pi', 'lsp.json');
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Starter template scaffolded on first run. Every server is `disabled` so the
|
|
42
|
+
* extension never auto-starts a language server the user did not opt into —
|
|
43
|
+
* TypeScript is just one example among several, not a default.
|
|
44
|
+
*/
|
|
41
45
|
const STARTER_CONFIG = `{
|
|
42
46
|
"lsp": {
|
|
43
47
|
"typescript": {
|
|
44
48
|
"command": ["typescript-language-server", "--stdio"],
|
|
45
|
-
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]
|
|
49
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
50
|
+
"disabled": true
|
|
51
|
+
},
|
|
52
|
+
"pyright": {
|
|
53
|
+
"command": ["pyright-langserver", "--stdio"],
|
|
54
|
+
"extensions": [".py"],
|
|
55
|
+
"disabled": true
|
|
56
|
+
},
|
|
57
|
+
"rust": {
|
|
58
|
+
"command": ["rust-analyzer"],
|
|
59
|
+
"extensions": [".rs"],
|
|
60
|
+
"disabled": true
|
|
61
|
+
},
|
|
62
|
+
"gopls": {
|
|
63
|
+
"command": ["gopls"],
|
|
64
|
+
"extensions": [".go"],
|
|
65
|
+
"disabled": true
|
|
46
66
|
}
|
|
47
67
|
}
|
|
48
68
|
}
|
|
49
69
|
`;
|
|
50
70
|
|
|
71
|
+
// ── Effect programs ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
51
73
|
/**
|
|
52
74
|
* Scaffold a starter global config if neither global nor project config exists.
|
|
53
|
-
*
|
|
75
|
+
* Succeeds with `true` when a file was created, `false` otherwise.
|
|
54
76
|
*/
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await mkdir(dirname(globalPath), { recursive: true });
|
|
63
|
-
await writeFile(globalPath, STARTER_CONFIG, 'utf8');
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
77
|
+
export function scaffoldGlobalConfigEffect(
|
|
78
|
+
cwd: string,
|
|
79
|
+
): Effect.Effect<boolean, ConfigWriteError, FileSystem> {
|
|
80
|
+
return Effect.gen(function* () {
|
|
81
|
+
const fs = yield* FileSystem;
|
|
82
|
+
const globalPath = globalConfigPath();
|
|
66
83
|
|
|
67
|
-
|
|
84
|
+
if (yield* fs.fileExists(globalPath)) return false;
|
|
85
|
+
if (yield* fs.fileExists(projectConfigPath(cwd))) return false;
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return JSON.parse(text) as T;
|
|
73
|
-
} catch {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
87
|
+
yield* fs.writeTextFile(globalPath, STARTER_CONFIG);
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
+
function readJsonFileEffect<T>(path: string): Effect.Effect<T | null, never, FileSystem> {
|
|
93
|
+
return Effect.gen(function* () {
|
|
94
|
+
const fs = yield* FileSystem;
|
|
95
|
+
const raw = yield* fs.readTextFile(path).pipe(Effect.either);
|
|
96
|
+
if (raw._tag === 'Left') return null;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(raw.right) as T;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
92
103
|
}
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
function resolveServer(
|
|
105
|
+
function resolveServerEffect(
|
|
97
106
|
name: string,
|
|
98
107
|
config: LspServerUserConfig,
|
|
99
108
|
cwd: string,
|
|
100
|
-
): ResolvedServerConfig | null {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
): Effect.Effect<ResolvedServerConfig | null, never, CommandResolver> {
|
|
110
|
+
return Effect.gen(function* () {
|
|
111
|
+
if (config.disabled) return null;
|
|
112
|
+
if (!config.command || config.command.length === 0) return null;
|
|
113
|
+
if (!config.extensions || config.extensions.length === 0) return null;
|
|
114
|
+
|
|
115
|
+
let finalCommand = config.command[0];
|
|
116
|
+
let finalArgs = config.command.slice(1);
|
|
117
|
+
|
|
118
|
+
const resolver = yield* CommandResolver;
|
|
119
|
+
const via = yield* resolver.resolve(finalCommand, cwd);
|
|
120
|
+
if (!via) return null;
|
|
121
|
+
if (via === 'npx') {
|
|
122
|
+
finalArgs = ['--yes', finalCommand, ...finalArgs];
|
|
123
|
+
finalCommand = 'npx';
|
|
124
|
+
}
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
command: finalCommand,
|
|
129
|
+
args: finalArgs,
|
|
130
|
+
extensions: config.extensions,
|
|
131
|
+
env: config.env ?? {},
|
|
132
|
+
initializationOptions: config.initialization ?? {},
|
|
133
|
+
} satisfies ResolvedServerConfig;
|
|
134
|
+
});
|
|
123
135
|
}
|
|
124
136
|
|
|
125
|
-
// ── Public API ──────────────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
137
|
export interface LoadedConfig {
|
|
128
138
|
servers: ResolvedServerConfig[];
|
|
129
139
|
globalDisabled: boolean;
|
|
130
140
|
errors: string[];
|
|
131
141
|
}
|
|
132
142
|
|
|
133
|
-
export
|
|
134
|
-
|
|
143
|
+
export function loadConfigEffect(
|
|
144
|
+
cwd: string,
|
|
145
|
+
): Effect.Effect<LoadedConfig, never, FileSystem | CommandResolver> {
|
|
146
|
+
return Effect.gen(function* () {
|
|
147
|
+
const errors: string[] = [];
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
const globalConfig = yield* readJsonFileEffect<LspConfigFile>(globalConfigPath());
|
|
150
|
+
const projectConfig = yield* readJsonFileEffect<LspConfigFile>(projectConfigPath(cwd));
|
|
138
151
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return { servers: [], globalDisabled: true, errors };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const globalServers = (typeof globalConfig?.lsp === 'object' ? globalConfig.lsp : {}) as Record<
|
|
145
|
-
string,
|
|
146
|
-
LspServerUserConfig
|
|
147
|
-
>;
|
|
148
|
-
const projectServers = (
|
|
149
|
-
typeof projectConfig?.lsp === 'object' ? projectConfig.lsp : {}
|
|
150
|
-
) as Record<string, LspServerUserConfig>;
|
|
151
|
-
|
|
152
|
-
// Merge: project overrides global
|
|
153
|
-
const allNames = new Set([...Object.keys(globalServers), ...Object.keys(projectServers)]);
|
|
154
|
-
const servers: ResolvedServerConfig[] = [];
|
|
155
|
-
|
|
156
|
-
for (const name of allNames) {
|
|
157
|
-
const userConfig: LspServerUserConfig = {
|
|
158
|
-
...globalServers[name],
|
|
159
|
-
...projectServers[name],
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
// Merge env maps properly
|
|
163
|
-
if (globalServers[name]?.env || projectServers[name]?.env) {
|
|
164
|
-
userConfig.env = { ...globalServers[name]?.env, ...projectServers[name]?.env };
|
|
152
|
+
if (globalConfig?.lsp === false || projectConfig?.lsp === false) {
|
|
153
|
+
return { servers: [], globalDisabled: true, errors };
|
|
165
154
|
}
|
|
166
155
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
156
|
+
const globalServers = (typeof globalConfig?.lsp === 'object' ? globalConfig.lsp : {}) as Record<
|
|
157
|
+
string,
|
|
158
|
+
LspServerUserConfig
|
|
159
|
+
>;
|
|
160
|
+
const projectServers = (
|
|
161
|
+
typeof projectConfig?.lsp === 'object' ? projectConfig.lsp : {}
|
|
162
|
+
) as Record<string, LspServerUserConfig>;
|
|
163
|
+
|
|
164
|
+
const allNames = new Set([...Object.keys(globalServers), ...Object.keys(projectServers)]);
|
|
165
|
+
const servers: ResolvedServerConfig[] = [];
|
|
166
|
+
|
|
167
|
+
for (const name of allNames) {
|
|
168
|
+
const userConfig: LspServerUserConfig = {
|
|
169
|
+
...globalServers[name],
|
|
170
|
+
...projectServers[name],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Merge env maps properly (project on top of global).
|
|
174
|
+
if (globalServers[name]?.env || projectServers[name]?.env) {
|
|
175
|
+
userConfig.env = { ...globalServers[name]?.env, ...projectServers[name]?.env };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resolved = yield* resolveServerEffect(name, userConfig, cwd);
|
|
179
|
+
if (resolved) servers.push(resolved);
|
|
170
180
|
}
|
|
171
|
-
}
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
return { servers, globalDisabled: false, errors };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Promise wrappers (live services provided) ────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export function scaffoldGlobalConfig(cwd: string): Promise<boolean> {
|
|
189
|
+
return Effect.runPromise(
|
|
190
|
+
scaffoldGlobalConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer())),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
195
|
+
return Effect.runPromise(loadConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer())));
|
|
174
196
|
}
|
|
175
197
|
|
|
176
198
|
/** Find all servers that handle a given file extension. */
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandResolver service — decides how a configured LSP server command can be
|
|
3
|
+
* launched: directly from PATH (`global`), via `npx` as a fallback, or not at
|
|
4
|
+
* all (`null`).
|
|
5
|
+
*
|
|
6
|
+
* Isolated behind an Effect service so config resolution stays pure and tests
|
|
7
|
+
* can inject deterministic availability without shelling out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context, Effect } from 'effect';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
export type CommandAvailability = 'global' | 'npx' | null;
|
|
14
|
+
|
|
15
|
+
export interface CommandResolverService {
|
|
16
|
+
readonly resolve: (command: string, cwd: string) => Effect.Effect<CommandAvailability>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CommandResolver extends Context.Tag('Lsp/CommandResolver')<
|
|
20
|
+
CommandResolver,
|
|
21
|
+
CommandResolverService
|
|
22
|
+
>() {}
|
|
23
|
+
|
|
24
|
+
function commandAvailableVia(command: string, cwd: string): CommandAvailability {
|
|
25
|
+
try {
|
|
26
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
27
|
+
execSync(`${whichCmd} ${command}`, { stdio: 'pipe', timeout: 5_000 });
|
|
28
|
+
return 'global';
|
|
29
|
+
} catch {
|
|
30
|
+
// not on PATH
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
execSync(`npx --yes ${command} --version`, { stdio: 'pipe', cwd, timeout: 15_000 });
|
|
34
|
+
return 'npx';
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const nodeCommandResolverService: CommandResolverService = {
|
|
41
|
+
resolve: (command, cwd) => Effect.sync(() => commandAvailableVia(command, cwd)),
|
|
42
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSystem service — the only place the LSP extension touches disk.
|
|
3
|
+
*
|
|
4
|
+
* Wrapping Node's `fs/promises` behind an Effect service keeps config loading
|
|
5
|
+
* pure and injectable: tests can swap in an in-memory implementation, and
|
|
6
|
+
* failures surface as typed `ConfigReadError` / `ConfigWriteError` values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Context, Effect } from 'effect';
|
|
10
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { ConfigReadError, ConfigWriteError } from '../errors';
|
|
14
|
+
|
|
15
|
+
export interface FileSystemService {
|
|
16
|
+
/** Read a UTF-8 file, failing with ConfigReadError when unreadable/missing. */
|
|
17
|
+
readonly readTextFile: (path: string) => Effect.Effect<string, ConfigReadError>;
|
|
18
|
+
/** Whether a path exists. Never fails. */
|
|
19
|
+
readonly fileExists: (path: string) => Effect.Effect<boolean>;
|
|
20
|
+
/** Write a UTF-8 file, creating parent directories first. */
|
|
21
|
+
readonly writeTextFile: (path: string, content: string) => Effect.Effect<void, ConfigWriteError>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class FileSystem extends Context.Tag('Lsp/FileSystem')<FileSystem, FileSystemService>() {}
|
|
25
|
+
|
|
26
|
+
export const nodeFileSystemService: FileSystemService = {
|
|
27
|
+
readTextFile: (path) =>
|
|
28
|
+
Effect.tryPromise({
|
|
29
|
+
try: () => readFile(path, 'utf8'),
|
|
30
|
+
catch: (cause) => new ConfigReadError({ path, cause }),
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
fileExists: (path) =>
|
|
34
|
+
Effect.tryPromise({
|
|
35
|
+
try: () => access(path),
|
|
36
|
+
catch: (cause) => cause,
|
|
37
|
+
}).pipe(
|
|
38
|
+
Effect.as(true),
|
|
39
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
writeTextFile: (path, content) =>
|
|
43
|
+
Effect.tryPromise({
|
|
44
|
+
try: async () => {
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
await writeFile(path, content, 'utf8');
|
|
47
|
+
},
|
|
48
|
+
catch: (cause) => new ConfigWriteError({ path, cause }),
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Effect layer for the LSP extension.
|
|
3
|
+
*
|
|
4
|
+
* Merges the disk-facing FileSystem service and the command-availability
|
|
5
|
+
* CommandResolver service. Build it once per extension activation and reuse it
|
|
6
|
+
* for every config/scaffold program.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Layer } from 'effect';
|
|
10
|
+
|
|
11
|
+
import { CommandResolver, nodeCommandResolverService } from './command';
|
|
12
|
+
import { FileSystem, nodeFileSystemService } from './filesystem';
|
|
13
|
+
|
|
14
|
+
export function makeRuntimeLayer() {
|
|
15
|
+
return Layer.mergeAll(
|
|
16
|
+
Layer.succeed(FileSystem, nodeFileSystemService),
|
|
17
|
+
Layer.succeed(CommandResolver, nodeCommandResolverService),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type LspServices = FileSystem | CommandResolver;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error types for the LSP extension.
|
|
3
|
+
*
|
|
4
|
+
* Modeled with Effect's `Data.TaggedError` so failures are typed, pattern
|
|
5
|
+
* matchable, and carry structured context. Helpers at the bottom convert these
|
|
6
|
+
* into human-readable messages, tool `details`, and native `Error`s when an
|
|
7
|
+
* Effect needs to cross back into Promise-land.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Data } from 'effect';
|
|
11
|
+
|
|
12
|
+
export class ConfigReadError extends Data.TaggedError('ConfigReadError')<{
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly cause: unknown;
|
|
15
|
+
}> {
|
|
16
|
+
get message(): string {
|
|
17
|
+
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ConfigWriteError extends Data.TaggedError('ConfigWriteError')<{
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly cause: unknown;
|
|
24
|
+
}> {
|
|
25
|
+
get message(): string {
|
|
26
|
+
return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LspValidationError extends Data.TaggedError('LspValidationError')<{
|
|
31
|
+
readonly reason: string;
|
|
32
|
+
}> {
|
|
33
|
+
get message(): string {
|
|
34
|
+
return this.reason;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class NoCapableServerError extends Data.TaggedError('NoCapableServerError')<{
|
|
39
|
+
readonly operation: string;
|
|
40
|
+
readonly filePath: string;
|
|
41
|
+
}> {
|
|
42
|
+
get message(): string {
|
|
43
|
+
return `No LSP server with '${this.operation}' capability found for ${this.filePath}. Check /lsp status.`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class NoServerAvailableError extends Data.TaggedError('NoServerAvailableError')<{
|
|
48
|
+
readonly operation: string;
|
|
49
|
+
}> {
|
|
50
|
+
get message(): string {
|
|
51
|
+
return `No LSP server available for ${this.operation}.`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class LspOperationError extends Data.TaggedError('LspOperationError')<{
|
|
56
|
+
readonly operation: string;
|
|
57
|
+
readonly server?: string;
|
|
58
|
+
readonly cause: unknown;
|
|
59
|
+
}> {
|
|
60
|
+
get message(): string {
|
|
61
|
+
const server = this.server ? ` (${this.server})` : '';
|
|
62
|
+
return `LSP ${this.operation}${server} failed: ${causeMessage(this.cause)}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type LspExtensionError =
|
|
67
|
+
| ConfigReadError
|
|
68
|
+
| ConfigWriteError
|
|
69
|
+
| LspValidationError
|
|
70
|
+
| NoCapableServerError
|
|
71
|
+
| NoServerAvailableError
|
|
72
|
+
| LspOperationError;
|
|
73
|
+
|
|
74
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function causeMessage(cause: unknown): string {
|
|
77
|
+
if (cause instanceof Error) return cause.message;
|
|
78
|
+
return String(cause);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function errorMessage(error: unknown): string {
|
|
82
|
+
if (error instanceof Error) return error.message;
|
|
83
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
84
|
+
const message = (error as { message?: unknown }).message;
|
|
85
|
+
if (typeof message === 'string') return message;
|
|
86
|
+
}
|
|
87
|
+
return String(error);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function errorDetails(error: unknown): Record<string, unknown> {
|
|
91
|
+
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
92
|
+
const tagged = error as { _tag: string } & Record<string, unknown>;
|
|
93
|
+
const details: Record<string, unknown> = { error: tagged._tag };
|
|
94
|
+
for (const [key, value] of Object.entries(tagged)) {
|
|
95
|
+
if (key === '_tag' || key === 'cause') continue;
|
|
96
|
+
details[key] = value;
|
|
97
|
+
}
|
|
98
|
+
details.message = errorMessage(error);
|
|
99
|
+
return details;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { error: 'lsp_error', message: errorMessage(error) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Convert a tagged/unknown error into a native Error for Promise rejection. */
|
|
106
|
+
export function toNativeError(error: unknown): Error {
|
|
107
|
+
if (error instanceof Error) return error;
|
|
108
|
+
const native = new Error(errorMessage(error));
|
|
109
|
+
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
110
|
+
native.name = String((error as { _tag: unknown })._tag);
|
|
111
|
+
}
|
|
112
|
+
return native;
|
|
113
|
+
}
|
package/extensions/lsp/index.ts
CHANGED
|
@@ -10,16 +10,29 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import { Effect } from 'effect';
|
|
13
14
|
|
|
14
15
|
import { LspClient } from './client';
|
|
15
|
-
import {
|
|
16
|
-
|
|
16
|
+
import {
|
|
17
|
+
loadConfigEffect,
|
|
18
|
+
scaffoldGlobalConfigEffect,
|
|
19
|
+
serversForExtension,
|
|
20
|
+
type LoadedConfig,
|
|
21
|
+
} from './config';
|
|
22
|
+
import { makeRuntimeLayer, type LspServices } from './effects/runtime';
|
|
23
|
+
import { errorMessage } from './errors';
|
|
24
|
+
import { registerLspTool, type ServerManagerService } from './tools';
|
|
17
25
|
import type { ResolvedServerConfig } from './types';
|
|
18
26
|
|
|
19
27
|
export default function lspExtension(pi: ExtensionAPI) {
|
|
20
28
|
let rootPath = '';
|
|
21
29
|
let config: LoadedConfig | null = null;
|
|
22
30
|
const clients = new Map<string, LspClient>();
|
|
31
|
+
const runtimeLayer = makeRuntimeLayer();
|
|
32
|
+
|
|
33
|
+
function runLsp<A, E>(program: Effect.Effect<A, E, LspServices>): Promise<A> {
|
|
34
|
+
return Effect.runPromise(program.pipe(Effect.provide(runtimeLayer)));
|
|
35
|
+
}
|
|
23
36
|
|
|
24
37
|
// ── Client management ───────────────────────────────────────────────
|
|
25
38
|
|
|
@@ -68,7 +81,7 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
68
81
|
|
|
69
82
|
// ── Server manager (passed to tool) ───────────────────────────────────
|
|
70
83
|
|
|
71
|
-
const serverManager:
|
|
84
|
+
const serverManager: ServerManagerService = {
|
|
72
85
|
clientsForFile(filePath: string): LspClient[] {
|
|
73
86
|
if (!config) return [];
|
|
74
87
|
const matching = serversForExtension(config.servers, filePath);
|
|
@@ -111,15 +124,18 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
111
124
|
pi.on('session_start', async (_event, ctx) => {
|
|
112
125
|
rootPath = ctx.cwd;
|
|
113
126
|
|
|
114
|
-
const scaffolded = await
|
|
127
|
+
const scaffolded = await runLsp(scaffoldGlobalConfigEffect(rootPath)).catch((err) => {
|
|
128
|
+
ctx.ui.notify(`LSP: could not scaffold config: ${errorMessage(err)}`, 'warning');
|
|
129
|
+
return false;
|
|
130
|
+
});
|
|
115
131
|
if (scaffolded) {
|
|
116
132
|
ctx.ui.notify(
|
|
117
|
-
'LSP: created starter config at ~/.pi/agent/extensions/lsp/config.json —
|
|
133
|
+
'LSP: created starter config at ~/.pi/agent/extensions/lsp/config.json — every server is disabled, enable the ones you need.',
|
|
118
134
|
'info',
|
|
119
135
|
);
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
config = await
|
|
138
|
+
config = await runLsp(loadConfigEffect(rootPath));
|
|
123
139
|
refreshStatus(ctx.ui, config);
|
|
124
140
|
});
|
|
125
141
|
|
|
@@ -139,7 +155,7 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
139
155
|
description: 'Show LSP server status',
|
|
140
156
|
handler: async (_args, ctx) => {
|
|
141
157
|
rootPath = ctx.cwd;
|
|
142
|
-
const cfg = await
|
|
158
|
+
const cfg = await runLsp(loadConfigEffect(ctx.cwd));
|
|
143
159
|
config = cfg;
|
|
144
160
|
refreshStatus(ctx.ui, cfg);
|
|
145
161
|
const lines: string[] = ['LSP Status:'];
|
|
@@ -173,7 +189,7 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
173
189
|
await shutdownAll();
|
|
174
190
|
config = null;
|
|
175
191
|
rootPath = ctx.cwd;
|
|
176
|
-
config = await
|
|
192
|
+
config = await runLsp(loadConfigEffect(ctx.cwd));
|
|
177
193
|
refreshStatus(ctx.ui, config);
|
|
178
194
|
ctx.ui.notify('LSP servers stopped. Will reinitialize on next tool use.', 'info');
|
|
179
195
|
},
|