@narumitw/pi-lsp 0.1.19
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/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +49 -0
- package/src/adapters.ts +141 -0
- package/src/command.ts +93 -0
- package/src/files.ts +119 -0
- package/src/lsp-client.ts +426 -0
- package/src/pi-lsp.ts +214 -0
- package/src/runner.ts +251 -0
- package/src/text-edits.ts +96 -0
- package/src/types.ts +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# 🧠 pi-lsp — Shared Language Server Tools for Pi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-lsp) [](https://pi.dev) [](./LICENSE)
|
|
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.
|
|
6
|
+
|
|
7
|
+
It is intended to cover the current behavior of `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp` while keeping those older packages available and unchanged for now.
|
|
8
|
+
|
|
9
|
+
## ✨ Features
|
|
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.
|
|
17
|
+
- Starts language servers only for tool calls, then shuts them down.
|
|
18
|
+
- Shows statusline activity only while LSP tools are running.
|
|
19
|
+
|
|
20
|
+
## 📦 Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install npm:@narumitw/pi-lsp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Try without installing permanently:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi -e npm:@narumitw/pi-lsp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Try this package locally from the repository root:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi -e ./extensions/pi-lsp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## ⚠️ Tool-name compatibility
|
|
39
|
+
|
|
40
|
+
This package intentionally registers the same tool names as `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp`:
|
|
41
|
+
|
|
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`
|
|
49
|
+
|
|
50
|
+
Avoid installing `@narumitw/pi-lsp` side by side with the older LSP packages unless you have verified how your Pi version handles duplicate tool names. The older packages are not deprecated in this phase. For the same reason, this repository's `just install-all` recipe skips `pi-lsp`; install `pi-lsp` separately when you want the shared LSP extension instead of the older split packages.
|
|
51
|
+
|
|
52
|
+
## ✅ Requirements
|
|
53
|
+
|
|
54
|
+
Install the language servers you want to use somewhere on `PATH`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv tool install ty
|
|
58
|
+
uv tool install ruff
|
|
59
|
+
```
|
|
60
|
+
|
|
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:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install -D @biomejs/biome
|
|
65
|
+
PI_BIOME_LSP_COMMAND="./node_modules/.bin/biome lsp-proxy" pi -e ./extensions/pi-lsp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or provide custom server commands:
|
|
69
|
+
|
|
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
|
+
```
|
|
76
|
+
|
|
77
|
+
Optional timeout overrides:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
PI_BIOME_LSP_TIMEOUT_MS=30000 \
|
|
81
|
+
PI_TY_LSP_TIMEOUT_MS=30000 \
|
|
82
|
+
PI_RUFF_LSP_TIMEOUT_MS=30000 \
|
|
83
|
+
pi -e ./extensions/pi-lsp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 🛠️ Pi tools
|
|
87
|
+
|
|
88
|
+
### Biome
|
|
89
|
+
|
|
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`.
|
|
93
|
+
|
|
94
|
+
### Python
|
|
95
|
+
|
|
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`.
|
|
100
|
+
|
|
101
|
+
## 🚀 Examples
|
|
102
|
+
|
|
103
|
+
Check a project subset with Biome diagnostics:
|
|
104
|
+
|
|
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
|
+
```
|
|
120
|
+
|
|
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.
|
|
141
|
+
|
|
142
|
+
## 💬 Command
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
/lsp
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Shows the configured Biome, ty, and Ruff LSP commands and whether each command is available on `PATH`.
|
|
149
|
+
|
|
150
|
+
## 🗂️ Package layout
|
|
151
|
+
|
|
152
|
+
```txt
|
|
153
|
+
extensions/pi-lsp/
|
|
154
|
+
├── src/
|
|
155
|
+
│ ├── adapters.ts
|
|
156
|
+
│ ├── command.ts
|
|
157
|
+
│ ├── files.ts
|
|
158
|
+
│ ├── lsp-client.ts
|
|
159
|
+
│ ├── pi-lsp.ts
|
|
160
|
+
│ ├── runner.ts
|
|
161
|
+
│ ├── text-edits.ts
|
|
162
|
+
│ └── types.ts
|
|
163
|
+
├── README.md
|
|
164
|
+
├── LICENSE
|
|
165
|
+
├── tsconfig.json
|
|
166
|
+
└── package.json
|
|
167
|
+
```
|
|
168
|
+
|
|
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
|
+
## 📄 License
|
|
174
|
+
|
|
175
|
+
MIT. See [`LICENSE`](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-lsp",
|
|
3
|
+
"version": "0.1.19",
|
|
4
|
+
"description": "Pi extension that exposes language-server tools for Biome, ty, and Ruff through a shared LSP runner.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"lsp",
|
|
13
|
+
"biome",
|
|
14
|
+
"python",
|
|
15
|
+
"ty",
|
|
16
|
+
"ruff",
|
|
17
|
+
"lint",
|
|
18
|
+
"format"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/pi-lsp.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "biome check . && npm run typecheck",
|
|
32
|
+
"format": "biome check --write .",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"typebox": "^1.1.37"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "2.4.14",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.74.0",
|
|
41
|
+
"@types/node": "25.6.0",
|
|
42
|
+
"typescript": "6.0.3"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
47
|
+
"directory": "extensions/pi-lsp"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { LspServerAdapter } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const BIOME_SKIP_DIRECTORIES = new Set([
|
|
5
|
+
".git",
|
|
6
|
+
".hg",
|
|
7
|
+
".next",
|
|
8
|
+
".nuxt",
|
|
9
|
+
".output",
|
|
10
|
+
".svelte-kit",
|
|
11
|
+
"coverage",
|
|
12
|
+
"dist",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"out",
|
|
15
|
+
]);
|
|
16
|
+
const BIOME_SUPPORTED_EXTENSIONS = new Set([
|
|
17
|
+
".astro",
|
|
18
|
+
".css",
|
|
19
|
+
".cts",
|
|
20
|
+
".cjs",
|
|
21
|
+
".graphql",
|
|
22
|
+
".gql",
|
|
23
|
+
".html",
|
|
24
|
+
".js",
|
|
25
|
+
".json",
|
|
26
|
+
".jsonc",
|
|
27
|
+
".jsx",
|
|
28
|
+
".mjs",
|
|
29
|
+
".mts",
|
|
30
|
+
".svelte",
|
|
31
|
+
".ts",
|
|
32
|
+
".tsx",
|
|
33
|
+
".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
|
+
]);
|
|
47
|
+
|
|
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,
|
|
66
|
+
},
|
|
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,
|
|
91
|
+
},
|
|
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,
|
|
115
|
+
},
|
|
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
|
+
};
|
|
125
|
+
|
|
126
|
+
export const adapters = [biomeAdapter, tyAdapter, ruffAdapter] as const;
|
|
127
|
+
|
|
128
|
+
function biomeLanguageIdFor(filePath: string) {
|
|
129
|
+
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);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isPythonFile(filePath: string) {
|
|
140
|
+
return filePath.endsWith(".py") || filePath.endsWith(".pyi");
|
|
141
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import type { ServerCommand } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export function commandFromEnv(envVar: string, fallback: ServerCommand): ServerCommand {
|
|
7
|
+
const customCommand = process.env[envVar]?.trim();
|
|
8
|
+
if (customCommand) {
|
|
9
|
+
const [command, ...args] = splitCommand(customCommand);
|
|
10
|
+
if (command) return { command, args };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
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
|
+
export function commandExists(command: string, cwd = process.cwd()) {
|
|
22
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
23
|
+
return isRunnableFile(path.isAbsolute(command) ? command : path.resolve(cwd, command));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pathValue = process.env.PATH ?? "";
|
|
27
|
+
const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
28
|
+
for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
|
|
29
|
+
if (!directory) continue;
|
|
30
|
+
for (const extension of extensions) {
|
|
31
|
+
if (isRunnableFile(path.join(directory, `${command}${extension}`))) return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRunnableFile(filePath: string) {
|
|
39
|
+
if (!existsSync(filePath)) return false;
|
|
40
|
+
try {
|
|
41
|
+
if (!statSync(filePath).isFile()) return false;
|
|
42
|
+
if (process.platform !== "win32") accessSync(filePath, constants.X_OK);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function splitCommand(input: string) {
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
let current = "";
|
|
52
|
+
let quote: '"' | "'" | undefined;
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
55
|
+
const char = input[index];
|
|
56
|
+
const next = input[index + 1];
|
|
57
|
+
|
|
58
|
+
if (char === "\\" && next !== undefined && shouldEscapeNextCharacter(next, quote)) {
|
|
59
|
+
current += next;
|
|
60
|
+
index += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if ((char === '"' || char === "'") && !quote) {
|
|
65
|
+
quote = char;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (char === quote) {
|
|
70
|
+
quote = undefined;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (/\s/.test(char) && !quote) {
|
|
75
|
+
if (current) {
|
|
76
|
+
parts.push(current);
|
|
77
|
+
current = "";
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
current += char;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (current) parts.push(current);
|
|
86
|
+
return parts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shouldEscapeNextCharacter(next: string, quote: '"' | "'" | undefined) {
|
|
90
|
+
if (next === "\\") return true;
|
|
91
|
+
if (quote) return next === quote;
|
|
92
|
+
return next === '"' || next === "'" || /\s/.test(next);
|
|
93
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type { LspServerAdapter } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export function resolveRoot(root?: string) {
|
|
8
|
+
const resolvedRoot = path.resolve(root?.trim() || process.cwd());
|
|
9
|
+
if (!existsSync(resolvedRoot)) throw new Error(`Workspace root does not exist: ${resolvedRoot}`);
|
|
10
|
+
if (!statSync(resolvedRoot).isDirectory()) {
|
|
11
|
+
throw new Error(`Expected workspace root to be a directory: ${resolvedRoot}`);
|
|
12
|
+
}
|
|
13
|
+
return resolvedRoot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function directoryUri(directory: string) {
|
|
17
|
+
return pathToFileURL(directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`).href;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveSupportedFile(adapter: LspServerAdapter, root: string, filePath: string) {
|
|
21
|
+
const resolvedPath = resolveWorkspacePath(root, filePath, "File path");
|
|
22
|
+
if (!existsSync(resolvedPath)) throw new Error(`${adapter.label} file does not exist: ${resolvedPath}`);
|
|
23
|
+
if (!isInsidePath(realpathSync(root), realpathSync(resolvedPath))) {
|
|
24
|
+
throw new Error(`File resolves outside workspace root: ${resolvedPath}`);
|
|
25
|
+
}
|
|
26
|
+
if (!statSync(resolvedPath).isFile()) throw new Error(`Expected a file: ${resolvedPath}`);
|
|
27
|
+
if (!adapter.isSupportedFile(resolvedPath)) {
|
|
28
|
+
throw new Error(`Expected a ${adapter.label} supported file: ${resolvedPath}`);
|
|
29
|
+
}
|
|
30
|
+
return resolvedPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function collectSupportedFiles(
|
|
34
|
+
adapter: LspServerAdapter,
|
|
35
|
+
root: string,
|
|
36
|
+
requestedPaths: string[] | undefined,
|
|
37
|
+
limit: number,
|
|
38
|
+
) {
|
|
39
|
+
const cappedLimit = Math.max(1, Math.floor(limit));
|
|
40
|
+
const files: string[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const visitedDirectories = new Set<string>();
|
|
43
|
+
const realRoot = realpathSync(root);
|
|
44
|
+
const inputs = requestedPaths?.length ? requestedPaths : [root];
|
|
45
|
+
|
|
46
|
+
for (const input of inputs) {
|
|
47
|
+
const targetPath = resolveWorkspacePath(root, input, "Requested path");
|
|
48
|
+
if (!existsSync(targetPath)) throw new Error(`Requested path does not exist: ${targetPath}`);
|
|
49
|
+
if (!isInsidePath(realRoot, realpathSync(targetPath))) {
|
|
50
|
+
throw new Error(`Requested path resolves outside workspace root: ${targetPath}`);
|
|
51
|
+
}
|
|
52
|
+
collectPath(adapter, targetPath, files, seen, visitedDirectories, realRoot, cappedLimit);
|
|
53
|
+
if (files.length >= cappedLimit) break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function collectPath(
|
|
60
|
+
adapter: LspServerAdapter,
|
|
61
|
+
targetPath: string,
|
|
62
|
+
files: string[],
|
|
63
|
+
seen: Set<string>,
|
|
64
|
+
visitedDirectories: Set<string>,
|
|
65
|
+
realRoot: string,
|
|
66
|
+
limit: number,
|
|
67
|
+
) {
|
|
68
|
+
if (files.length >= limit || !existsSync(targetPath)) return;
|
|
69
|
+
if (!isInsidePath(realRoot, realpathSync(targetPath))) return;
|
|
70
|
+
|
|
71
|
+
const stats = statSync(targetPath);
|
|
72
|
+
if (stats.isFile()) {
|
|
73
|
+
if (adapter.isSupportedFile(targetPath) && !seen.has(targetPath)) {
|
|
74
|
+
seen.add(targetPath);
|
|
75
|
+
files.push(targetPath);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!stats.isDirectory()) return;
|
|
81
|
+
const directoryKey = realpathSync(targetPath);
|
|
82
|
+
if (visitedDirectories.has(directoryKey)) return;
|
|
83
|
+
visitedDirectories.add(directoryKey);
|
|
84
|
+
|
|
85
|
+
const entries = readdirSync(targetPath, { withFileTypes: true }).sort((left, right) =>
|
|
86
|
+
left.name.localeCompare(right.name),
|
|
87
|
+
);
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (files.length >= limit) break;
|
|
90
|
+
if ((entry.isDirectory() || entry.isSymbolicLink()) && adapter.skipDirectories.has(entry.name)) continue;
|
|
91
|
+
collectPath(adapter, path.join(targetPath, entry.name), files, seen, visitedDirectories, realRoot, limit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveWorkspacePath(root: string, inputPath: string, label: string) {
|
|
96
|
+
const resolvedPath = path.resolve(root, inputPath);
|
|
97
|
+
const realRoot = realpathSync(root);
|
|
98
|
+
const isLexicallyInsideRoot = isInsidePath(root, resolvedPath);
|
|
99
|
+
|
|
100
|
+
if (existsSync(resolvedPath)) {
|
|
101
|
+
const realResolvedPath = realpathSync(resolvedPath);
|
|
102
|
+
if (!isInsidePath(realRoot, realResolvedPath)) {
|
|
103
|
+
throw new Error(`${label} resolves outside workspace root: ${resolvedPath}`);
|
|
104
|
+
}
|
|
105
|
+
return isLexicallyInsideRoot
|
|
106
|
+
? resolvedPath
|
|
107
|
+
: path.join(root, path.relative(realRoot, realResolvedPath));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!isLexicallyInsideRoot && !isInsidePath(realRoot, resolvedPath)) {
|
|
111
|
+
throw new Error(`${label} escapes workspace root: ${resolvedPath}`);
|
|
112
|
+
}
|
|
113
|
+
return resolvedPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isInsidePath(parent: string, child: string) {
|
|
117
|
+
const relativePath = path.relative(parent, child);
|
|
118
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
119
|
+
}
|