@narumitw/pi-lsp 0.1.25 → 0.1.27

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,119 @@ 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
39
-
40
- This package intentionally registers the same tool names as `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp`:
31
+ ## ⚙️ Configuration
41
32
 
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`
33
+ If no config is provided, pi-lsp ships compatible defaults for Biome, ty, and Ruff.
49
34
 
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.
35
+ Custom config can be supplied in one of these locations:
51
36
 
52
- ## Requirements
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`
53
40
 
54
- Install the language servers you want to use somewhere on `PATH`:
41
+ `lsp.json` can be a plain object keyed by server name:
55
42
 
56
- ```bash
57
- uv tool install ty
58
- uv tool install ruff
43
+ ```json
44
+ {
45
+ "ty": {
46
+ "command": ["ty", "server"],
47
+ "extensions": [".py", ".pyi"]
48
+ },
49
+ "ruff": {
50
+ "command": ["ruff", "server"],
51
+ "extensions": [".py", ".pyi"]
52
+ },
53
+ "biome": {
54
+ "command": ["biome", "lsp-proxy"],
55
+ "extensions": [
56
+ ".astro",
57
+ ".css",
58
+ ".graphql",
59
+ ".gql",
60
+ ".html",
61
+ ".js",
62
+ ".jsx",
63
+ ".json",
64
+ ".jsonc",
65
+ ".ts",
66
+ ".tsx",
67
+ ".vue"
68
+ ]
69
+ }
70
+ }
59
71
  ```
60
72
 
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:
73
+ Use `servers` when you need global pi-lsp options such as timeout:
62
74
 
63
- ```bash
64
- npm install -D @biomejs/biome
65
- PI_BIOME_LSP_COMMAND="./node_modules/.bin/biome lsp-proxy" pi -e ./extensions/pi-lsp
75
+ ```json
76
+ {
77
+ "timeout": 30000,
78
+ "servers": {
79
+ "ty": {
80
+ "command": ["ty", "server"],
81
+ "extensions": [".py", ".pyi"],
82
+ "env": {
83
+ "LSP_LOG": "debug"
84
+ },
85
+ "initialization": {
86
+ "settings": {}
87
+ }
88
+ }
89
+ }
90
+ }
66
91
  ```
67
92
 
68
- Or provide custom server commands:
93
+ Each server entry supports:
69
94
 
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
- ```
95
+ - `command`: argv array used to start the LSP server.
96
+ - `extensions`: file extensions that should route to this server.
97
+ - `env`: extra environment variables for the LSP server process.
98
+ - `initialization`: LSP initialization options and workspace configuration values.
76
99
 
77
- Optional timeout overrides:
100
+ Global options:
101
+
102
+ - `timeout`: request timeout in milliseconds. Defaults to `20000`.
103
+
104
+ pi-lsp infers `languageId` from common extensions and falls back to the extension without the leading dot.
105
+
106
+ Per-server command overrides still use the normalized server name:
78
107
 
79
108
  ```bash
80
- PI_BIOME_LSP_TIMEOUT_MS=30000 \
81
- PI_TY_LSP_TIMEOUT_MS=30000 \
82
- PI_RUFF_LSP_TIMEOUT_MS=30000 \
109
+ PI_TY_LSP_COMMAND="uvx ty server" \
110
+ PI_RUFF_LSP_COMMAND="uvx ruff server" \
83
111
  pi -e ./extensions/pi-lsp
84
112
  ```
85
113
 
86
114
  ## 🛠️ Pi tools
87
115
 
88
- ### Biome
116
+ ### `lsp_diagnostics`
89
117
 
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`.
118
+ Run diagnostics through configured servers.
93
119
 
94
- ### Python
120
+ Parameters:
95
121
 
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`.
122
+ - `paths?`: files or directories to check. Defaults to the workspace root.
123
+ - `root?`: workspace root. Defaults to cwd.
124
+ - `limit?`: maximum files to open per selected server.
125
+ - `server?`: configured server name, or an array of names. Defaults to all matching servers.
100
126
 
101
- ## 🚀 Examples
127
+ ### `lsp_fix`
102
128
 
103
- Check a project subset with Biome diagnostics:
129
+ Apply source fixes or import organization through a configured server that matches its extension. If multiple servers match, pass `server` explicitly.
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:
122
-
123
- ```json
124
- {
125
- "paths": ["src", "tests"],
126
- "limit": 100
127
- }
128
- ```
129
-
130
- Organize Python imports with Ruff:
131
-
132
- ```json
133
- {
134
- "path": "src/app.py",
135
- "kind": "source.organizeImports.ruff",
136
- "write": true
137
- }
138
- ```
139
-
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.
133
+ - `path`: file to fix.
134
+ - `root?`: workspace root. Defaults to cwd.
135
+ - `kind?`: source action kind. Defaults to `source.fixAll`.
136
+ - `write?`: write fixed text back to the file. Defaults to false.
137
+ - `server?`: optional configured server name.
141
138
 
142
139
  ## 💬 Command
143
140
 
@@ -145,7 +142,7 @@ If `paths` is omitted for diagnostics, the tool recursively discovers supported
145
142
  /lsp
146
143
  ```
147
144
 
148
- Shows the configured Biome, ty, and Ruff LSP commands and whether each command is available on `PATH`.
145
+ Shows configured LSP commands and whether each command is available on `PATH`.
149
146
 
150
147
  ## 🗂️ Package layout
151
148
 
@@ -157,6 +154,7 @@ extensions/pi-lsp/
157
154
  │ ├── files.ts
158
155
  │ ├── lsp-client.ts
159
156
  │ ├── pi-lsp.ts
157
+ │ ├── routes.ts
160
158
  │ ├── runner.ts
161
159
  │ ├── text-edits.ts
162
160
  │ └── types.ts
@@ -166,10 +164,6 @@ extensions/pi-lsp/
166
164
  └── package.json
167
165
  ```
168
166
 
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
167
  ## 📄 License
174
168
 
175
169
  MIT. See [`LICENSE`](./LICENSE).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-lsp",
3
- "version": "0.1.25",
4
- "description": "Pi extension that exposes language-server tools for Biome, ty, and Ruff through a shared LSP runner.",
3
+ "version": "0.1.27",
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
  }