@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 +98 -93
- package/package.json +6 -7
- package/src/adapters.ts +211 -101
- package/src/command.ts +0 -5
- package/src/files.ts +2 -2
- package/src/lsp-client.ts +38 -60
- package/src/pi-lsp.ts +105 -146
- package/src/routes.ts +98 -0
- package/src/runner.ts +46 -80
- package/src/types.ts +20 -19
package/README.md
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
# 🧠 pi-lsp —
|
|
1
|
+
# 🧠 pi-lsp — Configurable Language Server Tools for Pi
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-lsp) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
-
`@narumitw/pi-lsp` is a native [Pi coding agent](https://pi.dev) extension that exposes
|
|
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
|
-
|
|
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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- Uses one internal LSP runner for JSON-RPC framing, subprocess lifecycle, diagnostics,
|
|
15
|
-
-
|
|
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
|
-
##
|
|
31
|
+
## ⚙️ Configuration
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
If no config is provided, pi-lsp ships compatible defaults for Biome, ty, and Ruff.
|
|
41
34
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
`PI_LSP_CONFIG` only accepts JSON or a JSON file path; JavaScript and TypeScript config files are not evaluated.
|
|
53
42
|
|
|
54
|
-
|
|
43
|
+
`lsp.json` can be a plain object keyed by server name:
|
|
55
44
|
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
75
|
+
Use `servers` when you need global pi-lsp options such as timeout:
|
|
62
76
|
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
95
|
+
Each server entry supports:
|
|
69
96
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
##
|
|
116
|
+
## ⚠️ Tool changes
|
|
87
117
|
|
|
88
|
-
|
|
118
|
+
`lsp_format` is no longer provided. pi-lsp now focuses on LSP diagnostics and source code actions:
|
|
89
119
|
|
|
90
|
-
- `
|
|
91
|
-
- `
|
|
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
|
-
|
|
123
|
+
Use project formatters or shell commands for formatting workflows.
|
|
95
124
|
|
|
96
|
-
|
|
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
|
-
|
|
127
|
+
### `lsp_diagnostics`
|
|
102
128
|
|
|
103
|
-
|
|
129
|
+
Run diagnostics through configured servers.
|
|
104
130
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
{
|
|
125
|
-
"paths": ["src", "tests"],
|
|
126
|
-
"limit": 100
|
|
127
|
-
}
|
|
128
|
-
```
|
|
138
|
+
### `lsp_fix`
|
|
129
139
|
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
{
|
|
134
|
-
"path": "src/app.py",
|
|
135
|
-
"kind": "source.organizeImports.ruff",
|
|
136
|
-
"write": true
|
|
137
|
-
}
|
|
138
|
-
```
|
|
142
|
+
Parameters:
|
|
139
143
|
|
|
140
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Pi extension that exposes language-
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"ty",
|
|
16
|
-
"ruff",
|
|
13
|
+
"language-server-protocol",
|
|
14
|
+
"configurable",
|
|
17
15
|
"lint",
|
|
18
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
140
|
-
return
|
|
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.
|
|
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.
|
|
28
|
+
throw new Error(`Expected a ${adapter.name} supported file: ${resolvedPath}`);
|
|
29
29
|
}
|
|
30
30
|
return resolvedPath;
|
|
31
31
|
}
|