pi-lsp-lite 0.2.4 → 0.3.2
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/.github/workflows/ci.yml +30 -0
- package/.github/workflows/integration.yml +79 -0
- package/.github/workflows/release.yml +34 -0
- package/README.md +75 -26
- package/index.ts +238 -10
- package/package.json +7 -3
- package/src/client.ts +51 -19
- package/src/config.ts +295 -0
- package/src/format.ts +5 -1
- package/src/install-registry.ts +27 -0
- package/src/languages.ts +26 -5
- package/src/server-manager.ts +59 -7
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
check:
|
|
14
|
+
name: typecheck + unit tests
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-node@v6
|
|
20
|
+
with:
|
|
21
|
+
node-version: 20
|
|
22
|
+
cache: npm
|
|
23
|
+
|
|
24
|
+
- run: npm ci
|
|
25
|
+
|
|
26
|
+
- name: typecheck
|
|
27
|
+
run: npm run check
|
|
28
|
+
|
|
29
|
+
- name: unit tests
|
|
30
|
+
run: npm test
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: Integration Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
gopls:
|
|
12
|
+
name: gopls
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v6
|
|
16
|
+
- uses: actions/setup-node@v6
|
|
17
|
+
with:
|
|
18
|
+
node-version: 20
|
|
19
|
+
cache: npm
|
|
20
|
+
- uses: actions/setup-go@v6
|
|
21
|
+
with:
|
|
22
|
+
go-version: stable
|
|
23
|
+
- run: go install golang.org/x/tools/gopls@latest
|
|
24
|
+
- run: npm ci
|
|
25
|
+
- run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/gopls.test.ts
|
|
26
|
+
|
|
27
|
+
rust-analyzer:
|
|
28
|
+
name: rust-analyzer
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v6
|
|
32
|
+
- uses: actions/setup-node@v6
|
|
33
|
+
with:
|
|
34
|
+
node-version: 20
|
|
35
|
+
cache: npm
|
|
36
|
+
- run: |
|
|
37
|
+
rustup update stable
|
|
38
|
+
rustup component add rust-analyzer
|
|
39
|
+
- run: npm ci
|
|
40
|
+
- run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/rust-analyzer.test.ts
|
|
41
|
+
|
|
42
|
+
typescript:
|
|
43
|
+
name: typescript-language-server
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@v6
|
|
47
|
+
- uses: actions/setup-node@v6
|
|
48
|
+
with:
|
|
49
|
+
node-version: 20
|
|
50
|
+
cache: npm
|
|
51
|
+
- run: npm install -g typescript-language-server typescript
|
|
52
|
+
- run: npm ci
|
|
53
|
+
- run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/typescript.test.ts
|
|
54
|
+
|
|
55
|
+
pylsp:
|
|
56
|
+
name: pylsp
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@v6
|
|
60
|
+
- uses: actions/setup-node@v6
|
|
61
|
+
with:
|
|
62
|
+
node-version: 20
|
|
63
|
+
cache: npm
|
|
64
|
+
- run: pip install 'python-lsp-server[all]'
|
|
65
|
+
- run: npm ci
|
|
66
|
+
- run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/pylsp.test.ts
|
|
67
|
+
|
|
68
|
+
clangd:
|
|
69
|
+
name: clangd
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v6
|
|
73
|
+
- uses: actions/setup-node@v6
|
|
74
|
+
with:
|
|
75
|
+
node-version: 20
|
|
76
|
+
cache: npm
|
|
77
|
+
- run: sudo apt-get update -qq && sudo apt-get install -y -qq clangd
|
|
78
|
+
- run: npm ci
|
|
79
|
+
- run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/clangd.test.ts
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
name: publish to npm
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: public
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-node@v6
|
|
20
|
+
with:
|
|
21
|
+
node-version: 24
|
|
22
|
+
registry-url: https://registry.npmjs.org
|
|
23
|
+
cache: npm
|
|
24
|
+
|
|
25
|
+
- run: npm ci
|
|
26
|
+
|
|
27
|
+
- name: typecheck
|
|
28
|
+
run: npm run check
|
|
29
|
+
|
|
30
|
+
- name: unit tests
|
|
31
|
+
run: npm test
|
|
32
|
+
|
|
33
|
+
- name: publish
|
|
34
|
+
run: npm publish --access public
|
package/README.md
CHANGED
|
@@ -1,62 +1,111 @@
|
|
|
1
1
|
# pi-lsp-lite
|
|
2
2
|
|
|
3
|
-
[pi](https://github.com/
|
|
3
|
+
[](https://github.com/mcphailtom/pi-lsp-lite/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-lsp-lite)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Your agent can't see compiler errors. Now it can.
|
|
8
|
+
|
|
9
|
+
[pi](https://github.com/mariozechner/pi) extension that runs language servers in the background and feeds diagnostics back inline after every edit. Errors appear on the same turn — no context switch, no separate command.
|
|
10
|
+
|
|
11
|
+
**Go · Rust · TypeScript · Python · C/C++**
|
|
6
12
|
|
|
7
13
|
## Install
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
|
-
pi install
|
|
16
|
+
pi install npm:pi-lsp-lite
|
|
11
17
|
```
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
That's it. If you have `gopls`, `rust-analyzer`, `typescript-language-server`, `pylsp`, or `clangd` on PATH, diagnostics start flowing automatically.
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
## What you see
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
edit ─ src/main.go
|
|
25
|
+
✓ Edited src/main.go (replaced 2 lines)
|
|
26
|
+
|
|
27
|
+
⚠ LSP diagnostics for src/main.go (2 errors):
|
|
28
|
+
error 12:5 [compiler] undefined: foo
|
|
29
|
+
error 18:2 [compiler] too many arguments in call to bar
|
|
30
|
+
+ 1 diagnostic in 1 other file
|
|
17
31
|
```
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
The agent sees these too — they're appended to the tool result, so it can self-correct on the same turn.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
| Command | What it does |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| `/lsp-status` | Show running servers, PIDs, workspace roots, uptime |
|
|
40
|
+
| `/lsp-diag` | Show all current diagnostics (or `/lsp-diag path/to/file` for one file) |
|
|
41
|
+
| `/lsp-add` | Interactively add a new language server |
|
|
42
|
+
| `/lsp-remove` | Disable a configured server |
|
|
43
|
+
| `/lsp-toggle` | Flip a server on/off without removing config |
|
|
44
|
+
| `/lsp-install` | Install a missing server binary |
|
|
45
|
+
|
|
46
|
+
## Supported servers
|
|
22
47
|
|
|
23
48
|
| Server | Language | Install |
|
|
24
49
|
|--------|----------|---------|
|
|
25
50
|
| `gopls` | Go | `go install golang.org/x/tools/gopls@latest` |
|
|
26
51
|
| `rust-analyzer` | Rust | `rustup component add rust-analyzer` |
|
|
27
|
-
| `typescript-language-server` | TypeScript/
|
|
52
|
+
| `typescript-language-server` | TypeScript/JS | `npm install -g typescript-language-server typescript` |
|
|
53
|
+
| `pylsp` | Python | `pip install python-lsp-server` |
|
|
54
|
+
| `clangd` | C/C++ | Xcode CLI tools / `apt install clangd` |
|
|
55
|
+
|
|
56
|
+
Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to `.pi-lsp-lite.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"servers": {
|
|
61
|
+
"haskell": {
|
|
62
|
+
"extensions": [".hs"],
|
|
63
|
+
"command": "haskell-language-server-wrapper",
|
|
64
|
+
"args": ["--lsp"],
|
|
65
|
+
"rootPatterns": ["cabal.project", "stack.yaml"]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
28
70
|
|
|
29
|
-
##
|
|
71
|
+
## Configuration
|
|
30
72
|
|
|
31
|
-
|
|
73
|
+
Works without config. For customisation, create `.pi-lsp-lite.json` (project) or `~/.pi-lsp-lite.json` (global):
|
|
32
74
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
| Field | Description | Default |
|
|
76
|
+
|-------|-------------|---------|
|
|
77
|
+
| `servers.<id>.diagnosticTimeout` | Per-attempt timeout (ms) | per-language |
|
|
78
|
+
| `servers.<id>.maxRetries` | Retry attempts on timeout (0-10) | `3` |
|
|
79
|
+
| `servers.<id>.disabled` | Disable this server | `false` |
|
|
80
|
+
| `diagnosticTimeout` | Global default timeout (ms) | `5000` |
|
|
81
|
+
| `documentIdleTimeout` | Close idle documents after (ms) | `120000` |
|
|
39
82
|
|
|
40
|
-
|
|
83
|
+
Project config merges over global. Partial overrides work — only specify what you want to change.
|
|
41
84
|
|
|
42
85
|
## How it works
|
|
43
86
|
|
|
44
|
-
|
|
87
|
+
1. Agent writes/edits a file
|
|
88
|
+
2. Extension detects the language, finds the workspace root
|
|
89
|
+
3. Spawns (or reuses) an LSP server for that language + root
|
|
90
|
+
4. Sends `didChange`, waits for `publishDiagnostics`
|
|
91
|
+
5. If timeout: retries with exponential backoff + jitter (up to `maxRetries` times)
|
|
92
|
+
6. Filters to errors + warnings, formats, appends to tool result + shows in TUI
|
|
93
|
+
|
|
94
|
+
Cross-file impact is detected via snapshot-diff: if editing `lib.ts` breaks `caller.ts`, you see "+ N diagnostics in M other files".
|
|
45
95
|
|
|
46
|
-
|
|
96
|
+
Servers are lazy (spawn on first edit), idle-shutdown after 240s, and clean up on session end.
|
|
47
97
|
|
|
48
98
|
## Development
|
|
49
99
|
|
|
50
100
|
```bash
|
|
51
101
|
git clone https://github.com/mcphailtom/pi-lsp-lite
|
|
52
|
-
cd pi-lsp-lite
|
|
53
|
-
npm
|
|
54
|
-
npm
|
|
55
|
-
npm test
|
|
56
|
-
npm run test:integration # requires servers on PATH
|
|
102
|
+
cd pi-lsp-lite && npm install
|
|
103
|
+
npm run check # typecheck
|
|
104
|
+
npm test # unit tests (106, no servers needed)
|
|
105
|
+
npm run test:integration # real server tests (needs servers on PATH)
|
|
57
106
|
```
|
|
58
107
|
|
|
59
|
-
See [
|
|
108
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
|
|
60
109
|
|
|
61
110
|
## License
|
|
62
111
|
|
package/index.ts
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { createServerManager } from "./src/server-manager.js";
|
|
3
|
-
import { languageForFile, checkExtensionOverlaps } from "./src/languages.js";
|
|
3
|
+
import { languageForFile, checkExtensionOverlaps, builtinLanguages, type LanguageServerConfig } from "./src/languages.js";
|
|
4
4
|
import { formatDiagnostics } from "./src/format.js";
|
|
5
|
+
import { DiagnosticSeverity } from "vscode-languageserver-protocol";
|
|
6
|
+
import { loadConfig, writeGlobalConfig, readGlobalConfig } from "./src/config.js";
|
|
7
|
+
import { fileUri, which } from "./src/util.js";
|
|
8
|
+
import { installRegistry } from "./src/install-registry.js";
|
|
5
9
|
import { resolve, relative, isAbsolute } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
6
11
|
|
|
7
12
|
export default function (pi: ExtensionAPI) {
|
|
8
|
-
|
|
13
|
+
let servers: LanguageServerConfig[] = [];
|
|
14
|
+
let manager = createServerManager({});
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
async function initConfig(cwd: string) {
|
|
17
|
+
await manager.shutdownAll();
|
|
18
|
+
const resolved = await loadConfig(cwd);
|
|
19
|
+
servers = resolved.servers;
|
|
20
|
+
manager = createServerManager({
|
|
21
|
+
diagnosticTimeout: resolved.diagnosticTimeout,
|
|
22
|
+
documentIdleTimeout: resolved.documentIdleTimeout,
|
|
23
|
+
perServerTimeout: resolved.perServerTimeout,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
for (const warning of checkExtensionOverlaps(servers)) {
|
|
27
|
+
console.error(`[pi-lsp-lite] ${warning}`);
|
|
28
|
+
}
|
|
12
29
|
}
|
|
13
30
|
|
|
31
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
32
|
+
await initConfig(ctx.cwd);
|
|
33
|
+
});
|
|
34
|
+
|
|
14
35
|
pi.on("tool_result", async (event, ctx) => {
|
|
15
36
|
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
16
37
|
|
|
@@ -21,11 +42,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
21
42
|
const absolutePath = resolve(ctx.cwd, filePath);
|
|
22
43
|
const rel = relative(ctx.cwd, absolutePath);
|
|
23
44
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return;
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
45
|
+
const langConfig = languageForFile(absolutePath, servers);
|
|
46
|
+
if (!langConfig) return;
|
|
26
47
|
|
|
27
48
|
try {
|
|
28
|
-
const result = await manager.handleEdit(absolutePath,
|
|
49
|
+
const result = await manager.handleEdit(absolutePath, langConfig, ctx.cwd);
|
|
29
50
|
const formatted = formatDiagnostics(filePath, result);
|
|
30
51
|
if (!formatted) return;
|
|
31
52
|
|
|
@@ -46,12 +67,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
46
67
|
pi.registerCommand("lsp-status", {
|
|
47
68
|
description: "Show running LSP servers and recent diagnostic counts",
|
|
48
69
|
handler: async (_args, ctx) => {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
70
|
+
const running = manager.status();
|
|
71
|
+
if (running.length === 0) {
|
|
51
72
|
ctx.ui.notify("pi-lsp-lite: no servers running", "info");
|
|
52
73
|
return;
|
|
53
74
|
}
|
|
54
|
-
const lines =
|
|
75
|
+
const lines = running.map((s) => {
|
|
55
76
|
const idle = Math.round((Date.now() - s.lastActivity) / 1000);
|
|
56
77
|
const up = Math.round(s.uptime / 1000);
|
|
57
78
|
return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
|
|
@@ -59,4 +80,211 @@ export default function (pi: ExtensionAPI) {
|
|
|
59
80
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
60
81
|
},
|
|
61
82
|
});
|
|
83
|
+
|
|
84
|
+
pi.registerCommand("lsp-diag", {
|
|
85
|
+
description: "Show current LSP diagnostics for all tracked files (or a specific file)",
|
|
86
|
+
handler: async (args, ctx) => {
|
|
87
|
+
const allDiags = manager.getAllDiagnostics();
|
|
88
|
+
|
|
89
|
+
if (allDiags.size === 0) {
|
|
90
|
+
ctx.ui.notify("pi-lsp-lite: no diagnostics", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const filterPath = args?.trim();
|
|
95
|
+
let filterUri: string | undefined;
|
|
96
|
+
if (filterPath) {
|
|
97
|
+
const abs = resolve(ctx.cwd, filterPath);
|
|
98
|
+
filterUri = fileUri(abs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
for (const [uri, diags] of allDiags) {
|
|
103
|
+
if (filterUri && uri !== filterUri) continue;
|
|
104
|
+
const filePath = fileURLToPath(new URL(uri));
|
|
105
|
+
const relevant = diags.filter((d) => d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning);
|
|
106
|
+
if (relevant.length === 0) continue;
|
|
107
|
+
lines.push(`${filePath} (${relevant.length} diagnostic${relevant.length !== 1 ? "s" : ""})`);
|
|
108
|
+
for (const d of relevant) {
|
|
109
|
+
const severity = d.severity === DiagnosticSeverity.Error ? "error" : "warning";
|
|
110
|
+
const line = d.range.start.line + 1;
|
|
111
|
+
const col = d.range.start.character + 1;
|
|
112
|
+
const source = d.source ? `[${d.source}] ` : "";
|
|
113
|
+
lines.push(` ${severity} ${line}:${col} ${source}${d.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (lines.length === 0) {
|
|
118
|
+
ctx.ui.notify(filterPath ? `pi-lsp-lite: no diagnostics for ${filterPath}` : "pi-lsp-lite: no diagnostics", "info");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ctx.ui.notify(lines.join("\n"), "warning");
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.registerCommand("lsp-add", {
|
|
127
|
+
description: "Add a new language server to global config",
|
|
128
|
+
handler: async (_args, ctx) => {
|
|
129
|
+
if (!ctx.hasUI) {
|
|
130
|
+
ctx.ui.notify("pi-lsp-lite: /lsp-add requires interactive mode", "error");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const rawId = await ctx.ui.input("Server ID (e.g. haskell):");
|
|
135
|
+
if (!rawId) return;
|
|
136
|
+
const id = rawId.trim().toLowerCase();
|
|
137
|
+
if (!/^[a-z0-9_-]+$/.test(id)) {
|
|
138
|
+
ctx.ui.notify("pi-lsp-lite: server ID must be lowercase alphanumeric, hyphens, or underscores", "error");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const RESERVED_IDS = new Set(["__proto__", "constructor", "prototype"]);
|
|
142
|
+
if (RESERVED_IDS.has(id)) {
|
|
143
|
+
ctx.ui.notify("pi-lsp-lite: reserved ID, choose a different name", "error");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rawCommand = await ctx.ui.input("Binary command (e.g. haskell-language-server-wrapper):");
|
|
148
|
+
const command = rawCommand?.trim();
|
|
149
|
+
if (!command) return;
|
|
150
|
+
|
|
151
|
+
const argsRaw = await ctx.ui.input("CLI args (comma-separated, or empty):");
|
|
152
|
+
const args = argsRaw ? argsRaw.split(",").map((a) => a.trim()).filter(Boolean) : [];
|
|
153
|
+
|
|
154
|
+
const extRaw = await ctx.ui.input("File extensions (comma-separated, e.g. .hs,.lhs):");
|
|
155
|
+
if (!extRaw) return;
|
|
156
|
+
const extensions = extRaw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
157
|
+
if (extensions.length === 0) {
|
|
158
|
+
ctx.ui.notify("pi-lsp-lite: at least one extension is required", "error");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rootRaw = await ctx.ui.input("Root pattern files (comma-separated, or empty):");
|
|
163
|
+
const rootPatterns = rootRaw ? rootRaw.split(",").map((r) => r.trim()).filter(Boolean) : [];
|
|
164
|
+
|
|
165
|
+
const resolved = await which(command);
|
|
166
|
+
if (!resolved) {
|
|
167
|
+
ctx.ui.notify(`pi-lsp-lite: "${command}" not found on PATH — server added but won't start until installed`, "warning");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await writeGlobalConfig({ servers: { [id]: { command, args, extensions, rootPatterns } } });
|
|
171
|
+
await initConfig(ctx.cwd);
|
|
172
|
+
ctx.ui.notify(`pi-lsp-lite: added server "${id}"`, "info");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.registerCommand("lsp-remove", {
|
|
177
|
+
description: "Remove or disable a language server",
|
|
178
|
+
handler: async (_args, ctx) => {
|
|
179
|
+
if (!ctx.hasUI) {
|
|
180
|
+
ctx.ui.notify("pi-lsp-lite: /lsp-remove requires interactive mode", "error");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (servers.length === 0) {
|
|
185
|
+
ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ids = servers.map((s) => s.id);
|
|
190
|
+
const selected = await ctx.ui.select("Remove which server?", ids);
|
|
191
|
+
if (!selected) return;
|
|
192
|
+
|
|
193
|
+
const confirmed = await ctx.ui.confirm("Confirm removal", `Disable server "${selected}"?`);
|
|
194
|
+
if (!confirmed) return;
|
|
195
|
+
|
|
196
|
+
await writeGlobalConfig({ servers: { [selected]: { disabled: true } } });
|
|
197
|
+
await initConfig(ctx.cwd);
|
|
198
|
+
ctx.ui.notify(`pi-lsp-lite: disabled server "${selected}"`, "info");
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.registerCommand("lsp-toggle", {
|
|
203
|
+
description: "Enable or disable a language server",
|
|
204
|
+
handler: async (_args, ctx) => {
|
|
205
|
+
if (!ctx.hasUI) {
|
|
206
|
+
ctx.ui.notify("pi-lsp-lite: /lsp-toggle requires interactive mode", "error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const builtinIds = new Set(builtinLanguages.map((l) => l.id));
|
|
211
|
+
const activeIds = new Set(servers.map((s) => s.id));
|
|
212
|
+
|
|
213
|
+
// include disabled user-added servers from global config so they can be re-enabled
|
|
214
|
+
const globalConfig = await readGlobalConfig();
|
|
215
|
+
const RESERVED = new Set(["__proto__", "constructor", "prototype"]);
|
|
216
|
+
const globalServerIds = (globalConfig?.servers && typeof globalConfig.servers === "object" && !Array.isArray(globalConfig.servers))
|
|
217
|
+
? Object.keys(globalConfig.servers).filter((k) => !RESERVED.has(k))
|
|
218
|
+
: [];
|
|
219
|
+
const allIds = new Set<string>([...builtinIds, ...activeIds, ...globalServerIds]);
|
|
220
|
+
|
|
221
|
+
if (allIds.size === 0) {
|
|
222
|
+
ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const entries = [...allIds];
|
|
227
|
+
const options = entries.map((id) => `${id} ${activeIds.has(id) ? "[enabled]" : "[disabled]"}`);
|
|
228
|
+
const choice = await ctx.ui.select("Toggle which server?", options);
|
|
229
|
+
if (!choice) return;
|
|
230
|
+
|
|
231
|
+
const idx = options.indexOf(choice);
|
|
232
|
+
const id = entries[idx];
|
|
233
|
+
const isCurrentlyEnabled = activeIds.has(id);
|
|
234
|
+
|
|
235
|
+
if (isCurrentlyEnabled) {
|
|
236
|
+
await writeGlobalConfig({ servers: { [id]: { disabled: true } } });
|
|
237
|
+
} else {
|
|
238
|
+
// re-enable: works for both built-ins and user-added servers in global config
|
|
239
|
+
await writeGlobalConfig({ servers: { [id]: { disabled: false } } });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await initConfig(ctx.cwd);
|
|
243
|
+
ctx.ui.notify(`pi-lsp-lite: ${isCurrentlyEnabled ? "disabled" : "enabled"} server "${id}"`, "info");
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
pi.registerCommand("lsp-install", {
|
|
248
|
+
description: "Install a missing language server binary",
|
|
249
|
+
handler: async (_args, ctx) => {
|
|
250
|
+
if (!ctx.hasUI) {
|
|
251
|
+
ctx.ui.notify("pi-lsp-lite: /lsp-install requires interactive mode", "error");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const checks = await Promise.all(
|
|
256
|
+
[...installRegistry].map(async ([id, entry]) => {
|
|
257
|
+
const lang = builtinLanguages.find((l) => l.id === id);
|
|
258
|
+
const binary = lang?.command ?? id;
|
|
259
|
+
const found = await which(binary);
|
|
260
|
+
return found ? null : { id, command: binary, installCmd: entry.command, description: entry.description };
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
const missing = checks.filter((c): c is NonNullable<typeof c> => c !== null);
|
|
264
|
+
|
|
265
|
+
if (missing.length === 0) {
|
|
266
|
+
ctx.ui.notify("pi-lsp-lite: all known servers are available", "info");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const options = missing.map((m) => `${m.id} — ${m.description} (${m.command})`);
|
|
271
|
+
const choice = await ctx.ui.select("Install which server?", options);
|
|
272
|
+
if (!choice) return;
|
|
273
|
+
|
|
274
|
+
const idx = options.indexOf(choice);
|
|
275
|
+
const selected = missing[idx];
|
|
276
|
+
|
|
277
|
+
const confirmed = await ctx.ui.confirm("Confirm install", `Run: ${selected.installCmd}`);
|
|
278
|
+
if (!confirmed) return;
|
|
279
|
+
|
|
280
|
+
const result = await pi.exec("sh", ["-c", selected.installCmd]);
|
|
281
|
+
if (result.code !== 0) {
|
|
282
|
+
ctx.ui.notify(`pi-lsp-lite: install failed (exit ${result.code})\n${result.stderr}`, "error");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await initConfig(ctx.cwd);
|
|
287
|
+
ctx.ui.notify(`pi-lsp-lite: installed ${selected.id}`, "info");
|
|
288
|
+
},
|
|
289
|
+
});
|
|
62
290
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lsp-lite",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "LSP diagnostics for pi — errors and warnings on every edit, same turn. Go, Rust, TypeScript, Python, C/C++.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"license": "MIT",
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
"lsp",
|
|
18
18
|
"gopls",
|
|
19
19
|
"rust-analyzer",
|
|
20
|
-
"typescript-language-server"
|
|
20
|
+
"typescript-language-server",
|
|
21
|
+
"pylsp",
|
|
22
|
+
"clangd",
|
|
23
|
+
"python",
|
|
24
|
+
"cpp"
|
|
21
25
|
],
|
|
22
26
|
"pi": {
|
|
23
27
|
"extensions": [
|
package/src/client.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface DiagnosticResult {
|
|
|
27
27
|
status: "ok" | "timeout" | "unavailable";
|
|
28
28
|
diagnostics: Diagnostic[];
|
|
29
29
|
otherFiles: OtherFileDiagnostics[];
|
|
30
|
+
retryAttempts: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface LspClient {
|
|
@@ -35,10 +36,12 @@ export interface LspClient {
|
|
|
35
36
|
didChange(uri: string, content: string): void;
|
|
36
37
|
didClose(uri: string): void;
|
|
37
38
|
waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult>;
|
|
39
|
+
getAllDiagnostics(): Map<string, Diagnostic[]>;
|
|
38
40
|
shutdown(): Promise<void>;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
44
|
+
const QUIESCENCE_MS = 200;
|
|
42
45
|
|
|
43
46
|
function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: number } {
|
|
44
47
|
let errors = 0;
|
|
@@ -70,21 +73,21 @@ export function createLspClient(child: ChildProcess): LspClient {
|
|
|
70
73
|
const diagnosticsMap = new Map<string, DiagnosticEntry>();
|
|
71
74
|
const documentVersion = new Map<string, number>();
|
|
72
75
|
const uriGeneration = new Map<string, number>();
|
|
76
|
+
let crossFileCallback: ((changedUri: string) => void) | null = null;
|
|
73
77
|
|
|
74
78
|
connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
|
|
75
79
|
const entry = diagnosticsMap.get(params.uri);
|
|
76
80
|
if (entry) {
|
|
77
|
-
// only accept diagnostics for the current generation of this URI
|
|
78
81
|
const currentGen = uriGeneration.get(params.uri) ?? 0;
|
|
79
82
|
if (entry.generation !== currentGen) return;
|
|
80
83
|
entry.diagnostics = params.diagnostics;
|
|
81
84
|
entry.received = true;
|
|
82
85
|
entry.resolve?.();
|
|
83
86
|
} else {
|
|
84
|
-
// cross-file diagnostics for URIs we haven't opened — accept them
|
|
85
87
|
const gen = uriGeneration.get(params.uri) ?? 0;
|
|
86
88
|
diagnosticsMap.set(params.uri, { diagnostics: params.diagnostics, generation: gen, received: true });
|
|
87
89
|
}
|
|
90
|
+
if (crossFileCallback) crossFileCallback(params.uri);
|
|
88
91
|
});
|
|
89
92
|
|
|
90
93
|
connection.listen();
|
|
@@ -137,7 +140,6 @@ export function createLspClient(child: ChildProcess): LspClient {
|
|
|
137
140
|
},
|
|
138
141
|
|
|
139
142
|
didClose(uri: string) {
|
|
140
|
-
// bump generation so any in-flight diagnostics for the old open are rejected
|
|
141
143
|
const gen = (uriGeneration.get(uri) ?? 0) + 1;
|
|
142
144
|
uriGeneration.set(uri, gen);
|
|
143
145
|
connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
@@ -150,7 +152,6 @@ export function createLspClient(child: ChildProcess): LspClient {
|
|
|
150
152
|
async waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult> {
|
|
151
153
|
const targetGen = uriGeneration.get(uri) ?? 0;
|
|
152
154
|
|
|
153
|
-
// snapshot diagnostic counts for all other tracked URIs before the edit settles
|
|
154
155
|
const preSnapshot = new Map<string, { errors: number; warnings: number }>();
|
|
155
156
|
for (const [trackedUri, entry] of diagnosticsMap) {
|
|
156
157
|
if (trackedUri !== uri) {
|
|
@@ -174,19 +175,26 @@ export function createLspClient(child: ChildProcess): LspClient {
|
|
|
174
175
|
};
|
|
175
176
|
|
|
176
177
|
return new Promise<DiagnosticResult>((resolve) => {
|
|
177
|
-
const SETTLE_MS = 50;
|
|
178
178
|
let settled = false;
|
|
179
|
+
let quiescenceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
179
180
|
|
|
180
181
|
const settle = (status: "ok" | "timeout") => {
|
|
181
182
|
if (settled) return;
|
|
182
183
|
settled = true;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
crossFileCallback = null;
|
|
185
|
+
if (quiescenceTimer) clearTimeout(quiescenceTimer);
|
|
186
|
+
resolve({
|
|
187
|
+
status,
|
|
188
|
+
diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
|
|
189
|
+
otherFiles: collectOtherFiles(),
|
|
190
|
+
retryAttempts: 0,
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const resetQuiescence = () => {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
if (quiescenceTimer) clearTimeout(quiescenceTimer);
|
|
197
|
+
quiescenceTimer = setTimeout(() => settle("ok"), QUIESCENCE_MS);
|
|
190
198
|
};
|
|
191
199
|
|
|
192
200
|
const timeout = setTimeout(() => {
|
|
@@ -194,19 +202,43 @@ export function createLspClient(child: ChildProcess): LspClient {
|
|
|
194
202
|
}, timeoutMs);
|
|
195
203
|
|
|
196
204
|
const entry = diagnosticsMap.get(uri) ?? { diagnostics: [], generation: targetGen, received: false };
|
|
197
|
-
|
|
205
|
+
|
|
206
|
+
entry.resolve = () => {
|
|
198
207
|
clearTimeout(timeout);
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
208
|
+
resetQuiescence();
|
|
209
|
+
};
|
|
210
|
+
diagnosticsMap.set(uri, entry);
|
|
211
|
+
|
|
212
|
+
// when a non-target URI publishes diagnostics that differ from the
|
|
213
|
+
// pre-snapshot, start quiescence — this catches the case where the
|
|
214
|
+
// edited file is valid but dependents break
|
|
215
|
+
crossFileCallback = (changedUri: string) => {
|
|
216
|
+
if (settled || changedUri === uri) return;
|
|
217
|
+
const pre = preSnapshot.get(changedUri) ?? { errors: 0, warnings: 0 };
|
|
218
|
+
const post = countDiagnostics(diagnosticsMap.get(changedUri)?.diagnostics ?? []);
|
|
219
|
+
if (post.errors !== pre.errors || post.warnings !== pre.warnings) {
|
|
202
220
|
clearTimeout(timeout);
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
221
|
+
resetQuiescence();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (entry.received) {
|
|
226
|
+
clearTimeout(timeout);
|
|
227
|
+
resetQuiescence();
|
|
206
228
|
}
|
|
207
229
|
});
|
|
208
230
|
},
|
|
209
231
|
|
|
232
|
+
getAllDiagnostics(): Map<string, Diagnostic[]> {
|
|
233
|
+
const result = new Map<string, Diagnostic[]>();
|
|
234
|
+
for (const [uri, entry] of diagnosticsMap) {
|
|
235
|
+
if (entry.diagnostics.length > 0) {
|
|
236
|
+
result.set(uri, [...entry.diagnostics]);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
},
|
|
241
|
+
|
|
210
242
|
async shutdown() {
|
|
211
243
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
212
244
|
try {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { type LanguageServerConfig, builtinLanguages } from "./languages.js";
|
|
6
|
+
|
|
7
|
+
export interface ServerConfigOverride {
|
|
8
|
+
extensions?: string[];
|
|
9
|
+
command?: string;
|
|
10
|
+
args?: string[];
|
|
11
|
+
rootPatterns?: string[];
|
|
12
|
+
diagnosticTimeout?: number;
|
|
13
|
+
maxRetries?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UserConfig {
|
|
18
|
+
servers?: Record<string, ServerConfigOverride>;
|
|
19
|
+
diagnosticTimeout?: number;
|
|
20
|
+
documentIdleTimeout?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResolvedConfig {
|
|
24
|
+
servers: LanguageServerConfig[];
|
|
25
|
+
diagnosticTimeout: number;
|
|
26
|
+
documentIdleTimeout: number;
|
|
27
|
+
perServerTimeout: Map<string, number>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_DIAGNOSTIC_TIMEOUT = 5_000;
|
|
31
|
+
export const DEFAULT_DOCUMENT_IDLE_TIMEOUT = 120_000;
|
|
32
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
33
|
+
|
|
34
|
+
const MIN_DIAGNOSTIC_TIMEOUT = 1_000;
|
|
35
|
+
const MAX_DIAGNOSTIC_TIMEOUT = 60_000;
|
|
36
|
+
const MIN_DOCUMENT_IDLE_TIMEOUT = 10_000;
|
|
37
|
+
const MAX_DOCUMENT_IDLE_TIMEOUT = 600_000;
|
|
38
|
+
const MIN_MAX_RETRIES = 0;
|
|
39
|
+
const MAX_MAX_RETRIES = 10;
|
|
40
|
+
|
|
41
|
+
function clamp(value: unknown, min: number, max: number, fallback: number): number {
|
|
42
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
43
|
+
return Math.max(min, Math.min(max, value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
47
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isStringArray(v: unknown): v is string[] {
|
|
51
|
+
return Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateOverride(id: string, raw: unknown): ServerConfigOverride | null {
|
|
55
|
+
if (!isPlainObject(raw)) return null;
|
|
56
|
+
|
|
57
|
+
const override: ServerConfigOverride = {};
|
|
58
|
+
|
|
59
|
+
if (raw.disabled === true) {
|
|
60
|
+
override.disabled = true;
|
|
61
|
+
return override;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (raw.extensions !== undefined) {
|
|
65
|
+
if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
|
|
66
|
+
console.error(`[pi-lsp-lite] config "${id}": extensions must be a non-empty string array, skipping`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
override.extensions = (raw.extensions as string[]).map((e) => e.toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (raw.command !== undefined) {
|
|
73
|
+
if (typeof raw.command !== "string" || raw.command.length === 0) {
|
|
74
|
+
console.error(`[pi-lsp-lite] config "${id}": command must be a non-empty string, skipping`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
override.command = raw.command as string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (raw.args !== undefined) {
|
|
81
|
+
if (!isStringArray(raw.args)) {
|
|
82
|
+
console.error(`[pi-lsp-lite] config "${id}": args must be a string array, skipping`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
override.args = raw.args as string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (raw.rootPatterns !== undefined) {
|
|
89
|
+
if (!isStringArray(raw.rootPatterns)) {
|
|
90
|
+
console.error(`[pi-lsp-lite] config "${id}": rootPatterns must be a string array, skipping`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
override.rootPatterns = raw.rootPatterns as string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (raw.diagnosticTimeout !== undefined) {
|
|
97
|
+
override.diagnosticTimeout = clamp(
|
|
98
|
+
raw.diagnosticTimeout,
|
|
99
|
+
MIN_DIAGNOSTIC_TIMEOUT,
|
|
100
|
+
MAX_DIAGNOSTIC_TIMEOUT,
|
|
101
|
+
DEFAULT_DIAGNOSTIC_TIMEOUT,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (raw.maxRetries !== undefined) {
|
|
106
|
+
override.maxRetries = clamp(
|
|
107
|
+
raw.maxRetries,
|
|
108
|
+
MIN_MAX_RETRIES,
|
|
109
|
+
MAX_MAX_RETRIES,
|
|
110
|
+
DEFAULT_MAX_RETRIES,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return override;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readConfigFile(path: string): Promise<UserConfig | null> {
|
|
118
|
+
let content: string;
|
|
119
|
+
try {
|
|
120
|
+
content = await readFile(path, "utf-8");
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
|
|
123
|
+
console.error(`[pi-lsp-lite] failed to read config ${path}:`, err);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const parsed: unknown = JSON.parse(content);
|
|
129
|
+
if (!isPlainObject(parsed)) {
|
|
130
|
+
console.error(`[pi-lsp-lite] config ${path}: expected a JSON object, skipping`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return parsed as UserConfig;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`[pi-lsp-lite] config ${path}: invalid JSON, skipping:`, err);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function findProjectConfig(cwd: string): Promise<UserConfig | null> {
|
|
141
|
+
for (const candidate of [
|
|
142
|
+
join(cwd, ".pi-lsp-lite.json"),
|
|
143
|
+
join(cwd, ".pi", "lsp-lite.json"),
|
|
144
|
+
]) {
|
|
145
|
+
const config = await readConfigFile(candidate);
|
|
146
|
+
if (config) return config;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type ConfigSource = "global" | "project";
|
|
152
|
+
|
|
153
|
+
function mergeConfigs(
|
|
154
|
+
base: LanguageServerConfig[],
|
|
155
|
+
overrides: Record<string, ServerConfigOverride>,
|
|
156
|
+
source: ConfigSource,
|
|
157
|
+
): LanguageServerConfig[] {
|
|
158
|
+
const result = new Map<string, LanguageServerConfig>();
|
|
159
|
+
|
|
160
|
+
for (const server of base) {
|
|
161
|
+
result.set(server.id, { ...server });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const [id, rawOverride] of Object.entries(overrides)) {
|
|
165
|
+
const override = validateOverride(id, rawOverride);
|
|
166
|
+
if (!override) continue;
|
|
167
|
+
|
|
168
|
+
if (override.disabled) {
|
|
169
|
+
result.delete(id);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const existing = result.get(id);
|
|
174
|
+
if (existing) {
|
|
175
|
+
const { disabled: _, diagnosticTimeout: __, ...lspFields } = override;
|
|
176
|
+
const defined = Object.fromEntries(
|
|
177
|
+
Object.entries(lspFields).filter(([, v]) => v !== undefined),
|
|
178
|
+
);
|
|
179
|
+
result.set(id, { ...existing, ...defined });
|
|
180
|
+
} else {
|
|
181
|
+
if (source === "project") {
|
|
182
|
+
console.error(`[pi-lsp-lite] project config cannot define new server "${id}" — only global config (~/.pi-lsp-lite.json) can add servers`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!override.command || !override.extensions) {
|
|
186
|
+
console.error(`[pi-lsp-lite] config "${id}" must have at least "command" and "extensions" to define a new server, skipping`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
result.set(id, {
|
|
190
|
+
id,
|
|
191
|
+
extensions: override.extensions,
|
|
192
|
+
command: override.command,
|
|
193
|
+
args: override.args ?? [],
|
|
194
|
+
rootPatterns: override.rootPatterns ?? [],
|
|
195
|
+
...(override.maxRetries !== undefined && { maxRetries: override.maxRetries }),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Array.from(result.values());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function globalConfigFilePath(globalConfigPath?: string): string {
|
|
204
|
+
return globalConfigPath ?? join(homedir(), ".pi-lsp-lite.json");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function readGlobalConfig(globalConfigPath?: string): Promise<UserConfig | null> {
|
|
208
|
+
return readConfigFile(globalConfigFilePath(globalConfigPath));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let writeLock = Promise.resolve();
|
|
212
|
+
|
|
213
|
+
export function writeGlobalConfig(config: UserConfig, globalConfigPath?: string): Promise<void> {
|
|
214
|
+
const op = writeLock.then(() => writeGlobalConfigInner(config, globalConfigPath));
|
|
215
|
+
writeLock = op.catch(() => {});
|
|
216
|
+
return op;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function writeGlobalConfigInner(config: UserConfig, globalConfigPath?: string): Promise<void> {
|
|
220
|
+
const filePath = globalConfigFilePath(globalConfigPath);
|
|
221
|
+
const existing = await readConfigFile(filePath);
|
|
222
|
+
const merged = deepMerge(
|
|
223
|
+
(existing ?? {}) as Record<string, unknown>,
|
|
224
|
+
config as Record<string, unknown>,
|
|
225
|
+
) as UserConfig;
|
|
226
|
+
const dir = dirname(filePath);
|
|
227
|
+
await mkdir(dir, { recursive: true });
|
|
228
|
+
const tmpPath = join(dir, `.tmp-${randomUUID()}`);
|
|
229
|
+
try {
|
|
230
|
+
await writeFile(tmpPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
231
|
+
await rename(tmpPath, filePath);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
await unlink(tmpPath).catch(() => {});
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
239
|
+
|
|
240
|
+
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
241
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
242
|
+
for (const key of Object.keys(target)) {
|
|
243
|
+
if (!RESERVED_KEYS.has(key)) result[key] = target[key];
|
|
244
|
+
}
|
|
245
|
+
for (const key of Object.keys(source)) {
|
|
246
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
247
|
+
const sv = source[key];
|
|
248
|
+
const tv = target[key];
|
|
249
|
+
if (sv === undefined) continue;
|
|
250
|
+
if (sv === null) {
|
|
251
|
+
delete result[key];
|
|
252
|
+
} else if (isPlainObject(sv) && isPlainObject(tv)) {
|
|
253
|
+
result[key] = deepMerge(tv, sv);
|
|
254
|
+
} else {
|
|
255
|
+
result[key] = sv;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function loadConfig(cwd: string, globalConfigPath?: string): Promise<ResolvedConfig> {
|
|
262
|
+
const globalConfig = await readConfigFile(globalConfigFilePath(globalConfigPath));
|
|
263
|
+
const projectConfig = await findProjectConfig(cwd);
|
|
264
|
+
|
|
265
|
+
let servers = [...builtinLanguages];
|
|
266
|
+
const perServerTimeout = new Map<string, number>();
|
|
267
|
+
let diagnosticTimeout = DEFAULT_DIAGNOSTIC_TIMEOUT;
|
|
268
|
+
let documentIdleTimeout = DEFAULT_DOCUMENT_IDLE_TIMEOUT;
|
|
269
|
+
|
|
270
|
+
const layers: [UserConfig | null, ConfigSource][] = [
|
|
271
|
+
[globalConfig, "global"],
|
|
272
|
+
[projectConfig, "project"],
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
for (const [layer, source] of layers) {
|
|
276
|
+
if (!layer) continue;
|
|
277
|
+
if (layer.servers && isPlainObject(layer.servers)) {
|
|
278
|
+
servers = mergeConfigs(servers, layer.servers as Record<string, ServerConfigOverride>, source);
|
|
279
|
+
for (const [id, rawOverride] of Object.entries(layer.servers)) {
|
|
280
|
+
const override = validateOverride(id, rawOverride);
|
|
281
|
+
if (override?.diagnosticTimeout !== undefined) {
|
|
282
|
+
perServerTimeout.set(id, override.diagnosticTimeout);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (layer.diagnosticTimeout !== undefined) {
|
|
287
|
+
diagnosticTimeout = clamp(layer.diagnosticTimeout, MIN_DIAGNOSTIC_TIMEOUT, MAX_DIAGNOSTIC_TIMEOUT, diagnosticTimeout);
|
|
288
|
+
}
|
|
289
|
+
if (layer.documentIdleTimeout !== undefined) {
|
|
290
|
+
documentIdleTimeout = clamp(layer.documentIdleTimeout, MIN_DOCUMENT_IDLE_TIMEOUT, MAX_DOCUMENT_IDLE_TIMEOUT, documentIdleTimeout);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { servers, diagnosticTimeout, documentIdleTimeout, perServerTimeout };
|
|
295
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -9,6 +9,10 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
|
|
|
9
9
|
if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length === 0) return "";
|
|
10
10
|
if (result.status === "unavailable") return "";
|
|
11
11
|
|
|
12
|
+
const retryNote = result.status === "timeout" && result.retryAttempts > 0
|
|
13
|
+
? ` after ${result.retryAttempts} ${result.retryAttempts === 1 ? "retry" : "retries"}`
|
|
14
|
+
: "";
|
|
15
|
+
|
|
12
16
|
if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length > 0) {
|
|
13
17
|
return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result)}`;
|
|
14
18
|
}
|
|
@@ -27,7 +31,7 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
|
|
|
27
31
|
const summary = [
|
|
28
32
|
errorCount > 0 ? `${errorCount} error${errorCount > 1 ? "s" : ""}` : "",
|
|
29
33
|
warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? "s" : ""}` : "",
|
|
30
|
-
result.status === "timeout" ?
|
|
34
|
+
result.status === "timeout" ? `timed out${retryNote}, may be incomplete` : "",
|
|
31
35
|
]
|
|
32
36
|
.filter(Boolean)
|
|
33
37
|
.join(", ");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface InstallEntry {
|
|
2
|
+
command: string;
|
|
3
|
+
description: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const installRegistry = new Map<string, InstallEntry>([
|
|
7
|
+
["go", {
|
|
8
|
+
command: "go install golang.org/x/tools/gopls@latest",
|
|
9
|
+
description: "Go language server",
|
|
10
|
+
}],
|
|
11
|
+
["rust", {
|
|
12
|
+
command: "rustup component add rust-analyzer",
|
|
13
|
+
description: "Rust language server",
|
|
14
|
+
}],
|
|
15
|
+
["typescript", {
|
|
16
|
+
command: "npm install -g typescript-language-server typescript",
|
|
17
|
+
description: "TypeScript/JavaScript language server",
|
|
18
|
+
}],
|
|
19
|
+
["python", {
|
|
20
|
+
command: "pip install python-lsp-server",
|
|
21
|
+
description: "Python language server",
|
|
22
|
+
}],
|
|
23
|
+
["cpp", {
|
|
24
|
+
command: "sudo apt-get install -y clangd || brew install llvm",
|
|
25
|
+
description: "C/C++ language server",
|
|
26
|
+
}],
|
|
27
|
+
]);
|
package/src/languages.ts
CHANGED
|
@@ -4,15 +4,18 @@ export interface LanguageServerConfig {
|
|
|
4
4
|
command: string;
|
|
5
5
|
args: string[];
|
|
6
6
|
rootPatterns: string[];
|
|
7
|
+
diagnosticTimeout?: number;
|
|
8
|
+
maxRetries?: number;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
export const
|
|
11
|
+
export const builtinLanguages: LanguageServerConfig[] = [
|
|
10
12
|
{
|
|
11
13
|
id: "go",
|
|
12
14
|
extensions: [".go"],
|
|
13
15
|
command: "gopls",
|
|
14
16
|
args: ["serve"],
|
|
15
17
|
rootPatterns: ["go.mod"],
|
|
18
|
+
diagnosticTimeout: 5_000,
|
|
16
19
|
},
|
|
17
20
|
{
|
|
18
21
|
id: "rust",
|
|
@@ -20,6 +23,7 @@ export const languages: LanguageServerConfig[] = [
|
|
|
20
23
|
command: "rust-analyzer",
|
|
21
24
|
args: [],
|
|
22
25
|
rootPatterns: ["Cargo.toml"],
|
|
26
|
+
diagnosticTimeout: 30_000,
|
|
23
27
|
},
|
|
24
28
|
{
|
|
25
29
|
id: "typescript",
|
|
@@ -27,18 +31,35 @@ export const languages: LanguageServerConfig[] = [
|
|
|
27
31
|
command: "typescript-language-server",
|
|
28
32
|
args: ["--stdio"],
|
|
29
33
|
rootPatterns: ["tsconfig.json", "package.json"],
|
|
34
|
+
diagnosticTimeout: 30_000,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "python",
|
|
38
|
+
extensions: [".py"],
|
|
39
|
+
command: "pylsp",
|
|
40
|
+
args: [],
|
|
41
|
+
rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt"],
|
|
42
|
+
diagnosticTimeout: 15_000,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "cpp",
|
|
46
|
+
extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"],
|
|
47
|
+
command: "clangd",
|
|
48
|
+
args: [],
|
|
49
|
+
rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
|
|
50
|
+
diagnosticTimeout: 15_000,
|
|
30
51
|
},
|
|
31
52
|
];
|
|
32
53
|
|
|
33
|
-
export function languageForFile(path: string): LanguageServerConfig | undefined {
|
|
54
|
+
export function languageForFile(path: string, configs: LanguageServerConfig[]): LanguageServerConfig | undefined {
|
|
34
55
|
const lower = path.toLowerCase();
|
|
35
|
-
return
|
|
56
|
+
return configs.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
export function checkExtensionOverlaps(): string[] {
|
|
59
|
+
export function checkExtensionOverlaps(configs: LanguageServerConfig[]): string[] {
|
|
39
60
|
const warnings: string[] = [];
|
|
40
61
|
const seen = new Map<string, string>();
|
|
41
|
-
for (const lang of
|
|
62
|
+
for (const lang of configs) {
|
|
42
63
|
for (const ext of lang.extensions) {
|
|
43
64
|
const existing = seen.get(ext);
|
|
44
65
|
if (existing) {
|
package/src/server-manager.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import { which, fileUri, findWorkspaceRoot } from "./util.js";
|
|
3
3
|
import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
|
|
4
4
|
import type { LanguageServerConfig } from "./languages.js";
|
|
5
|
+
import type { Diagnostic } from "vscode-languageserver-protocol";
|
|
6
|
+
import { DEFAULT_DIAGNOSTIC_TIMEOUT, DEFAULT_DOCUMENT_IDLE_TIMEOUT, DEFAULT_MAX_RETRIES } from "./config.js";
|
|
5
7
|
import { readFile } from "node:fs/promises";
|
|
6
8
|
|
|
7
9
|
interface ManagedServer {
|
|
@@ -20,6 +22,7 @@ interface ManagedServer {
|
|
|
20
22
|
export interface ServerManager {
|
|
21
23
|
handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult>;
|
|
22
24
|
status(): ServerStatus[];
|
|
25
|
+
getAllDiagnostics(): Map<string, Diagnostic[]>;
|
|
23
26
|
shutdownAll(): Promise<void>;
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -33,12 +36,24 @@ export interface ServerStatus {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
const IDLE_TIMEOUT_MS = 240_000;
|
|
36
|
-
const DIAGNOSTIC_TIMEOUT_MS = 5_000;
|
|
37
39
|
const INIT_TIMEOUT_MS = 10_000;
|
|
38
|
-
const DOCUMENT_IDLE_MS = 120_000;
|
|
39
40
|
const SWEEP_INTERVAL_MS = 60_000;
|
|
40
41
|
|
|
41
|
-
export
|
|
42
|
+
export interface ServerManagerOptions {
|
|
43
|
+
diagnosticTimeout?: number;
|
|
44
|
+
documentIdleTimeout?: number;
|
|
45
|
+
perServerTimeout?: Map<string, number>;
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RETRY_BASE_DELAY_MS = 500;
|
|
50
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
export function createServerManager(options: ServerManagerOptions = {}): ServerManager {
|
|
53
|
+
const diagnosticTimeout = options.diagnosticTimeout ?? DEFAULT_DIAGNOSTIC_TIMEOUT;
|
|
54
|
+
const documentIdleTimeout = options.documentIdleTimeout ?? DEFAULT_DOCUMENT_IDLE_TIMEOUT;
|
|
55
|
+
const perServerTimeout = options.perServerTimeout ?? new Map();
|
|
56
|
+
const defaultMaxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
42
57
|
const servers = new Map<string, ManagedServer>();
|
|
43
58
|
const pending = new Map<string, Promise<ManagedServer | null>>();
|
|
44
59
|
const disabledBinaries = new Set<string>();
|
|
@@ -51,7 +66,7 @@ export function createServerManager(): ServerManager {
|
|
|
51
66
|
const now = Date.now();
|
|
52
67
|
for (const server of servers.values()) {
|
|
53
68
|
const stale = [...server.openDocuments.entries()]
|
|
54
|
-
.filter(([, lastActive]) => now - lastActive >
|
|
69
|
+
.filter(([, lastActive]) => now - lastActive > documentIdleTimeout);
|
|
55
70
|
for (const [docUri] of stale) {
|
|
56
71
|
server.client.didClose(docUri);
|
|
57
72
|
server.openDocuments.delete(docUri);
|
|
@@ -156,7 +171,7 @@ export function createServerManager(): ServerManager {
|
|
|
156
171
|
idleTimer: null,
|
|
157
172
|
startTime: now,
|
|
158
173
|
lastActivity: now,
|
|
159
|
-
editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [] }),
|
|
174
|
+
editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [], retryAttempts: 0 }),
|
|
160
175
|
};
|
|
161
176
|
|
|
162
177
|
child.on("exit", () => {
|
|
@@ -189,11 +204,19 @@ export function createServerManager(): ServerManager {
|
|
|
189
204
|
return promise;
|
|
190
205
|
}
|
|
191
206
|
|
|
207
|
+
function getMaxRetries(config: LanguageServerConfig): number {
|
|
208
|
+
const raw = config.maxRetries ?? defaultMaxRetries;
|
|
209
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return defaultMaxRetries;
|
|
210
|
+
return Math.max(0, Math.min(10, Math.floor(raw)));
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
async function doEdit(server: ManagedServer, filePath: string): Promise<DiagnosticResult> {
|
|
193
214
|
resetIdleTimer(server);
|
|
194
215
|
|
|
195
216
|
const uri = fileUri(filePath);
|
|
196
217
|
const content = await readFile(filePath, "utf-8");
|
|
218
|
+
const timeout = perServerTimeout.get(server.config.id) ?? server.config.diagnosticTimeout ?? diagnosticTimeout;
|
|
219
|
+
const retries = getMaxRetries(server.config);
|
|
197
220
|
|
|
198
221
|
if (server.openDocuments.has(uri)) {
|
|
199
222
|
server.client.didChange(uri, content);
|
|
@@ -202,14 +225,33 @@ export function createServerManager(): ServerManager {
|
|
|
202
225
|
}
|
|
203
226
|
server.openDocuments.set(uri, Date.now());
|
|
204
227
|
|
|
205
|
-
|
|
228
|
+
let lastResult = await server.client.waitForDiagnostics(uri, timeout);
|
|
229
|
+
|
|
230
|
+
for (let attempt = 0; attempt < retries && lastResult.status === "timeout"; attempt++) {
|
|
231
|
+
resetIdleTimer(server);
|
|
232
|
+
const baseDelay = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
|
|
233
|
+
const jitter = baseDelay * Math.random() * 0.5;
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
|
|
235
|
+
|
|
236
|
+
server.client.didChange(uri, content);
|
|
237
|
+
server.openDocuments.set(uri, Date.now());
|
|
238
|
+
const result = await server.client.waitForDiagnostics(uri, timeout);
|
|
239
|
+
result.retryAttempts = attempt + 1;
|
|
240
|
+
|
|
241
|
+
if (result.status === "ok") {
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
lastResult = result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return lastResult;
|
|
206
248
|
}
|
|
207
249
|
|
|
208
250
|
return {
|
|
209
251
|
async handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult> {
|
|
210
252
|
const root = await findWorkspaceRoot(filePath, config.rootPatterns, cwd);
|
|
211
253
|
const server = await ensureServer(config, root);
|
|
212
|
-
if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [] };
|
|
254
|
+
if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [], retryAttempts: 0 };
|
|
213
255
|
|
|
214
256
|
// serialize edits per server to avoid concurrent waitForDiagnostics races
|
|
215
257
|
const result = server.editQueue.then(
|
|
@@ -231,6 +273,16 @@ export function createServerManager(): ServerManager {
|
|
|
231
273
|
}));
|
|
232
274
|
},
|
|
233
275
|
|
|
276
|
+
getAllDiagnostics(): Map<string, Diagnostic[]> {
|
|
277
|
+
const result = new Map<string, Diagnostic[]>();
|
|
278
|
+
for (const server of servers.values()) {
|
|
279
|
+
for (const [uri, diags] of server.client.getAllDiagnostics()) {
|
|
280
|
+
result.set(uri, diags);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
},
|
|
285
|
+
|
|
234
286
|
async shutdownAll() {
|
|
235
287
|
stopSweepTimer();
|
|
236
288
|
const shutdowns = Array.from(servers.values()).map((s) => shutdownServer(s));
|