@narumitw/pi-lsp 0.1.26 → 0.1.28

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
@@ -1,19 +1,18 @@
1
- # 🧠 pi-lsp — Shared Language Server Tools for Pi
1
+ # 🧠 pi-lsp — Configurable Language Server Tools for Pi
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-lsp)](https://www.npmjs.com/package/@narumitw/pi-lsp) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-lsp` is a native [Pi coding agent](https://pi.dev) extension that exposes Biome, ty, and Ruff language-server tools through one shared LSP runner Module.
5
+ `@narumitw/pi-lsp` is a native [Pi coding agent](https://pi.dev) extension that exposes diagnostics and source-fix tools through configurable Language Server Protocol routes.
6
6
 
7
- It supersedes the older split packages `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp`, which now live under `extensions/deprecated/` and are excluded from active workspace scripts.
7
+ The extension is language-agnostic: servers are selected by config and file extension instead of hard-coded language families.
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - Runs `biome lsp-proxy` on demand for diagnostics, formatting, import organization, and source fixes.
12
- - Runs `ty server` on demand for Python type diagnostics.
13
- - Runs `ruff server` on demand for Python lint diagnostics, formatting, import organization, and source fixes.
14
- - Uses one internal LSP runner for JSON-RPC framing, subprocess lifecycle, diagnostics, formatting, code actions, and workspace edit application.
15
- - Keeps Biome, ty, and Ruff behavior in small server Adapters.
16
- - Supports workspace roots, file limits, recursive file discovery, and write-or-preview edits.
11
+ - Configure LSP servers with simple JSON keyed by server name.
12
+ - Routes diagnostics and source fixes by configured file extensions.
13
+ - Supports multiple servers for the same extension, for example `ty` and `ruff` for `.py`/`.pyi` diagnostics.
14
+ - Uses one internal LSP runner for JSON-RPC framing, subprocess lifecycle, diagnostics, code actions, and workspace edit application.
15
+ - Supports workspace roots, file limits, recursive file discovery, server overrides, and write-or-preview edits.
17
16
  - Starts language servers only for tool calls, then shuts them down.
18
17
  - Shows statusline activity only while LSP tools are running.
19
18
 
@@ -23,121 +22,130 @@ It supersedes the older split packages `@narumitw/pi-biome-lsp` and `@narumitw/p
23
22
  pi install npm:@narumitw/pi-lsp
24
23
  ```
25
24
 
26
- Try without installing permanently:
27
-
28
- ```bash
29
- pi -e npm:@narumitw/pi-lsp
30
- ```
31
-
32
25
  Try this package locally from the repository root:
33
26
 
34
27
  ```bash
35
28
  pi -e ./extensions/pi-lsp
36
29
  ```
37
30
 
38
- ## ⚠️ Tool-name compatibility
31
+ ## ⚙️ Configuration
39
32
 
40
- This package intentionally registers the same tool names as `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp`:
33
+ If no config is provided, pi-lsp ships compatible defaults for Biome, ty, and Ruff.
41
34
 
42
- - `biome_lsp_diagnostics`
43
- - `biome_lsp_format`
44
- - `biome_lsp_fix`
45
- - `ty_lsp_diagnostics`
46
- - `ruff_lsp_diagnostics`
47
- - `ruff_lsp_format`
48
- - `ruff_lsp_fix`
35
+ Custom config can be supplied in one of these locations:
49
36
 
50
- Avoid installing `@narumitw/pi-lsp` side by side with the older deprecated LSP packages unless you have verified how your Pi version handles duplicate tool names.
37
+ 1. `PI_LSP_CONFIG` as inline JSON or a path to a JSON file
38
+ 2. `<workspace>/.pi/lsp.json`
39
+ 3. `~/.pi/agent/lsp.json`
51
40
 
52
- ## Requirements
41
+ `PI_LSP_CONFIG` only accepts JSON or a JSON file path; JavaScript and TypeScript config files are not evaluated.
53
42
 
54
- Install the language servers you want to use somewhere on `PATH`:
43
+ `lsp.json` can be a plain object keyed by server name:
55
44
 
56
- ```bash
57
- uv tool install ty
58
- uv tool install ruff
45
+ ```json
46
+ {
47
+ "ty": {
48
+ "command": ["ty", "server"],
49
+ "extensions": [".py", ".pyi"]
50
+ },
51
+ "ruff": {
52
+ "command": ["ruff", "server"],
53
+ "extensions": [".py", ".pyi"]
54
+ },
55
+ "biome": {
56
+ "command": ["biome", "lsp-proxy"],
57
+ "extensions": [
58
+ ".astro",
59
+ ".css",
60
+ ".graphql",
61
+ ".gql",
62
+ ".html",
63
+ ".js",
64
+ ".jsx",
65
+ ".json",
66
+ ".jsonc",
67
+ ".ts",
68
+ ".tsx",
69
+ ".vue"
70
+ ]
71
+ }
72
+ }
59
73
  ```
60
74
 
61
- For Biome, either install it globally/on `PATH`, add your project's `node_modules/.bin` to `PATH`, or point the extension at a project-local command. For example:
75
+ Use `servers` when you need global pi-lsp options such as timeout:
62
76
 
63
- ```bash
64
- npm install -D @biomejs/biome
65
- PI_BIOME_LSP_COMMAND="./node_modules/.bin/biome lsp-proxy" pi -e ./extensions/pi-lsp
77
+ ```json
78
+ {
79
+ "timeout": 30000,
80
+ "servers": {
81
+ "ty": {
82
+ "command": ["ty", "server"],
83
+ "extensions": [".py", ".pyi"],
84
+ "env": {
85
+ "LSP_LOG": "debug"
86
+ },
87
+ "initialization": {
88
+ "settings": {}
89
+ }
90
+ }
91
+ }
92
+ }
66
93
  ```
67
94
 
68
- Or provide custom server commands:
95
+ Each server entry supports:
69
96
 
70
- ```bash
71
- PI_BIOME_LSP_COMMAND="npx biome lsp-proxy" \
72
- PI_TY_LSP_COMMAND="uvx ty server" \
73
- PI_RUFF_LSP_COMMAND="uvx ruff server" \
74
- pi -e ./extensions/pi-lsp
75
- ```
97
+ - `command`: argv array used to start the LSP server.
98
+ - `extensions`: file extensions that should route to this server.
99
+ - `env`: extra environment variables for the LSP server process.
100
+ - `initialization`: LSP initialization options and workspace configuration values.
76
101
 
77
- Optional timeout overrides:
102
+ Global options:
103
+
104
+ - `timeout`: request timeout in milliseconds. Defaults to `20000`.
105
+
106
+ pi-lsp infers `languageId` from common extensions and falls back to the extension without the leading dot.
107
+
108
+ Per-server command overrides still use the normalized server name:
78
109
 
79
110
  ```bash
80
- PI_BIOME_LSP_TIMEOUT_MS=30000 \
81
- PI_TY_LSP_TIMEOUT_MS=30000 \
82
- PI_RUFF_LSP_TIMEOUT_MS=30000 \
111
+ PI_TY_LSP_COMMAND="uvx ty server" \
112
+ PI_RUFF_LSP_COMMAND="uvx ruff server" \
83
113
  pi -e ./extensions/pi-lsp
84
114
  ```
85
115
 
86
- ## 🛠️ Pi tools
116
+ ## ⚠️ Tool changes
87
117
 
88
- ### Biome
118
+ `lsp_format` is no longer provided. pi-lsp now focuses on LSP diagnostics and source code actions:
89
119
 
90
- - `biome_lsp_diagnostics` — start `biome lsp-proxy`, open supported files, and return diagnostics.
91
- - `biome_lsp_format` — compute or write formatting edits for one Biome-supported file.
92
- - `biome_lsp_fix` — compute or write source actions such as `source.fixAll.biome` or `source.organizeImports.biome`.
120
+ - `lsp_diagnostics`
121
+ - `lsp_fix`
93
122
 
94
- ### Python
123
+ Use project formatters or shell commands for formatting workflows.
95
124
 
96
- - `ty_lsp_diagnostics` start `ty server`, open Python files, and return type diagnostics.
97
- - `ruff_lsp_diagnostics` — start `ruff server`, open Python files, and return lint diagnostics.
98
- - `ruff_lsp_format` — compute or write Ruff formatting edits for one Python file.
99
- - `ruff_lsp_fix` — compute or write Ruff source actions such as `source.fixAll.ruff` or `source.organizeImports.ruff`.
125
+ ## 🛠️ Pi tools
100
126
 
101
- ## 🚀 Examples
127
+ ### `lsp_diagnostics`
102
128
 
103
- Check a project subset with Biome diagnostics:
129
+ Run diagnostics through configured servers.
104
130
 
105
- ```json
106
- {
107
- "paths": ["src", "extensions/pi-lsp/src"],
108
- "limit": 100
109
- }
110
- ```
111
-
112
- Format a TypeScript file with Biome:
113
-
114
- ```json
115
- {
116
- "path": "src/index.ts",
117
- "write": true
118
- }
119
- ```
131
+ Parameters:
120
132
 
121
- Check a Python project with ty or Ruff diagnostics:
133
+ - `paths?`: files or directories to check. Defaults to the workspace root.
134
+ - `root?`: workspace root. Defaults to cwd.
135
+ - `limit?`: maximum files to open per selected server.
136
+ - `server?`: configured server name, or an array of names. Defaults to all matching servers.
122
137
 
123
- ```json
124
- {
125
- "paths": ["src", "tests"],
126
- "limit": 100
127
- }
128
- ```
138
+ ### `lsp_fix`
129
139
 
130
- Organize Python imports with Ruff:
140
+ Apply source fixes or import organization through a configured server that matches its extension. If multiple servers match, pass `server` explicitly.
131
141
 
132
- ```json
133
- {
134
- "path": "src/app.py",
135
- "kind": "source.organizeImports.ruff",
136
- "write": true
137
- }
138
- ```
142
+ Parameters:
139
143
 
140
- If `paths` is omitted for diagnostics, the tool recursively discovers supported files under the workspace root while skipping common generated, dependency, cache, and virtualenv directories.
144
+ - `path`: file to fix.
145
+ - `root?`: workspace root. Defaults to cwd.
146
+ - `kind?`: source action kind. Defaults to `source.fixAll`.
147
+ - `write?`: write fixed text back to the file. Defaults to false.
148
+ - `server?`: optional configured server name.
141
149
 
142
150
  ## 💬 Command
143
151
 
@@ -145,7 +153,7 @@ If `paths` is omitted for diagnostics, the tool recursively discovers supported
145
153
  /lsp
146
154
  ```
147
155
 
148
- Shows the configured Biome, ty, and Ruff LSP commands and whether each command is available on `PATH`.
156
+ Shows configured LSP commands and whether each command is available on `PATH`.
149
157
 
150
158
  ## 🗂️ Package layout
151
159
 
@@ -157,6 +165,7 @@ extensions/pi-lsp/
157
165
  │ ├── files.ts
158
166
  │ ├── lsp-client.ts
159
167
  │ ├── pi-lsp.ts
168
+ │ ├── routes.ts
160
169
  │ ├── runner.ts
161
170
  │ ├── text-edits.ts
162
171
  │ └── types.ts
@@ -166,10 +175,6 @@ extensions/pi-lsp/
166
175
  └── package.json
167
176
  ```
168
177
 
169
- ## 🔎 Keywords
170
-
171
- Pi extension, Pi coding agent, Language Server Protocol, Biome LSP, ty, Ruff, Python LSP, formatter, linter, import organization, AI coding tools.
172
-
173
178
  ## 📄 License
174
179
 
175
180
  MIT. See [`LICENSE`](./LICENSE).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-lsp",
3
- "version": "0.1.26",
4
- "description": "Pi extension that exposes language-server tools for Biome, ty, and Ruff through a shared LSP runner.",
3
+ "version": "0.1.28",
4
+ "description": "Pi extension that exposes configurable, language-agnostic LSP tools through a shared runner.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -10,12 +10,11 @@
10
10
  "pi-extension",
11
11
  "pi",
12
12
  "lsp",
13
- "biome",
14
- "python",
15
- "ty",
16
- "ruff",
13
+ "language-server-protocol",
14
+ "configurable",
17
15
  "lint",
18
- "format"
16
+ "diagnostics",
17
+ "code-action"
19
18
  ],
20
19
  "files": [
21
20
  "src",
package/src/adapters.ts CHANGED
@@ -1,19 +1,30 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
- import type { LspServerAdapter } from "./types.js";
4
+ import process from "node:process";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import type { InternalLspServer, LspConfig, LspServerAdapter } from "./types.js";
3
7
 
4
- const BIOME_SKIP_DIRECTORIES = new Set([
8
+ const COMMON_SKIP_DIRECTORIES = new Set([
5
9
  ".git",
6
10
  ".hg",
11
+ ".mypy_cache",
7
12
  ".next",
8
13
  ".nuxt",
9
14
  ".output",
15
+ ".ruff_cache",
10
16
  ".svelte-kit",
17
+ ".tox",
18
+ ".venv",
19
+ "__pycache__",
11
20
  "coverage",
12
21
  "dist",
13
22
  "node_modules",
14
23
  "out",
24
+ "venv",
15
25
  ]);
16
- const BIOME_SUPPORTED_EXTENSIONS = new Set([
26
+
27
+ const BIOME_EXTENSIONS = [
17
28
  ".astro",
18
29
  ".css",
19
30
  ".cts",
@@ -31,111 +42,210 @@ const BIOME_SUPPORTED_EXTENSIONS = new Set([
31
42
  ".ts",
32
43
  ".tsx",
33
44
  ".vue",
34
- ]);
35
-
36
- const PYTHON_SKIP_DIRECTORIES = new Set([
37
- ".git",
38
- ".hg",
39
- ".mypy_cache",
40
- ".ruff_cache",
41
- ".tox",
42
- ".venv",
43
- "__pycache__",
44
- "node_modules",
45
- "venv",
46
- ]);
45
+ ];
47
46
 
48
- export const biomeAdapter: LspServerAdapter = {
49
- label: "Biome",
50
- statusPrefix: "🧬",
51
- defaultCommand: { command: "biome", args: ["lsp-proxy"] },
52
- commandEnvVar: "PI_BIOME_LSP_COMMAND",
53
- timeoutEnvVar: "PI_BIOME_LSP_TIMEOUT_MS",
54
- missingCommandHint: "Install @biomejs/biome or set PI_BIOME_LSP_COMMAND.",
55
- skipDirectories: BIOME_SKIP_DIRECTORIES,
56
- isSupportedFile: (filePath) => BIOME_SUPPORTED_EXTENSIONS.has(path.extname(filePath)),
57
- languageIdFor: biomeLanguageIdFor,
58
- formattingOptions: { tabSize: 2, insertSpaces: false },
59
- initialize: {
60
- codeAction: true,
61
- diagnosticDynamicRegistration: true,
62
- formattingDynamicRegistration: true,
63
- codeActionDynamicRegistration: true,
64
- didChangeConfigurationDynamicRegistration: true,
65
- didSaveDynamicRegistration: true,
47
+ export const DEFAULT_SERVER_CONFIGS: InternalLspServer[] = [
48
+ {
49
+ name: "biome",
50
+ command: ["biome", "lsp-proxy"],
51
+ extensions: BIOME_EXTENSIONS,
66
52
  },
67
- fallbackToPublishDiagnostics: true,
68
- resolveUnsupportedCodeActions: true,
69
- serverRequestWorkspaceFolders: true,
70
- emptyDiagnosticsMessage: "Biome LSP found no supported files to check.",
71
- formatDiagnosticsHeader: (summary) =>
72
- `Biome LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
73
- editSummaryLabel: "Biome",
74
- defaultFixKind: "source.fixAll.biome",
75
- };
76
-
77
- export const tyAdapter: LspServerAdapter = {
78
- label: "ty",
79
- statusPrefix: "🐍 ty",
80
- defaultCommand: { command: "ty", args: ["server"] },
81
- commandEnvVar: "PI_TY_LSP_COMMAND",
82
- timeoutEnvVar: "PI_TY_LSP_TIMEOUT_MS",
83
- missingCommandHint: "Install ty or set PI_TY_LSP_COMMAND.",
84
- skipDirectories: PYTHON_SKIP_DIRECTORIES,
85
- isSupportedFile: isPythonFile,
86
- languageIdFor: () => "python",
87
- formattingOptions: { tabSize: 4, insertSpaces: true },
88
- initialize: {
89
- codeAction: false,
90
- diagnosticDynamicRegistration: false,
53
+ {
54
+ name: "ty",
55
+ command: ["ty", "server"],
56
+ extensions: [".py", ".pyi"],
91
57
  },
92
- fallbackToPublishDiagnostics: false,
93
- resolveUnsupportedCodeActions: false,
94
- serverRequestWorkspaceFolders: false,
95
- emptyDiagnosticsMessage: "ty LSP found no Python files to check.",
96
- formatDiagnosticsHeader: (summary) =>
97
- `ty LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
98
- editSummaryLabel: "ty",
99
- };
100
-
101
- export const ruffAdapter: LspServerAdapter = {
102
- label: "Ruff",
103
- statusPrefix: "🐍 ruff",
104
- defaultCommand: { command: "ruff", args: ["server"] },
105
- commandEnvVar: "PI_RUFF_LSP_COMMAND",
106
- timeoutEnvVar: "PI_RUFF_LSP_TIMEOUT_MS",
107
- missingCommandHint: "Install ruff or set PI_RUFF_LSP_COMMAND.",
108
- skipDirectories: PYTHON_SKIP_DIRECTORIES,
109
- isSupportedFile: isPythonFile,
110
- languageIdFor: () => "python",
111
- formattingOptions: { tabSize: 4, insertSpaces: true },
112
- initialize: {
113
- codeAction: true,
114
- diagnosticDynamicRegistration: false,
58
+ {
59
+ name: "ruff",
60
+ command: ["ruff", "server"],
61
+ extensions: [".py", ".pyi"],
115
62
  },
116
- fallbackToPublishDiagnostics: false,
117
- resolveUnsupportedCodeActions: false,
118
- serverRequestWorkspaceFolders: false,
119
- emptyDiagnosticsMessage: "Ruff LSP found no Python files to check.",
120
- formatDiagnosticsHeader: (summary) =>
121
- `Ruff LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
122
- editSummaryLabel: "Ruff",
123
- defaultFixKind: "source.fixAll.ruff",
124
- };
63
+ ];
64
+
65
+ export function loadRuntime(cwd = process.cwd()) {
66
+ const config = loadConfig(cwd);
67
+ return {
68
+ adapters: config.servers.map(configToAdapter),
69
+ timeoutMs: config.timeout ?? 20_000,
70
+ };
71
+ }
125
72
 
126
- export const adapters = [biomeAdapter, tyAdapter, ruffAdapter] as const;
73
+ export function loadConfig(cwd = process.cwd()): LspConfig {
74
+ const configured = loadConfiguredConfig(cwd);
75
+ return configured ?? { servers: DEFAULT_SERVER_CONFIGS };
76
+ }
77
+
78
+ function loadConfiguredConfig(cwd: string): LspConfig | undefined {
79
+ const rawConfig = process.env.PI_LSP_CONFIG?.trim();
80
+ if (rawConfig) return parseConfigSource(rawConfig, cwd, "PI_LSP_CONFIG");
81
+
82
+ const projectConfig = path.join(cwd, ".pi", "lsp.json");
83
+ if (existsSync(projectConfig)) return parseConfigFile(projectConfig);
84
+
85
+ const userConfig = path.join(getAgentDir(), "lsp.json");
86
+ if (existsSync(userConfig)) return parseConfigFile(userConfig);
87
+
88
+ return undefined;
89
+ }
90
+
91
+ function parseConfigSource(source: string, cwd: string, label: string): LspConfig {
92
+ if (source.startsWith("{")) return normalizeConfig(JSON.parse(source), label);
93
+ const expandedSource = expandHome(source);
94
+ const filePath = path.isAbsolute(expandedSource) ? expandedSource : path.resolve(cwd, expandedSource);
95
+ return parseConfigFile(filePath);
96
+ }
127
97
 
128
- function biomeLanguageIdFor(filePath: string) {
98
+ function parseConfigFile(filePath: string): LspConfig {
99
+ return normalizeConfig(JSON.parse(readFileSync(filePath, "utf8")), filePath);
100
+ }
101
+
102
+ function normalizeConfig(value: unknown, label: string): LspConfig {
103
+ if (!isRecord(value) || Array.isArray(value)) {
104
+ throw new Error(`${label} must be a JSON object mapping server names to LSP server config.`);
105
+ }
106
+
107
+ if ("servers" in value) {
108
+ if (isServerEntry(value.servers)) {
109
+ throw new Error(
110
+ `${label} uses reserved top-level key 'servers'. Use the wrapper shape ` +
111
+ `{ "servers": { "<name>": { "command": [...], "extensions": [...] } } }` +
112
+ " or choose a different server name.",
113
+ );
114
+ }
115
+ const timeout = normalizeTimeout(value.timeout, label);
116
+ const servers = value.servers;
117
+ if (!isRecord(servers) || Array.isArray(servers)) {
118
+ throw new Error(`${label}.servers must be a JSON object mapping server names to LSP server config.`);
119
+ }
120
+ return { timeout, servers: normalizeServerMap(servers, `${label}.servers`) };
121
+ }
122
+
123
+ if ("timeout" in value) {
124
+ throw new Error(`${label}.timeout requires the wrapper shape with a servers object.`);
125
+ }
126
+
127
+ return { servers: normalizeServerMap(value, label) };
128
+ }
129
+
130
+ function normalizeServerMap(value: Record<string, unknown>, label: string) {
131
+ return Object.entries(value).map(([name, server]) => normalizeServer(name, server, `${label}.${name}`));
132
+ }
133
+
134
+ function isServerEntry(value: unknown) {
135
+ return (
136
+ isRecord(value) &&
137
+ (Array.isArray(value.command) || Array.isArray(value.extensions))
138
+ );
139
+ }
140
+
141
+ function normalizeServer(name: string, value: unknown, label: string): InternalLspServer {
142
+ if (!isRecord(value)) throw new Error(`${label} must be an object.`);
143
+ const command = stringArrayField(value, "command", label);
144
+ const extensions = stringArrayField(value, "extensions", label).map(normalizeExtension);
145
+ return {
146
+ name,
147
+ command,
148
+ extensions,
149
+ env: optionalStringRecordField(value, "env", label),
150
+ initialization: optionalRecordField(value, "initialization", label),
151
+ };
152
+ }
153
+
154
+ function normalizeTimeout(value: unknown, label: string) {
155
+ if (value === undefined) return undefined;
156
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
157
+ throw new Error(`${label}.timeout must be a positive number.`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function configToAdapter(config: InternalLspServer): LspServerAdapter {
163
+ const extensionSet = new Set(config.extensions.map(normalizeExtension));
164
+ const [command, ...args] = config.command;
165
+ if (!command) throw new Error(`${config.name}.command must contain at least one string.`);
166
+ return {
167
+ name: config.name,
168
+ defaultCommand: { command, args },
169
+ commandEnvVar: envName(config.name, "COMMAND"),
170
+ missingCommandHint: `Install ${config.name} or set ${envName(config.name, "COMMAND")}.`,
171
+ extensions: config.extensions,
172
+ env: config.env,
173
+ initialization: config.initialization,
174
+ skipDirectories: COMMON_SKIP_DIRECTORIES,
175
+ isSupportedFile: (filePath) => extensionSet.has(path.extname(filePath)),
176
+ languageIdFor: (filePath) => languageIdFor(config, filePath),
177
+ };
178
+ }
179
+
180
+ function languageIdFor(_config: InternalLspServer, filePath: string) {
129
181
  const extension = path.extname(filePath);
130
- if (extension === ".js" || extension === ".cjs" || extension === ".mjs") return "javascript";
131
- if (extension === ".jsx") return "javascriptreact";
132
- if (extension === ".ts" || extension === ".cts" || extension === ".mts") return "typescript";
133
- if (extension === ".tsx") return "typescriptreact";
134
- if (extension === ".gql") return "graphql";
135
- if (extension === ".jsonc") return "jsonc";
136
- return extension.slice(1);
182
+ return LANGUAGE_IDS[extension] ?? extension.slice(1);
183
+ }
184
+
185
+ const LANGUAGE_IDS: Record<string, string> = {
186
+ ".cjs": "javascript",
187
+ ".cts": "typescript",
188
+ ".gql": "graphql",
189
+ ".js": "javascript",
190
+ ".jsx": "javascriptreact",
191
+ ".jsonc": "jsonc",
192
+ ".mjs": "javascript",
193
+ ".mts": "typescript",
194
+ ".py": "python",
195
+ ".pyi": "python",
196
+ ".ts": "typescript",
197
+ ".tsx": "typescriptreact",
198
+ };
199
+
200
+ function commandFromEnvName(name: string): string {
201
+ return name.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
202
+ }
203
+
204
+ function envName(name: string, suffix: "COMMAND") {
205
+ return `PI_${commandFromEnvName(name)}_LSP_${suffix}`;
206
+ }
207
+
208
+ function normalizeExtension(extension: string) {
209
+ return extension.startsWith(".") ? extension : `.${extension}`;
210
+ }
211
+
212
+ function stringArrayField(value: Record<string, unknown>, field: string, label: string) {
213
+ const fieldValue = value[field];
214
+ if (!Array.isArray(fieldValue) || !fieldValue.every((item) => typeof item === "string")) {
215
+ throw new Error(`${label}.${field} must be an array of strings.`);
216
+ }
217
+ return fieldValue;
218
+ }
219
+
220
+ function optionalStringRecordField(value: Record<string, unknown>, field: string, label: string) {
221
+ const fieldValue = value[field];
222
+ if (fieldValue === undefined) return undefined;
223
+ if (!isRecord(fieldValue) || Array.isArray(fieldValue)) {
224
+ throw new Error(`${label}.${field} must be an object with string values.`);
225
+ }
226
+ if (!Object.values(fieldValue).every((item) => typeof item === "string")) {
227
+ throw new Error(`${label}.${field} must be an object with string values.`);
228
+ }
229
+ return fieldValue as Record<string, string>;
230
+ }
231
+
232
+ function optionalRecordField(value: Record<string, unknown>, field: string, label: string) {
233
+ const fieldValue = value[field];
234
+ if (fieldValue === undefined) return undefined;
235
+ if (!isRecord(fieldValue) || Array.isArray(fieldValue)) {
236
+ throw new Error(`${label}.${field} must be an object.`);
237
+ }
238
+ return fieldValue;
239
+ }
240
+
241
+ function expandHome(filePath: string) {
242
+ if (filePath === "~") return os.homedir();
243
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
244
+ return path.join(os.homedir(), filePath.slice(2));
245
+ }
246
+ return filePath;
137
247
  }
138
248
 
139
- function isPythonFile(filePath: string) {
140
- return filePath.endsWith(".py") || filePath.endsWith(".pyi");
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === "object" && value !== null;
141
251
  }
package/src/command.ts CHANGED
@@ -13,11 +13,6 @@ export function commandFromEnv(envVar: string, fallback: ServerCommand): ServerC
13
13
  return fallback;
14
14
  }
15
15
 
16
- export function timeoutFromEnv(envVar: string, defaultTimeoutMs: number) {
17
- const rawValue = Number(process.env[envVar] ?? defaultTimeoutMs);
18
- return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : defaultTimeoutMs;
19
- }
20
-
21
16
  export function commandExists(command: string, cwd = process.cwd()) {
22
17
  if (command.includes("/") || command.includes("\\")) {
23
18
  return isRunnableFile(path.isAbsolute(command) ? command : path.resolve(cwd, command));
package/src/files.ts CHANGED
@@ -19,13 +19,13 @@ export function directoryUri(directory: string) {
19
19
 
20
20
  export function resolveSupportedFile(adapter: LspServerAdapter, root: string, filePath: string) {
21
21
  const resolvedPath = resolveWorkspacePath(root, filePath, "File path");
22
- if (!existsSync(resolvedPath)) throw new Error(`${adapter.label} file does not exist: ${resolvedPath}`);
22
+ if (!existsSync(resolvedPath)) throw new Error(`${adapter.name} file does not exist: ${resolvedPath}`);
23
23
  if (!isInsidePath(realpathSync(root), realpathSync(resolvedPath))) {
24
24
  throw new Error(`File resolves outside workspace root: ${resolvedPath}`);
25
25
  }
26
26
  if (!statSync(resolvedPath).isFile()) throw new Error(`Expected a file: ${resolvedPath}`);
27
27
  if (!adapter.isSupportedFile(resolvedPath)) {
28
- throw new Error(`Expected a ${adapter.label} supported file: ${resolvedPath}`);
28
+ throw new Error(`Expected a ${adapter.name} supported file: ${resolvedPath}`);
29
29
  }
30
30
  return resolvedPath;
31
31
  }