@narumitw/pi-lsp 0.17.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -3
- package/package.json +1 -1
- package/src/adapters.ts +228 -2
- package/src/command.ts +73 -12
- package/src/lsp-client.ts +32 -7
- package/src/pi-lsp.ts +24 -8
- package/src/routes.ts +50 -11
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -36,7 +36,47 @@ pi -e ./extensions/pi-lsp
|
|
|
36
36
|
|
|
37
37
|
## ⚙️ Configuration
|
|
38
38
|
|
|
39
|
-
If no config is provided, pi-lsp ships
|
|
39
|
+
If no config is provided, pi-lsp ships a broad catalog of direct-command defaults. Servers are started only when matching files are requested. pi-lsp does not download language servers, so install the commands you need and make them available on `PATH`. During no-config diagnostics, unavailable default commands are filtered before workspace discovery; explicitly selected or custom-configured missing commands still report an error.
|
|
40
|
+
|
|
41
|
+
| Language or format | Default server | Startup command | Extensions |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| JavaScript, TypeScript, JSON, CSS, GraphQL, HTML, Vue, Astro, Svelte | `biome` | `biome lsp-proxy` | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.graphql`, `.gql`, `.html`, `.vue`, `.astro`, `.svelte`, and module variants |
|
|
44
|
+
| Python typing | `ty` | `ty server` | `.py`, `.pyi` |
|
|
45
|
+
| Python linting and fixes | `ruff` | `ruff server` | `.py`, `.pyi` |
|
|
46
|
+
| Rust | `rust-analyzer` | `rust-analyzer` | `.rs` |
|
|
47
|
+
| Go | `gopls` | `gopls` | `.go` |
|
|
48
|
+
| Ruby | `rubocop` | `rubocop --lsp` | `.rb`, `.rake`, `.gemspec`, `.ru` |
|
|
49
|
+
| Elixir | `elixir-ls` | `language_server.sh` (`language_server.bat` on Windows) | `.ex`, `.exs` |
|
|
50
|
+
| Zig | `zls` | `zls` | `.zig`, `.zon` |
|
|
51
|
+
| C# | `csharp` | `roslyn-language-server --stdio --autoLoadProjects` | `.cs`, `.csx` |
|
|
52
|
+
| F# | `fsharp` | `fsautocomplete` | `.fs`, `.fsi`, `.fsx`, `.fsscript` |
|
|
53
|
+
| Swift and Objective-C++ | `sourcekit-lsp` | `sourcekit-lsp` | `.swift`, `.mm` |
|
|
54
|
+
| C and C++ | `clangd` | `clangd --background-index --clang-tidy` | C/C++ source and header extensions |
|
|
55
|
+
| Java | `jdtls` | `jdtls` | `.java` |
|
|
56
|
+
| Kotlin | `kotlin-lsp` | `kotlin-lsp --stdio` | `.kt`, `.kts` |
|
|
57
|
+
| YAML | `yaml-language-server` | `yaml-language-server --stdio` | `.yaml`, `.yml` |
|
|
58
|
+
| Lua | `lua-language-server` | `lua-language-server` | `.lua` |
|
|
59
|
+
| PHP | `intelephense` | `intelephense --stdio` | `.php` |
|
|
60
|
+
| Prisma | `prisma` | `prisma-language-server --stdio` | `.prisma` |
|
|
61
|
+
| Dart | `dart` | `dart language-server` | `.dart` |
|
|
62
|
+
| OCaml | `ocaml-lsp` | `ocamllsp` | `.ml`, `.mli` |
|
|
63
|
+
| Bash | `bash-language-server` | `bash-language-server start` | `.sh`, `.bash` |
|
|
64
|
+
| Terraform | `terraform-ls` | `terraform-ls serve` | `.tf`, `.tfvars` |
|
|
65
|
+
| LaTeX and BibTeX | `texlab` | `texlab` | `.tex`, `.bib` |
|
|
66
|
+
| Gleam | `gleam` | `gleam lsp` | `.gleam` |
|
|
67
|
+
| Clojure | `clojure-lsp` | `clojure-lsp listen` | `.clj`, `.cljs`, `.cljc`, `.edn` |
|
|
68
|
+
| Nix | `nixd` | `nixd` | `.nix` |
|
|
69
|
+
| Typst | `tinymist` | `tinymist` | `.typ`, `.typc` |
|
|
70
|
+
| Haskell | `haskell-language-server` | `haskell-language-server-wrapper --lsp` | `.hs`, `.lhs` |
|
|
71
|
+
|
|
72
|
+
For example, install the Rust and Go servers with their official toolchains:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
rustup component add rust-analyzer rust-src
|
|
76
|
+
go install golang.org/x/tools/gopls@latest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Ensure the Go install directory (`$GOBIN` or `$(go env GOPATH)/bin`) is also on `PATH`.
|
|
40
80
|
|
|
41
81
|
Custom config can be supplied in one of these locations:
|
|
42
82
|
|
|
@@ -48,7 +88,7 @@ Custom config can be supplied in one of these locations:
|
|
|
48
88
|
|
|
49
89
|
Compatibility: a user-scoped legacy `lsp.json` is migrated automatically. A project-scoped legacy `.pi/lsp.json` remains readable with a warning but is not renamed automatically, so the extension never modifies a repository working tree. New paths take precedence when both names exist.
|
|
50
90
|
|
|
51
|
-
`pi-lsp.json`
|
|
91
|
+
Providing custom config replaces the default server map. The following `pi-lsp.json` example intentionally keeps five selected servers:
|
|
52
92
|
|
|
53
93
|
```json
|
|
54
94
|
{
|
|
@@ -76,6 +116,14 @@ Compatibility: a user-scoped legacy `lsp.json` is migrated automatically. A proj
|
|
|
76
116
|
".tsx",
|
|
77
117
|
".vue"
|
|
78
118
|
]
|
|
119
|
+
},
|
|
120
|
+
"rust-analyzer": {
|
|
121
|
+
"command": ["rust-analyzer"],
|
|
122
|
+
"extensions": [".rs"]
|
|
123
|
+
},
|
|
124
|
+
"gopls": {
|
|
125
|
+
"command": ["gopls"],
|
|
126
|
+
"extensions": [".go"]
|
|
79
127
|
}
|
|
80
128
|
}
|
|
81
129
|
```
|
|
@@ -94,7 +142,8 @@ Use `servers` when you need global pi-lsp options such as timeout:
|
|
|
94
142
|
},
|
|
95
143
|
"initialization": {
|
|
96
144
|
"settings": {}
|
|
97
|
-
}
|
|
145
|
+
},
|
|
146
|
+
"skipDirectories": ["generated"]
|
|
98
147
|
}
|
|
99
148
|
}
|
|
100
149
|
}
|
|
@@ -106,6 +155,7 @@ Each server entry supports:
|
|
|
106
155
|
- `extensions`: file extensions that should route to this server.
|
|
107
156
|
- `env`: extra environment variables for the LSP server process.
|
|
108
157
|
- `initialization`: LSP initialization options and workspace configuration values.
|
|
158
|
+
- `skipDirectories`: additional directory names to exclude from recursive discovery. Explicitly requested paths remain available.
|
|
109
159
|
|
|
110
160
|
Global options:
|
|
111
161
|
|
package/package.json
CHANGED
package/src/adapters.ts
CHANGED
|
@@ -31,6 +31,8 @@ const COMMON_SKIP_DIRECTORIES = new Set([
|
|
|
31
31
|
"dist",
|
|
32
32
|
"node_modules",
|
|
33
33
|
"out",
|
|
34
|
+
"target",
|
|
35
|
+
"vendor",
|
|
34
36
|
"venv",
|
|
35
37
|
]);
|
|
36
38
|
|
|
@@ -70,6 +72,150 @@ export const DEFAULT_SERVER_CONFIGS: InternalLspServer[] = [
|
|
|
70
72
|
command: ["ruff", "server"],
|
|
71
73
|
extensions: [".py", ".pyi"],
|
|
72
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: "rust-analyzer",
|
|
77
|
+
command: ["rust-analyzer"],
|
|
78
|
+
extensions: [".rs"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "gopls",
|
|
82
|
+
command: ["gopls"],
|
|
83
|
+
extensions: [".go"],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "rubocop",
|
|
87
|
+
command: ["rubocop", "--lsp"],
|
|
88
|
+
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "elixir-ls",
|
|
92
|
+
command: [process.platform === "win32" ? "language_server.bat" : "language_server.sh"],
|
|
93
|
+
extensions: [".ex", ".exs"],
|
|
94
|
+
skipDirectories: ["_build", "deps"],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "zls",
|
|
98
|
+
command: ["zls"],
|
|
99
|
+
extensions: [".zig", ".zon"],
|
|
100
|
+
skipDirectories: [".zig-cache", "zig-out"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "csharp",
|
|
104
|
+
command: ["roslyn-language-server", "--stdio", "--autoLoadProjects"],
|
|
105
|
+
extensions: [".cs", ".csx"],
|
|
106
|
+
skipDirectories: ["bin", "obj"],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "fsharp",
|
|
110
|
+
command: ["fsautocomplete"],
|
|
111
|
+
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
|
112
|
+
skipDirectories: ["bin", "obj"],
|
|
113
|
+
initialization: { AutomaticWorkspaceInit: true },
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "sourcekit-lsp",
|
|
117
|
+
command: ["sourcekit-lsp"],
|
|
118
|
+
extensions: [".swift", ".mm"],
|
|
119
|
+
skipDirectories: [".build", "DerivedData"],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "clangd",
|
|
123
|
+
command: ["clangd", "--background-index", "--clang-tidy"],
|
|
124
|
+
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
|
125
|
+
skipDirectories: ["build"],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "jdtls",
|
|
129
|
+
command: ["jdtls"],
|
|
130
|
+
extensions: [".java"],
|
|
131
|
+
skipDirectories: [".gradle", "build"],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "kotlin-lsp",
|
|
135
|
+
command: ["kotlin-lsp", "--stdio"],
|
|
136
|
+
extensions: [".kt", ".kts"],
|
|
137
|
+
skipDirectories: [".gradle", "build"],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "yaml-language-server",
|
|
141
|
+
command: ["yaml-language-server", "--stdio"],
|
|
142
|
+
extensions: [".yaml", ".yml"],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "lua-language-server",
|
|
146
|
+
command: ["lua-language-server"],
|
|
147
|
+
extensions: [".lua"],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "intelephense",
|
|
151
|
+
command: ["intelephense", "--stdio"],
|
|
152
|
+
extensions: [".php"],
|
|
153
|
+
initialization: { intelephense: { telemetry: { enabled: false } } },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "prisma",
|
|
157
|
+
command: ["prisma-language-server", "--stdio"],
|
|
158
|
+
extensions: [".prisma"],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "dart",
|
|
162
|
+
command: ["dart", "language-server"],
|
|
163
|
+
extensions: [".dart"],
|
|
164
|
+
skipDirectories: [".dart_tool", "build"],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "ocaml-lsp",
|
|
168
|
+
command: ["ocamllsp"],
|
|
169
|
+
extensions: [".ml", ".mli"],
|
|
170
|
+
skipDirectories: ["_build", "_opam"],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "bash-language-server",
|
|
174
|
+
command: ["bash-language-server", "start"],
|
|
175
|
+
extensions: [".sh", ".bash"],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "terraform-ls",
|
|
179
|
+
command: ["terraform-ls", "serve"],
|
|
180
|
+
extensions: [".tf", ".tfvars"],
|
|
181
|
+
skipDirectories: [".terraform"],
|
|
182
|
+
initialization: {
|
|
183
|
+
experimentalFeatures: { prefillRequiredFields: true },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "texlab",
|
|
188
|
+
command: ["texlab"],
|
|
189
|
+
extensions: [".tex", ".bib"],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "gleam",
|
|
193
|
+
command: ["gleam", "lsp"],
|
|
194
|
+
extensions: [".gleam"],
|
|
195
|
+
skipDirectories: ["build"],
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "clojure-lsp",
|
|
199
|
+
command: ["clojure-lsp", "listen"],
|
|
200
|
+
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
|
201
|
+
skipDirectories: [".cpcache"],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "nixd",
|
|
205
|
+
command: ["nixd"],
|
|
206
|
+
extensions: [".nix"],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "tinymist",
|
|
210
|
+
command: ["tinymist"],
|
|
211
|
+
extensions: [".typ", ".typc"],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "haskell-language-server",
|
|
215
|
+
command: ["haskell-language-server-wrapper", "--lsp"],
|
|
216
|
+
extensions: [".hs", ".lhs"],
|
|
217
|
+
skipDirectories: [".stack-work", "dist-newstyle"],
|
|
218
|
+
},
|
|
73
219
|
];
|
|
74
220
|
|
|
75
221
|
export function loadRuntime(cwd = process.cwd()) {
|
|
@@ -82,7 +228,11 @@ export function loadRuntime(cwd = process.cwd()) {
|
|
|
82
228
|
|
|
83
229
|
export function loadConfig(cwd = process.cwd()): LspConfig {
|
|
84
230
|
const configured = loadConfiguredConfig(cwd);
|
|
85
|
-
return
|
|
231
|
+
return (
|
|
232
|
+
configured ?? {
|
|
233
|
+
servers: DEFAULT_SERVER_CONFIGS.map((server) => ({ ...server, isDefault: true })),
|
|
234
|
+
}
|
|
235
|
+
);
|
|
86
236
|
}
|
|
87
237
|
|
|
88
238
|
let pendingConfigNotice: string | undefined;
|
|
@@ -268,6 +418,7 @@ function normalizeServer(name: string, value: unknown, label: string): InternalL
|
|
|
268
418
|
extensions,
|
|
269
419
|
env: optionalStringRecordField(value, "env", label),
|
|
270
420
|
initialization: optionalRecordField(value, "initialization", label),
|
|
421
|
+
skipDirectories: optionalDirectoryNamesField(value, "skipDirectories", label),
|
|
271
422
|
};
|
|
272
423
|
}
|
|
273
424
|
|
|
@@ -285,13 +436,14 @@ function configToAdapter(config: InternalLspServer): LspServerAdapter {
|
|
|
285
436
|
if (!command) throw new Error(`${config.name}.command must contain at least one string.`);
|
|
286
437
|
return {
|
|
287
438
|
name: config.name,
|
|
439
|
+
isDefault: config.isDefault ?? false,
|
|
288
440
|
defaultCommand: { command, args },
|
|
289
441
|
commandEnvVar: envName(config.name, "COMMAND"),
|
|
290
442
|
missingCommandHint: `Install ${config.name} or set ${envName(config.name, "COMMAND")}.`,
|
|
291
443
|
extensions: config.extensions,
|
|
292
444
|
env: config.env,
|
|
293
445
|
initialization: config.initialization,
|
|
294
|
-
skipDirectories: COMMON_SKIP_DIRECTORIES,
|
|
446
|
+
skipDirectories: new Set([...COMMON_SKIP_DIRECTORIES, ...(config.skipDirectories ?? [])]),
|
|
295
447
|
isSupportedFile: (filePath) => extensionSet.has(path.extname(filePath)),
|
|
296
448
|
languageIdFor: (filePath) => languageIdFor(config, filePath),
|
|
297
449
|
};
|
|
@@ -303,18 +455,73 @@ function languageIdFor(_config: InternalLspServer, filePath: string) {
|
|
|
303
455
|
}
|
|
304
456
|
|
|
305
457
|
const LANGUAGE_IDS: Record<string, string> = {
|
|
458
|
+
".bash": "shellscript",
|
|
459
|
+
".bib": "bibtex",
|
|
460
|
+
".c": "c",
|
|
461
|
+
".c++": "cpp",
|
|
462
|
+
".cc": "cpp",
|
|
306
463
|
".cjs": "javascript",
|
|
464
|
+
".clj": "clojure",
|
|
465
|
+
".cljc": "clojure",
|
|
466
|
+
".cljs": "clojure",
|
|
467
|
+
".cpp": "cpp",
|
|
468
|
+
".cs": "csharp",
|
|
469
|
+
".csx": "csharp",
|
|
307
470
|
".cts": "typescript",
|
|
471
|
+
".cxx": "cpp",
|
|
472
|
+
".dart": "dart",
|
|
473
|
+
".edn": "clojure",
|
|
474
|
+
".ex": "elixir",
|
|
475
|
+
".exs": "elixir",
|
|
476
|
+
".fs": "fsharp",
|
|
477
|
+
".fsi": "fsharp",
|
|
478
|
+
".fsscript": "fsharp",
|
|
479
|
+
".fsx": "fsharp",
|
|
480
|
+
".gemspec": "ruby",
|
|
481
|
+
".go": "go",
|
|
308
482
|
".gql": "graphql",
|
|
483
|
+
".h": "c",
|
|
484
|
+
".h++": "cpp",
|
|
485
|
+
".hh": "cpp",
|
|
486
|
+
".hpp": "cpp",
|
|
487
|
+
".hs": "haskell",
|
|
488
|
+
".hxx": "cpp",
|
|
489
|
+
".jl": "julia",
|
|
309
490
|
".js": "javascript",
|
|
310
491
|
".jsx": "javascriptreact",
|
|
311
492
|
".jsonc": "jsonc",
|
|
493
|
+
".ksh": "shellscript",
|
|
494
|
+
".kt": "kotlin",
|
|
495
|
+
".kts": "kotlin",
|
|
496
|
+
".lhs": "lhaskell",
|
|
497
|
+
".m": "objective-c",
|
|
312
498
|
".mjs": "javascript",
|
|
499
|
+
".ml": "ocaml",
|
|
500
|
+
".mli": "ocaml.interface",
|
|
501
|
+
".mm": "objective-cpp",
|
|
313
502
|
".mts": "typescript",
|
|
503
|
+
".nix": "nix",
|
|
504
|
+
".php": "php",
|
|
314
505
|
".py": "python",
|
|
315
506
|
".pyi": "python",
|
|
507
|
+
".rake": "ruby",
|
|
508
|
+
".rb": "ruby",
|
|
509
|
+
".rs": "rust",
|
|
510
|
+
".ru": "ruby",
|
|
511
|
+
".sh": "shellscript",
|
|
512
|
+
".swift": "swift",
|
|
513
|
+
".tex": "latex",
|
|
514
|
+
".tf": "terraform",
|
|
515
|
+
".tfvars": "terraform-vars",
|
|
316
516
|
".ts": "typescript",
|
|
317
517
|
".tsx": "typescriptreact",
|
|
518
|
+
".typ": "typst",
|
|
519
|
+
".typc": "typst-code",
|
|
520
|
+
".yaml": "yaml",
|
|
521
|
+
".yml": "yaml",
|
|
522
|
+
".zig": "zig",
|
|
523
|
+
".zon": "zig",
|
|
524
|
+
".zsh": "shellscript",
|
|
318
525
|
};
|
|
319
526
|
|
|
320
527
|
function commandFromEnvName(name: string): string {
|
|
@@ -361,6 +568,25 @@ function optionalRecordField(value: Record<string, unknown>, field: string, labe
|
|
|
361
568
|
return fieldValue;
|
|
362
569
|
}
|
|
363
570
|
|
|
571
|
+
function optionalDirectoryNamesField(value: Record<string, unknown>, field: string, label: string) {
|
|
572
|
+
const fieldValue = value[field];
|
|
573
|
+
if (fieldValue === undefined) return undefined;
|
|
574
|
+
if (!Array.isArray(fieldValue) || !fieldValue.every((item) => typeof item === "string")) {
|
|
575
|
+
throw new Error(`${label}.${field} must be an array of directory names.`);
|
|
576
|
+
}
|
|
577
|
+
const names = fieldValue.map((item) => item.trim());
|
|
578
|
+
if (
|
|
579
|
+
names.some(
|
|
580
|
+
(name) => !name || name === "." || name === ".." || name.includes("/") || name.includes("\\"),
|
|
581
|
+
)
|
|
582
|
+
) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`${label}.${field} must contain non-empty directory names without path separators.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return [...new Set(names)];
|
|
588
|
+
}
|
|
589
|
+
|
|
364
590
|
function expandHome(filePath: string) {
|
|
365
591
|
if (filePath === "~") return os.homedir();
|
|
366
592
|
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
package/src/command.ts
CHANGED
|
@@ -13,28 +13,89 @@ export function commandFromEnv(envVar: string, fallback: ServerCommand): ServerC
|
|
|
13
13
|
return fallback;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function commandExists(
|
|
16
|
+
export function commandExists(
|
|
17
|
+
command: string,
|
|
18
|
+
cwd = process.cwd(),
|
|
19
|
+
pathValue = process.env.PATH ?? "",
|
|
20
|
+
) {
|
|
21
|
+
return resolveCommandPath(command, cwd, process.platform, pathValue) !== undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commandPathValue(
|
|
25
|
+
env: Record<string, string> | undefined,
|
|
26
|
+
platform: NodeJS.Platform = process.platform,
|
|
27
|
+
) {
|
|
28
|
+
return (
|
|
29
|
+
environmentValue(env, "PATH", platform) ?? environmentValue(process.env, "PATH", platform) ?? ""
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mergeEnvironment(
|
|
34
|
+
overrides: Record<string, string> | undefined,
|
|
35
|
+
platform: NodeJS.Platform = process.platform,
|
|
36
|
+
) {
|
|
37
|
+
const environment: NodeJS.ProcessEnv = { ...process.env };
|
|
38
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
for (const existingKey of Object.keys(environment)) {
|
|
41
|
+
if (existingKey.toLowerCase() === key.toLowerCase()) delete environment[existingKey];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
environment[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return environment;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function environmentValue(
|
|
50
|
+
environment: NodeJS.ProcessEnv | Record<string, string> | undefined,
|
|
51
|
+
name: string,
|
|
52
|
+
platform: NodeJS.Platform,
|
|
53
|
+
) {
|
|
54
|
+
if (platform !== "win32") return environment?.[name];
|
|
55
|
+
let value: string | undefined;
|
|
56
|
+
for (const [key, candidate] of Object.entries(environment ?? {})) {
|
|
57
|
+
if (key.toLowerCase() === name.toLowerCase()) value = candidate;
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveCommandPath(
|
|
63
|
+
command: string,
|
|
64
|
+
cwd = process.cwd(),
|
|
65
|
+
platform: NodeJS.Platform = process.platform,
|
|
66
|
+
pathValue = process.env.PATH ?? "",
|
|
67
|
+
) {
|
|
68
|
+
const extensions = platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
17
69
|
if (command.includes("/") || command.includes("\\")) {
|
|
18
|
-
|
|
70
|
+
const commandPath = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
|
71
|
+
return resolveRunnableFile(commandPath, extensions, platform);
|
|
19
72
|
}
|
|
20
73
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
74
|
+
for (const directory of pathValue.split(platform === "win32" ? ";" : ":")) {
|
|
75
|
+
const resolved = resolveRunnableFile(
|
|
76
|
+
path.resolve(cwd, directory || ".", command),
|
|
77
|
+
extensions,
|
|
78
|
+
platform,
|
|
79
|
+
);
|
|
80
|
+
if (resolved) return resolved;
|
|
28
81
|
}
|
|
29
82
|
|
|
30
|
-
return
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveRunnableFile(filePath: string, extensions: string[], platform: NodeJS.Platform) {
|
|
87
|
+
for (const extension of extensions) {
|
|
88
|
+
const candidate = `${filePath}${extension}`;
|
|
89
|
+
if (isRunnableFile(candidate, platform)) return candidate;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
31
92
|
}
|
|
32
93
|
|
|
33
|
-
function isRunnableFile(filePath: string) {
|
|
94
|
+
function isRunnableFile(filePath: string, platform: NodeJS.Platform) {
|
|
34
95
|
if (!existsSync(filePath)) return false;
|
|
35
96
|
try {
|
|
36
97
|
if (!statSync(filePath).isFile()) return false;
|
|
37
|
-
if (
|
|
98
|
+
if (platform !== "win32") accessSync(filePath, constants.X_OK);
|
|
38
99
|
return true;
|
|
39
100
|
} catch {
|
|
40
101
|
return false;
|
package/src/lsp-client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import { commandPathValue, mergeEnvironment, resolveCommandPath } from "./command.js";
|
|
5
5
|
import { directoryUri } from "./files.js";
|
|
6
6
|
import { positionAt } from "./text-edits.js";
|
|
7
7
|
import type {
|
|
@@ -12,6 +12,18 @@ import type {
|
|
|
12
12
|
ServerCommand,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
+
export function resolveSpawnCommand(
|
|
16
|
+
command: ServerCommand,
|
|
17
|
+
platform: NodeJS.Platform = process.platform,
|
|
18
|
+
comSpec = process.env.ComSpec,
|
|
19
|
+
): ServerCommand {
|
|
20
|
+
if (platform !== "win32" || !/\.(?:bat|cmd)$/i.test(command.command)) return command;
|
|
21
|
+
return {
|
|
22
|
+
command: comSpec?.trim() || "cmd.exe",
|
|
23
|
+
args: ["/d", "/s", "/c", command.command, ...command.args],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export class LspClient {
|
|
16
28
|
#child?: ChildProcessWithoutNullStreams;
|
|
17
29
|
#buffer = Buffer.alloc(0);
|
|
@@ -47,15 +59,22 @@ export class LspClient {
|
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
async start() {
|
|
50
|
-
|
|
62
|
+
const commandPath = resolveCommandPath(
|
|
63
|
+
this.#command.command,
|
|
64
|
+
this.#cwd,
|
|
65
|
+
process.platform,
|
|
66
|
+
commandPathValue(this.#adapter.env),
|
|
67
|
+
);
|
|
68
|
+
if (!commandPath) {
|
|
51
69
|
throw new Error(
|
|
52
70
|
`${this.#adapter.name} LSP command not found: ${this.#command.command}. ${this.#adapter.missingCommandHint}`,
|
|
53
71
|
);
|
|
54
72
|
}
|
|
55
73
|
|
|
56
|
-
const
|
|
74
|
+
const spawnCommand = resolveSpawnCommand({ ...this.#command, command: commandPath });
|
|
75
|
+
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
|
57
76
|
cwd: this.#cwd,
|
|
58
|
-
env:
|
|
77
|
+
env: mergeEnvironment(this.#adapter.env),
|
|
59
78
|
stdio: "pipe",
|
|
60
79
|
});
|
|
61
80
|
this.#child = child;
|
|
@@ -233,7 +252,9 @@ export class LspClient {
|
|
|
233
252
|
const timeout = setTimeout(() => {
|
|
234
253
|
this.#pending.delete(id);
|
|
235
254
|
reject(
|
|
236
|
-
new Error(
|
|
255
|
+
new Error(
|
|
256
|
+
`${this.#adapter.name} LSP request timed out: ${method}.${this.#formatStderr()}`,
|
|
257
|
+
),
|
|
237
258
|
);
|
|
238
259
|
}, this.#timeoutMs);
|
|
239
260
|
this.#pending.set(id, { resolve, reject, timeout });
|
|
@@ -334,7 +355,8 @@ export class LspClient {
|
|
|
334
355
|
resolve,
|
|
335
356
|
reject,
|
|
336
357
|
timeout: setTimeout(() => {
|
|
337
|
-
const waiters =
|
|
358
|
+
const waiters =
|
|
359
|
+
this.#diagnosticWaiters.get(uri)?.filter((entry) => entry !== waiter) ?? [];
|
|
338
360
|
if (waiters.length) this.#diagnosticWaiters.set(uri, waiters);
|
|
339
361
|
else this.#diagnosticWaiters.delete(uri);
|
|
340
362
|
reject(
|
|
@@ -396,7 +418,10 @@ export class LspClient {
|
|
|
396
418
|
}
|
|
397
419
|
|
|
398
420
|
function isUnsupportedMethodError(error: unknown) {
|
|
399
|
-
return
|
|
421
|
+
return (
|
|
422
|
+
error instanceof Error &&
|
|
423
|
+
/method not found|unhandled method|not supported|unsupported/i.test(error.message)
|
|
424
|
+
);
|
|
400
425
|
}
|
|
401
426
|
|
|
402
427
|
function formatErrorMessage(error: unknown) {
|
package/src/pi-lsp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { consumeLspConfigNotice, loadRuntime } from "./adapters.js";
|
|
4
|
-
import { commandExists, commandFromEnv } from "./command.js";
|
|
4
|
+
import { commandExists, commandFromEnv, commandPathValue } from "./command.js";
|
|
5
5
|
import { resolveRoot } from "./files.js";
|
|
6
6
|
import { selectDiagnosticRoutes, selectFixRoute } from "./routes.js";
|
|
7
7
|
import { DEFAULT_FILE_LIMIT, runDiagnostics, runFix, textResult } from "./runner.js";
|
|
@@ -60,7 +60,7 @@ const lspDiagnosticsTool = defineTool({
|
|
|
60
60
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
61
61
|
const requestedRoot = resolveRoot(params.root);
|
|
62
62
|
const { adapters, timeoutMs } = loadRuntime(requestedRoot);
|
|
63
|
-
const { root, routes } = selectDiagnosticRoutes(
|
|
63
|
+
const { root, routes, skipped } = selectDiagnosticRoutes(
|
|
64
64
|
adapters,
|
|
65
65
|
{ ...params, root: requestedRoot },
|
|
66
66
|
DEFAULT_FILE_LIMIT,
|
|
@@ -78,11 +78,23 @@ const lspDiagnosticsTool = defineTool({
|
|
|
78
78
|
results.push({ route, result });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const sections = results.map(
|
|
82
|
+
({ route, result }) => `${route.reason}\n\n${textFromResult(result)}`,
|
|
83
|
+
);
|
|
84
|
+
if (skipped.length) {
|
|
85
|
+
sections.push(
|
|
86
|
+
`Skipped unavailable default LSP server(s): ${skipped
|
|
87
|
+
.map((route) => route.adapter.name)
|
|
88
|
+
.join(", ")}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return textResult(sections.join("\n\n---\n\n"), {
|
|
85
92
|
root,
|
|
93
|
+
skipped: skipped.map((route) => ({
|
|
94
|
+
server: route.adapter.name,
|
|
95
|
+
reason: route.reason,
|
|
96
|
+
files: route.files,
|
|
97
|
+
})),
|
|
86
98
|
routes: results.map(({ route, result }) => ({
|
|
87
99
|
server: route.adapter.name,
|
|
88
100
|
backend: route.adapter.name,
|
|
@@ -174,7 +186,11 @@ function buildStatusMessage(adapters: ReturnType<typeof loadRuntime>["adapters"]
|
|
|
174
186
|
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
175
187
|
return [
|
|
176
188
|
`${adapter.name} LSP command: ${command.command} ${command.args.join(" ")}`.trim(),
|
|
177
|
-
`${adapter.name} status: ${
|
|
189
|
+
`${adapter.name} status: ${
|
|
190
|
+
commandExists(command.command, cwd, commandPathValue(adapter.env))
|
|
191
|
+
? "ready"
|
|
192
|
+
: "command missing"
|
|
193
|
+
}`,
|
|
178
194
|
];
|
|
179
195
|
})
|
|
180
196
|
.join("\n");
|
|
@@ -183,7 +199,7 @@ function buildStatusMessage(adapters: ReturnType<typeof loadRuntime>["adapters"]
|
|
|
183
199
|
function statusLevel(adapters: ReturnType<typeof loadRuntime>["adapters"], cwd: string) {
|
|
184
200
|
return adapters.every((adapter) => {
|
|
185
201
|
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
186
|
-
return commandExists(command.command, cwd);
|
|
202
|
+
return commandExists(command.command, cwd, commandPathValue(adapter.env));
|
|
187
203
|
})
|
|
188
204
|
? "info"
|
|
189
205
|
: "warning";
|
package/src/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { commandExists, commandFromEnv, commandPathValue } from "./command.js";
|
|
2
3
|
import { collectSupportedFiles, resolveRoot } from "./files.js";
|
|
3
4
|
import type { LspServerAdapter } from "./types.js";
|
|
4
5
|
|
|
@@ -38,14 +39,33 @@ export function selectDiagnosticRoutes(
|
|
|
38
39
|
) {
|
|
39
40
|
const root = resolveRoot(params.root);
|
|
40
41
|
const candidates = filterAdapters(adapters, params.server);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
42
|
+
const skipped: DiagnosticRoute[] = [];
|
|
43
|
+
const runnableCandidates = params.server
|
|
44
|
+
? candidates
|
|
45
|
+
: candidates.filter((adapter) => {
|
|
46
|
+
if (!adapter.isDefault) return true;
|
|
47
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
48
|
+
if (commandExists(command.command, root, commandPathValue(adapter.env))) return true;
|
|
49
|
+
skipped.push({ adapter, reason: `${adapter.name} command missing`, files: [] });
|
|
50
|
+
return false;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (runnableCandidates.length === 0 && skipped.length > 0) {
|
|
54
|
+
const names = skipped.map((route) => route.adapter.name).join(", ");
|
|
55
|
+
throw new Error(
|
|
56
|
+
`No available default LSP commands: ${names}. ` +
|
|
57
|
+
"Install a matching server command or explicitly select a configured server.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filesByPolicy = new Map<string, string[]>();
|
|
62
|
+
const routes = runnableCandidates
|
|
43
63
|
.map((adapter) => {
|
|
44
|
-
const key = adapter
|
|
45
|
-
let files =
|
|
64
|
+
const key = diagnosticFilePolicyKey(adapter);
|
|
65
|
+
let files = filesByPolicy.get(key);
|
|
46
66
|
if (!files) {
|
|
47
67
|
files = collectSupportedFiles(adapter, root, params.paths, params.limit ?? defaultLimit);
|
|
48
|
-
|
|
68
|
+
filesByPolicy.set(key, files);
|
|
49
69
|
}
|
|
50
70
|
return { adapter, reason: `${adapter.name} diagnostics`, files };
|
|
51
71
|
})
|
|
@@ -53,16 +73,26 @@ export function selectDiagnosticRoutes(
|
|
|
53
73
|
|
|
54
74
|
if (routes.length === 0) {
|
|
55
75
|
const scope = params.paths?.length ? ` in requested paths: ${params.paths.join(", ")}` : "";
|
|
76
|
+
if (skipped.length > 0) {
|
|
77
|
+
const names = skipped.map((route) => route.adapter.name).join(", ");
|
|
78
|
+
throw new Error(
|
|
79
|
+
`No supported files found for available LSP commands${scope}. ` +
|
|
80
|
+
`Skipped unavailable default LSP commands: ${names}. ` +
|
|
81
|
+
"Install a matching server command or explicitly select a configured server.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
56
84
|
throw new Error(`No supported files found${scope}. ${SUPPORTED_SERVER_DESCRIPTION}`);
|
|
57
85
|
}
|
|
58
86
|
|
|
59
|
-
return { root, routes };
|
|
87
|
+
return { root, routes, skipped };
|
|
60
88
|
}
|
|
61
89
|
|
|
62
90
|
export function selectFixRoute(adapters: LspServerAdapter[], params: SingleFileRouteParams) {
|
|
63
91
|
const root = resolveRoot(params.root);
|
|
64
92
|
const file = path.resolve(root, params.path);
|
|
65
|
-
const candidates = filterAdapters(adapters, params.server).filter((adapter) =>
|
|
93
|
+
const candidates = filterAdapters(adapters, params.server).filter((adapter) =>
|
|
94
|
+
adapter.isSupportedFile(file),
|
|
95
|
+
);
|
|
66
96
|
if (candidates.length === 0) throw unsupportedFileError("fix", params.path, params.server);
|
|
67
97
|
if (!params.server && candidates.length > 1) {
|
|
68
98
|
throw new Error(
|
|
@@ -80,11 +110,18 @@ export function selectFixRoute(adapters: LspServerAdapter[], params: SingleFileR
|
|
|
80
110
|
};
|
|
81
111
|
}
|
|
82
112
|
|
|
113
|
+
function diagnosticFilePolicyKey(adapter: LspServerAdapter) {
|
|
114
|
+
return JSON.stringify([
|
|
115
|
+
adapter.extensions,
|
|
116
|
+
[...adapter.skipDirectories].sort((left, right) => left.localeCompare(right)),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
function filterAdapters(adapters: LspServerAdapter[], selected: string | string[] | undefined) {
|
|
84
121
|
if (!selected) return adapters;
|
|
85
|
-
const names = [
|
|
86
|
-
(name) => name.
|
|
87
|
-
);
|
|
122
|
+
const names = [
|
|
123
|
+
...new Set((Array.isArray(selected) ? selected : [selected]).map((name) => name.trim())),
|
|
124
|
+
].filter((name) => name.length > 0);
|
|
88
125
|
if (names.length === 0) throw new Error("LSP server parameter must not be blank.");
|
|
89
126
|
const matched = adapters.filter((adapter) => names.includes(adapter.name));
|
|
90
127
|
const missing = names.filter((name) => !adapters.some((adapter) => adapter.name === name));
|
|
@@ -94,5 +131,7 @@ function filterAdapters(adapters: LspServerAdapter[], selected: string | string[
|
|
|
94
131
|
|
|
95
132
|
function unsupportedFileError(action: LspAction, filePath: string, server: string | undefined) {
|
|
96
133
|
const override = server ? ` for server '${server}'` : "";
|
|
97
|
-
return new Error(
|
|
134
|
+
return new Error(
|
|
135
|
+
`No ${action} route supports ${filePath}${override}. ${SUPPORTED_SERVER_DESCRIPTION}`,
|
|
136
|
+
);
|
|
98
137
|
}
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface ConfiguredLspServer {
|
|
|
66
66
|
extensions: string[];
|
|
67
67
|
env?: Record<string, string>;
|
|
68
68
|
initialization?: Record<string, unknown>;
|
|
69
|
+
skipDirectories?: string[];
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export interface LspConfig {
|
|
@@ -75,10 +76,12 @@ export interface LspConfig {
|
|
|
75
76
|
|
|
76
77
|
export interface InternalLspServer extends ConfiguredLspServer {
|
|
77
78
|
name: string;
|
|
79
|
+
isDefault?: boolean;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export interface LspServerAdapter {
|
|
81
83
|
name: string;
|
|
84
|
+
isDefault: boolean;
|
|
82
85
|
defaultCommand: ServerCommand;
|
|
83
86
|
commandEnvVar: string;
|
|
84
87
|
missingCommandHint: string;
|