@narumitw/pi-lsp 0.18.0 → 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 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 compatible defaults for Biome, ty, and Ruff.
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` can be a plain object keyed by server name:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-lsp",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Pi extension that exposes configurable, language-agnostic LSP tools through a shared runner.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 configured ?? { servers: DEFAULT_SERVER_CONFIGS };
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(command: string, cwd = process.cwd()) {
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
- return isRunnableFile(path.isAbsolute(command) ? command : path.resolve(cwd, command));
70
+ const commandPath = path.isAbsolute(command) ? command : path.resolve(cwd, command);
71
+ return resolveRunnableFile(commandPath, extensions, platform);
19
72
  }
20
73
 
21
- const pathValue = process.env.PATH ?? "";
22
- const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
23
- for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
24
- if (!directory) continue;
25
- for (const extension of extensions) {
26
- if (isRunnableFile(path.join(directory, `${command}${extension}`))) return true;
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 false;
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 (process.platform !== "win32") accessSync(filePath, constants.X_OK);
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 { commandExists } from "./command.js";
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
- if (!commandExists(this.#command.command, this.#cwd)) {
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 child = spawn(this.#command.command, this.#command.args, {
74
+ const spawnCommand = resolveSpawnCommand({ ...this.#command, command: commandPath });
75
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
57
76
  cwd: this.#cwd,
58
- env: { ...process.env, ...this.#adapter.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(`${this.#adapter.name} LSP request timed out: ${method}.${this.#formatStderr()}`),
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 = this.#diagnosticWaiters.get(uri)?.filter((entry) => entry !== waiter) ?? [];
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 error instanceof Error && /method not found|unhandled method|not supported|unsupported/i.test(error.message);
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 text = results
82
- .map(({ route, result }) => `${route.reason}\n\n${textFromResult(result)}`)
83
- .join("\n\n---\n\n");
84
- return textResult(text, {
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: ${commandExists(command.command, cwd) ? "ready" : "command missing"}`,
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 filesByExtensions = new Map<string, string[]>();
42
- const routes = candidates
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.extensions.join("\0");
45
- let files = filesByExtensions.get(key);
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
- filesByExtensions.set(key, files);
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) => adapter.isSupportedFile(file));
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 = [...new Set((Array.isArray(selected) ? selected : [selected]).map((name) => name.trim()))].filter(
86
- (name) => name.length > 0,
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(`No ${action} route supports ${filePath}${override}. ${SUPPORTED_SERVER_DESCRIPTION}`);
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;