@narumitw/pi-lsp 0.1.26 → 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 +91 -97
- 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,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
|
-
##
|
|
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
|
-
-
|
|
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
|
-
|
|
35
|
+
Custom config can be supplied in one of these locations:
|
|
51
36
|
|
|
52
|
-
|
|
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
|
-
|
|
41
|
+
`lsp.json` can be a plain object keyed by server name:
|
|
55
42
|
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
+
Use `servers` when you need global pi-lsp options such as timeout:
|
|
62
74
|
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
93
|
+
Each server entry supports:
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
###
|
|
116
|
+
### `lsp_diagnostics`
|
|
89
117
|
|
|
90
|
-
|
|
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
|
-
|
|
120
|
+
Parameters:
|
|
95
121
|
|
|
96
|
-
- `
|
|
97
|
-
- `
|
|
98
|
-
- `
|
|
99
|
-
- `
|
|
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
|
-
|
|
127
|
+
### `lsp_fix`
|
|
102
128
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Pi extension that exposes language-
|
|
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
|
-
"
|
|
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
|
}
|