@effected/lsp 0.5.0 → 0.6.0
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/LspUri.js +266 -0
- package/README.md +16 -0
- package/index.d.ts +145 -2
- package/index.js +2 -1
- package/package.json +1 -1
- package/tsdoc-metadata.json +1 -1
package/LspUri.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/LspUri.ts
|
|
4
|
+
const FILE_SCHEME = /^file:/i;
|
|
5
|
+
const DRIVE = /^[A-Za-z][:|]$/;
|
|
6
|
+
const DRIVE_PATH = /^[A-Za-z]:(?:\/|$)/;
|
|
7
|
+
const HOSTNAME = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
|
|
8
|
+
const EXTRA = /[!'()*]/g;
|
|
9
|
+
/**
|
|
10
|
+
* Remove `.` and `..` segments (RFC 3986 §5.2.4) from the segments after a
|
|
11
|
+
* root, never climbing above it. A `.` or `..` in the last position leaves an
|
|
12
|
+
* empty segment behind, so the result keeps its trailing slash.
|
|
13
|
+
*/
|
|
14
|
+
const removeDotSegments = (segments) => {
|
|
15
|
+
const out = [];
|
|
16
|
+
segments.forEach((segment, index) => {
|
|
17
|
+
const last = index === segments.length - 1;
|
|
18
|
+
if (segment === ".") {
|
|
19
|
+
if (last) out.push("");
|
|
20
|
+
} else if (segment === "..") {
|
|
21
|
+
out.pop();
|
|
22
|
+
if (last) out.push("");
|
|
23
|
+
} else out.push(segment);
|
|
24
|
+
});
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Percent-encode one path segment or host: every character except RFC 3986's
|
|
29
|
+
* unreserved set (`A-Z a-z 0-9 - . _ ~`) becomes its UTF-8 bytes as `%XX`,
|
|
30
|
+
* uppercase. A lone surrogate has no UTF-8 encoding, so it throws.
|
|
31
|
+
*/
|
|
32
|
+
const encode = (segment) => {
|
|
33
|
+
try {
|
|
34
|
+
return encodeURIComponent(segment).replace(EXTRA, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new TypeError(`LspUri.fromPath: ${JSON.stringify(segment)} holds a lone surrogate, which no URI can encode`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** Percent-decode one segment, or `undefined` for a malformed escape or invalid UTF-8. */
|
|
40
|
+
const decode = (segment) => {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(segment);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9\xb9\xb2\xb3]|lpt[0-9\xb9\xb2\xb3]|conin\$|conout\$) *(?:\..*)?$/i;
|
|
48
|
+
/**
|
|
49
|
+
* Why win32 would not open a segment as the entry it spells, or `undefined`:
|
|
50
|
+
* a `:` (an NTFS alternate data stream, `b:stream`), a trailing dot or space
|
|
51
|
+
* (which win32 trims, so `a.` opens `a`), or a DOS device name.
|
|
52
|
+
*/
|
|
53
|
+
const segmentRefusal = (segment) => {
|
|
54
|
+
if (segment.includes(":")) return `has a segment ${JSON.stringify(segment)} holding a ':', which win32 reads as an alternate data stream`;
|
|
55
|
+
if (segment.endsWith(".") || segment.endsWith(" ")) return `has a segment ${JSON.stringify(segment)} ending in a dot or a space, which win32 trims away`;
|
|
56
|
+
if (DEVICE_NAME.test(segment)) return `has a segment ${JSON.stringify(segment)} that names a DOS device`;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Why `fromPath` refuses a Win32 path, or `undefined` when it accepts it.
|
|
60
|
+
* The one statement of the refused set: `fromPath` throws on a reason, and
|
|
61
|
+
* `toPath` answers `Option.none()` for any path this refuses, so `toPath`
|
|
62
|
+
* never answers a path `fromPath` would not write. Segments are checked
|
|
63
|
+
* after `.` and `..` are removed, as both directions remove them.
|
|
64
|
+
*/
|
|
65
|
+
const win32Refusal = (path) => {
|
|
66
|
+
if (path.includes("\0")) return "holds a NUL character";
|
|
67
|
+
const slashed = path.replaceAll("\\", "/");
|
|
68
|
+
if (slashed.startsWith("//?/") || slashed.startsWith("//./") || slashed.startsWith("/??/")) return "is a Win32 device or namespace path, which no file: URI names";
|
|
69
|
+
let segments;
|
|
70
|
+
if (slashed.startsWith("//")) {
|
|
71
|
+
const [server = "", share = "", ...rest] = slashed.slice(2).split("/");
|
|
72
|
+
if (!HOSTNAME.test(server)) return "is not a UNC path whose server is a hostname";
|
|
73
|
+
if (share === "" || share === "." || share === "..") return "is not a UNC path with a server and a share";
|
|
74
|
+
segments = [share, ...removeDotSegments(rest)];
|
|
75
|
+
} else if (DRIVE_PATH.test(slashed)) {
|
|
76
|
+
const rest = slashed.slice(2);
|
|
77
|
+
segments = rest === "" ? [] : removeDotSegments(rest.slice(1).split("/"));
|
|
78
|
+
} else return "is not an absolute win32 path";
|
|
79
|
+
for (const segment of segments) {
|
|
80
|
+
const refusal = segmentRefusal(segment);
|
|
81
|
+
if (refusal !== void 0) return refusal;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const toPosixPath = (host, tail) => host === "" ? Option.some(`/${removeDotSegments(tail).join("/")}`) : Option.none();
|
|
85
|
+
const toWin32Path = (host, tail) => {
|
|
86
|
+
const unc = (server, rest) => {
|
|
87
|
+
const [share, ...after] = rest;
|
|
88
|
+
if (server === "" || share === void 0 || share === "") return Option.none();
|
|
89
|
+
const root = `\\\\${server.toLowerCase()}\\${share}`;
|
|
90
|
+
return Option.some(after.length === 0 ? root : `${root}\\${removeDotSegments(after).join("\\")}`);
|
|
91
|
+
};
|
|
92
|
+
if (host !== "") return unc(host, tail);
|
|
93
|
+
const [first, ...rest] = tail;
|
|
94
|
+
if (first === void 0) return Option.none();
|
|
95
|
+
if (first === "" && rest.length > 0 && rest[0] !== "") return unc(rest[0], rest.slice(1));
|
|
96
|
+
if (DRIVE.test(first)) return Option.some(`${first.charAt(0).toLowerCase()}:\\${removeDotSegments(rest).join("\\")}`);
|
|
97
|
+
return Option.none();
|
|
98
|
+
};
|
|
99
|
+
const toWin32 = (host, tail) => Option.filter(toWin32Path(host, tail), (path) => win32Refusal(path) === void 0);
|
|
100
|
+
/**
|
|
101
|
+
* Conversion between `file:` URIs and absolute file-system paths, the two
|
|
102
|
+
* spellings every Language Server juggles: LSP names documents by URI
|
|
103
|
+
* (`textDocument.uri`, `rootUri`, `workspaceFolders`), the file system by
|
|
104
|
+
* path. Pure, with no `node:url` and no `process` read, so it runs in a
|
|
105
|
+
* worker or a browser. Not instantiable.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Follows RFC 8089 and RFC 3986, and agrees with Node's `fileURLToPath` /
|
|
109
|
+
* `pathToFileURL` and VS Code's `vscode-uri` (`URI.file`, `fsPath`) wherever
|
|
110
|
+
* they agree with each other. The rulings where they do not:
|
|
111
|
+
*
|
|
112
|
+
* - **A non-`file:` scheme (`untitled:`, `vscode-notebook-cell:`) or a
|
|
113
|
+
* malformed URI is `Option.none()`, never a throw**: a client may name
|
|
114
|
+
* any document, and only `file:` documents have paths. So is a `%2F`
|
|
115
|
+
* inside a segment (it would smuggle a separator into one name), a `%5C`
|
|
116
|
+
* under `"win32"` (the same), a `%00`, a malformed or non-UTF-8 escape, a
|
|
117
|
+
* relative `file:` URI, a host under `"posix"`, and a rooted path with no
|
|
118
|
+
* drive or share under `"win32"`. Node throws for each of these.
|
|
119
|
+
* - **`toPath` never answers a Win32 device or namespace path**, nor any
|
|
120
|
+
* other path `fromPath` would refuse: `file://./pipe/x` and
|
|
121
|
+
* `file://%3F/C:/x` would read as `\\.\pipe\x` and `\\?\C:\x`, and
|
|
122
|
+
* are `Option.none()`, as is a host spelled inside the path
|
|
123
|
+
* (`file:////./x`). A host must be a plain hostname: dot-separated labels
|
|
124
|
+
* of ASCII letters, digits, `-` and `_`. The authority is matched as
|
|
125
|
+
* written, never percent-decoded, so a percent-encoded host is
|
|
126
|
+
* `Option.none()`, as is a `.` or `?` host, a separator, `%`, NUL,
|
|
127
|
+
* userinfo, a port, an IPv6 literal, and a UNC share of `.` or `..`.
|
|
128
|
+
* - **`toPath` never answers a path win32 would open as something else**,
|
|
129
|
+
* and `fromPath` refuses one, through the same check: a segment naming a
|
|
130
|
+
* DOS device (`CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`,
|
|
131
|
+
* their superscript-digit variants, `CONIN$`, `CONOUT$`), in any case and
|
|
132
|
+
* with or without an extension (`file:///c:/con` and `file:///c:/a/nul.txt`
|
|
133
|
+
* are `Option.none()`); a segment holding a `:` after the drive (an NTFS
|
|
134
|
+
* alternate data stream, `file:///c:/a/b:stream`); and a segment ending in
|
|
135
|
+
* a dot or a space, which win32 trims, so `c:\a.` would open `c:\a`. Node
|
|
136
|
+
* reads all of these as paths.
|
|
137
|
+
* - **`.` and `..` segments are removed (RFC 3986 §5.2.4) and never climb
|
|
138
|
+
* above the root**, in both directions, including their encoded forms
|
|
139
|
+
* (`%2E%2E`). Node's WHATWG parser collapses them the same way; `vscode-uri`
|
|
140
|
+
* keeps them. A URI is never a path traversal: `file:///a/../../etc` is
|
|
141
|
+
* `/etc`.
|
|
142
|
+
* - **A Windows drive letter is lowercase in both directions.** `toPath`
|
|
143
|
+
* answers `c:\…` for `file:///C:/…`, `file:///c:/…` and VS Code's
|
|
144
|
+
* `file:///c%3A/…` alike, as `vscode-uri`'s `fsPath` does, so the paths of
|
|
145
|
+
* one file agree however a client spelled its URI; `fromPath` writes
|
|
146
|
+
* `file:///c:/…`, RFC 8089's form, with the colon literal. Compare
|
|
147
|
+
* documents by path, or by a URI this module wrote, never by the client's
|
|
148
|
+
* URI string.
|
|
149
|
+
* - **Encoding.** `fromPath` percent-encodes every character outside RFC
|
|
150
|
+
* 3986's unreserved set, `/` aside (non-ASCII as UTF-8 bytes, uppercase
|
|
151
|
+
* hex), as `vscode-uri` does; Node leaves more characters bare. Every
|
|
152
|
+
* decoder reads both.
|
|
153
|
+
* - **`localhost` is the local machine** (RFC 8089 §2): `file://localhost/a`
|
|
154
|
+
* is `/a`. Any other host is a UNC path under `"win32"`
|
|
155
|
+
* (`file://server/share/a` is `\\server\share\a`, host lowercased) and
|
|
156
|
+
* `Option.none()` under `"posix"`. So `file://localhost/share/a` is
|
|
157
|
+
* `Option.none()` under `"win32"`, as Node refuses it; `fromPath` writes a
|
|
158
|
+
* UNC path whose server is literally `localhost` with the host inside the
|
|
159
|
+
* path, `file:////localhost/share/a`, which `toPath` reads back as
|
|
160
|
+
* `\\localhost\share\a`. (Node's `pathToFileURL` writes that path as
|
|
161
|
+
* `file:///share/a`, a different file, and its `fileURLToPath` reads
|
|
162
|
+
* neither spelling.) An IDN host is read and written only in
|
|
163
|
+
* its ASCII (punycode) form; neither direction converts it.
|
|
164
|
+
* - A query or fragment is ignored, as Node ignores it.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { LspUri } from "@effected/lsp";
|
|
169
|
+
*
|
|
170
|
+
* console.log(LspUri.toPath("file:///home/me/My%20Project/a.ts"));
|
|
171
|
+
* // => Option.some("/home/me/My Project/a.ts")
|
|
172
|
+
* console.log(LspUri.toPath("untitled:Untitled-1"));
|
|
173
|
+
* // => Option.none()
|
|
174
|
+
* console.log(LspUri.fromPath("C:\\Users\\me\\a.ts", { platform: "win32" }));
|
|
175
|
+
* // => "file:///c:/Users/me/a.ts"
|
|
176
|
+
* ```
|
|
177
|
+
*
|
|
178
|
+
* @public
|
|
179
|
+
*/
|
|
180
|
+
var LspUri = class {
|
|
181
|
+
constructor() {}
|
|
182
|
+
/**
|
|
183
|
+
* The absolute path a `file:` URI names, or `Option.none()` when it names
|
|
184
|
+
* none: a different scheme, or a URI that is malformed or cannot be a path
|
|
185
|
+
* on `options.platform` (see the class remarks for every case).
|
|
186
|
+
*
|
|
187
|
+
* @param uri - A URI as a client sent it, e.g. a `textDocument.uri`.
|
|
188
|
+
* @param options - The path syntax to answer in; defaults to `"posix"`.
|
|
189
|
+
* @returns The path, `.` and `..` segments removed, or `Option.none()`.
|
|
190
|
+
*/
|
|
191
|
+
static toPath(uri, options) {
|
|
192
|
+
if (!FILE_SCHEME.test(uri)) return Option.none();
|
|
193
|
+
let rest = uri.slice(5);
|
|
194
|
+
const hash = rest.indexOf("#");
|
|
195
|
+
if (hash !== -1) rest = rest.slice(0, hash);
|
|
196
|
+
const query = rest.indexOf("?");
|
|
197
|
+
if (query !== -1) rest = rest.slice(0, query);
|
|
198
|
+
let authority = "";
|
|
199
|
+
let path = rest;
|
|
200
|
+
if (rest.startsWith("//")) {
|
|
201
|
+
const slash = rest.indexOf("/", 2);
|
|
202
|
+
authority = slash === -1 ? rest.slice(2) : rest.slice(2, slash);
|
|
203
|
+
path = slash === -1 ? "" : rest.slice(slash);
|
|
204
|
+
}
|
|
205
|
+
if (!path.startsWith("/")) return Option.none();
|
|
206
|
+
const win32 = options?.platform === "win32";
|
|
207
|
+
const tail = [];
|
|
208
|
+
for (const raw of path.slice(1).split("/")) {
|
|
209
|
+
const segment = decode(raw);
|
|
210
|
+
if (segment === void 0 || segment.includes("/") || segment.includes("\0")) return Option.none();
|
|
211
|
+
if (win32 && segment.includes("\\")) return Option.none();
|
|
212
|
+
tail.push(segment);
|
|
213
|
+
}
|
|
214
|
+
const local = authority.toLowerCase() === "localhost" ? "" : authority;
|
|
215
|
+
return win32 ? toWin32(local, tail) : toPosixPath(local, tail);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The `file:` URI of an absolute path.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* The path is taken literally apart from its `.` and `..` segments, which
|
|
222
|
+
* are removed so the URI round-trips: `toPath(fromPath(p))` is `p`
|
|
223
|
+
* normalized, and `p` itself for an already-normalized path (a Windows
|
|
224
|
+
* drive letter lowercased). Under `"win32"`, `\` and `/` both separate,
|
|
225
|
+
* and a UNC path `\\server\share\…` becomes `file://server/share/…`
|
|
226
|
+
* (`file:////localhost/share/…` for a server named `localhost`, since
|
|
227
|
+
* `file://localhost/` is the local machine).
|
|
228
|
+
*
|
|
229
|
+
* A path that is not absolute on `options.platform` (`a/b`, `C:a`, or a
|
|
230
|
+
* rooted `\a` with no drive), one holding a NUL, a lone surrogate, a Win32
|
|
231
|
+
* device or namespace path (`\\?\…`, `\\.\…`, `\??\…`), a UNC path
|
|
232
|
+
* whose server is not a plain hostname or whose share is missing, `.` or
|
|
233
|
+
* `..`, or, under `"win32"`, a segment naming a DOS device (`con`,
|
|
234
|
+
* `nul.txt`), holding a `:` after the drive (`b:stream`) or ending in a
|
|
235
|
+
* dot or a space throws a `TypeError`: these are
|
|
236
|
+
* programmer errors, since a server's own paths are absolute, and no URI
|
|
237
|
+
* could name them faithfully. Resolve a relative path first, with the
|
|
238
|
+
* platform's path module.
|
|
239
|
+
*
|
|
240
|
+
* @param path - An absolute path in `options.platform`'s syntax.
|
|
241
|
+
* @param options - The path syntax to read; defaults to `"posix"`.
|
|
242
|
+
* @returns The URI, e.g. `"file:///home/me/a%20b.ts"`.
|
|
243
|
+
*/
|
|
244
|
+
static fromPath(path, options) {
|
|
245
|
+
if (path.includes("\0")) throw new TypeError("LspUri.fromPath: a path cannot hold a NUL character");
|
|
246
|
+
if (options?.platform !== "win32") {
|
|
247
|
+
if (!path.startsWith("/")) throw new TypeError(`LspUri.fromPath: ${JSON.stringify(path)} is not an absolute posix path`);
|
|
248
|
+
return `file:///${removeDotSegments(path.slice(1).split("/")).map(encode).join("/")}`;
|
|
249
|
+
}
|
|
250
|
+
const refusal = win32Refusal(path);
|
|
251
|
+
if (refusal !== void 0) throw new TypeError(`LspUri.fromPath: ${JSON.stringify(path)} ${refusal}`);
|
|
252
|
+
const slashed = path.replaceAll("\\", "/");
|
|
253
|
+
if (slashed.startsWith("//")) {
|
|
254
|
+
const [server = "", share = "", ...rest] = slashed.slice(2).split("/");
|
|
255
|
+
const after = rest.length === 0 ? "" : `/${removeDotSegments(rest).map(encode).join("/")}`;
|
|
256
|
+
const host = server.toLowerCase();
|
|
257
|
+
return host === "localhost" ? `file:////${host}/${encode(share)}${after}` : `file://${host}/${encode(share)}${after}`;
|
|
258
|
+
}
|
|
259
|
+
const drive = slashed.charAt(0).toLowerCase();
|
|
260
|
+
const rest = slashed.slice(2);
|
|
261
|
+
const segments = rest === "" ? [] : rest.slice(1).split("/");
|
|
262
|
+
return `file:///${drive}:/${removeDotSegments(segments).map(encode).join("/")}`;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export { LspUri };
|
package/README.md
CHANGED
|
@@ -88,6 +88,22 @@ The kit does not own the message loop, so it cannot see `shutdown` or `exit` its
|
|
|
88
88
|
|
|
89
89
|
`LogToStderr` reaches Effect's default logger and `Logger.consolePretty`. `Logger.consoleJson`, `consoleLogFmt` and `consoleStructured` write through `console.log` regardless — wrap a formatter in `Logger.withConsoleError` instead — and `Console.log` still writes to stdout.
|
|
90
90
|
|
|
91
|
+
## LspUri
|
|
92
|
+
|
|
93
|
+
LSP names documents by `file:` URI, the file system by path. `LspUri` converts both ways without `node:url`, so it also runs in a worker:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { LspUri } from "@effected/lsp";
|
|
97
|
+
|
|
98
|
+
const platform = process.platform === "win32" ? "win32" : "posix";
|
|
99
|
+
|
|
100
|
+
LspUri.toPath("file:///home/me/My%20Project/a.ts", { platform }); // Option.some("/home/me/My Project/a.ts")
|
|
101
|
+
LspUri.toPath("untitled:Untitled-1", { platform }); // Option.none()
|
|
102
|
+
LspUri.fromPath("C:\\Users\\me\\a.ts", { platform: "win32" }); // "file:///c:/Users/me/a.ts"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`toPath` answers `Option.none()` instead of throwing for a URI that names no path: another scheme, a malformed escape, a `%2F` inside a name, a host on posix. `.` and `..` never climb above the root, a drive or a UNC share. The drive letter comes back lowercase however the client spelled it (`C:`, `c:` or VS Code's `c%3A`), so key documents by path, not by the client's URI string.
|
|
106
|
+
|
|
91
107
|
## LspProbe (`@effected/lsp/testing`)
|
|
92
108
|
|
|
93
109
|
The packed-install proof for a Language Server bin, the twin of `@effected/mcp/testing`'s `McpProbe`. It spawns the command and runs the lifecycle an editor runs — `initialize`, `initialized`, `shutdown`, `exit` — then waits for the server to exit:
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Result, Runtime, Schema, Stream } from "effect";
|
|
1
|
+
import { Effect, Option, Result, Runtime, Schema, Stream } from "effect";
|
|
2
2
|
/**
|
|
3
3
|
* Why a byte stream is not a sequence of LSP base-protocol frames.
|
|
4
4
|
*
|
|
@@ -305,5 +305,148 @@ export declare class LspStdio {
|
|
|
305
305
|
*/
|
|
306
306
|
static teardown(host: LspExitHost): Runtime.Teardown;
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Which path syntax {@link LspUri} reads and writes: `"posix"` (`/home/a`)
|
|
310
|
+
* or `"win32"` (`C:\Users\a`, `\\server\share\a`).
|
|
311
|
+
*
|
|
312
|
+
* @remarks
|
|
313
|
+
* Always passed explicitly, never read from the running process, so the
|
|
314
|
+
* conversion is pure and a worker or a test can convert either platform's
|
|
315
|
+
* paths anywhere. A server picks it once at its edge, typically from
|
|
316
|
+
* `process.platform === "win32"`.
|
|
317
|
+
*
|
|
318
|
+
* @public
|
|
319
|
+
*/
|
|
320
|
+
type LspUriPlatform = "posix" | "win32";
|
|
321
|
+
/**
|
|
322
|
+
* Options for {@link LspUri.toPath} and {@link LspUri.fromPath}.
|
|
323
|
+
*
|
|
324
|
+
* @public
|
|
325
|
+
*/
|
|
326
|
+
interface LspUriOptions {
|
|
327
|
+
/** The path syntax to read or write. Defaults to `"posix"`. */
|
|
328
|
+
readonly platform?: LspUriPlatform;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Conversion between `file:` URIs and absolute file-system paths, the two
|
|
332
|
+
* spellings every Language Server juggles: LSP names documents by URI
|
|
333
|
+
* (`textDocument.uri`, `rootUri`, `workspaceFolders`), the file system by
|
|
334
|
+
* path. Pure, with no `node:url` and no `process` read, so it runs in a
|
|
335
|
+
* worker or a browser. Not instantiable.
|
|
336
|
+
*
|
|
337
|
+
* @remarks
|
|
338
|
+
* Follows RFC 8089 and RFC 3986, and agrees with Node's `fileURLToPath` /
|
|
339
|
+
* `pathToFileURL` and VS Code's `vscode-uri` (`URI.file`, `fsPath`) wherever
|
|
340
|
+
* they agree with each other. The rulings where they do not:
|
|
341
|
+
*
|
|
342
|
+
* - **A non-`file:` scheme (`untitled:`, `vscode-notebook-cell:`) or a
|
|
343
|
+
* malformed URI is `Option.none()`, never a throw**: a client may name
|
|
344
|
+
* any document, and only `file:` documents have paths. So is a `%2F`
|
|
345
|
+
* inside a segment (it would smuggle a separator into one name), a `%5C`
|
|
346
|
+
* under `"win32"` (the same), a `%00`, a malformed or non-UTF-8 escape, a
|
|
347
|
+
* relative `file:` URI, a host under `"posix"`, and a rooted path with no
|
|
348
|
+
* drive or share under `"win32"`. Node throws for each of these.
|
|
349
|
+
* - **`toPath` never answers a Win32 device or namespace path**, nor any
|
|
350
|
+
* other path `fromPath` would refuse: `file://./pipe/x` and
|
|
351
|
+
* `file://%3F/C:/x` would read as `\\.\pipe\x` and `\\?\C:\x`, and
|
|
352
|
+
* are `Option.none()`, as is a host spelled inside the path
|
|
353
|
+
* (`file:////./x`). A host must be a plain hostname: dot-separated labels
|
|
354
|
+
* of ASCII letters, digits, `-` and `_`. The authority is matched as
|
|
355
|
+
* written, never percent-decoded, so a percent-encoded host is
|
|
356
|
+
* `Option.none()`, as is a `.` or `?` host, a separator, `%`, NUL,
|
|
357
|
+
* userinfo, a port, an IPv6 literal, and a UNC share of `.` or `..`.
|
|
358
|
+
* - **`toPath` never answers a path win32 would open as something else**,
|
|
359
|
+
* and `fromPath` refuses one, through the same check: a segment naming a
|
|
360
|
+
* DOS device (`CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`,
|
|
361
|
+
* their superscript-digit variants, `CONIN$`, `CONOUT$`), in any case and
|
|
362
|
+
* with or without an extension (`file:///c:/con` and `file:///c:/a/nul.txt`
|
|
363
|
+
* are `Option.none()`); a segment holding a `:` after the drive (an NTFS
|
|
364
|
+
* alternate data stream, `file:///c:/a/b:stream`); and a segment ending in
|
|
365
|
+
* a dot or a space, which win32 trims, so `c:\a.` would open `c:\a`. Node
|
|
366
|
+
* reads all of these as paths.
|
|
367
|
+
* - **`.` and `..` segments are removed (RFC 3986 §5.2.4) and never climb
|
|
368
|
+
* above the root**, in both directions, including their encoded forms
|
|
369
|
+
* (`%2E%2E`). Node's WHATWG parser collapses them the same way; `vscode-uri`
|
|
370
|
+
* keeps them. A URI is never a path traversal: `file:///a/../../etc` is
|
|
371
|
+
* `/etc`.
|
|
372
|
+
* - **A Windows drive letter is lowercase in both directions.** `toPath`
|
|
373
|
+
* answers `c:\…` for `file:///C:/…`, `file:///c:/…` and VS Code's
|
|
374
|
+
* `file:///c%3A/…` alike, as `vscode-uri`'s `fsPath` does, so the paths of
|
|
375
|
+
* one file agree however a client spelled its URI; `fromPath` writes
|
|
376
|
+
* `file:///c:/…`, RFC 8089's form, with the colon literal. Compare
|
|
377
|
+
* documents by path, or by a URI this module wrote, never by the client's
|
|
378
|
+
* URI string.
|
|
379
|
+
* - **Encoding.** `fromPath` percent-encodes every character outside RFC
|
|
380
|
+
* 3986's unreserved set, `/` aside (non-ASCII as UTF-8 bytes, uppercase
|
|
381
|
+
* hex), as `vscode-uri` does; Node leaves more characters bare. Every
|
|
382
|
+
* decoder reads both.
|
|
383
|
+
* - **`localhost` is the local machine** (RFC 8089 §2): `file://localhost/a`
|
|
384
|
+
* is `/a`. Any other host is a UNC path under `"win32"`
|
|
385
|
+
* (`file://server/share/a` is `\\server\share\a`, host lowercased) and
|
|
386
|
+
* `Option.none()` under `"posix"`. So `file://localhost/share/a` is
|
|
387
|
+
* `Option.none()` under `"win32"`, as Node refuses it; `fromPath` writes a
|
|
388
|
+
* UNC path whose server is literally `localhost` with the host inside the
|
|
389
|
+
* path, `file:////localhost/share/a`, which `toPath` reads back as
|
|
390
|
+
* `\\localhost\share\a`. (Node's `pathToFileURL` writes that path as
|
|
391
|
+
* `file:///share/a`, a different file, and its `fileURLToPath` reads
|
|
392
|
+
* neither spelling.) An IDN host is read and written only in
|
|
393
|
+
* its ASCII (punycode) form; neither direction converts it.
|
|
394
|
+
* - A query or fragment is ignored, as Node ignores it.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```ts
|
|
398
|
+
* import { LspUri } from "@effected/lsp";
|
|
399
|
+
*
|
|
400
|
+
* console.log(LspUri.toPath("file:///home/me/My%20Project/a.ts"));
|
|
401
|
+
* // => Option.some("/home/me/My Project/a.ts")
|
|
402
|
+
* console.log(LspUri.toPath("untitled:Untitled-1"));
|
|
403
|
+
* // => Option.none()
|
|
404
|
+
* console.log(LspUri.fromPath("C:\\Users\\me\\a.ts", { platform: "win32" }));
|
|
405
|
+
* // => "file:///c:/Users/me/a.ts"
|
|
406
|
+
* ```
|
|
407
|
+
*
|
|
408
|
+
* @public
|
|
409
|
+
*/
|
|
410
|
+
export declare class LspUri {
|
|
411
|
+
private constructor();
|
|
412
|
+
/**
|
|
413
|
+
* The absolute path a `file:` URI names, or `Option.none()` when it names
|
|
414
|
+
* none: a different scheme, or a URI that is malformed or cannot be a path
|
|
415
|
+
* on `options.platform` (see the class remarks for every case).
|
|
416
|
+
*
|
|
417
|
+
* @param uri - A URI as a client sent it, e.g. a `textDocument.uri`.
|
|
418
|
+
* @param options - The path syntax to answer in; defaults to `"posix"`.
|
|
419
|
+
* @returns The path, `.` and `..` segments removed, or `Option.none()`.
|
|
420
|
+
*/
|
|
421
|
+
static toPath(uri: string, options?: LspUriOptions): Option.Option<string>;
|
|
422
|
+
/**
|
|
423
|
+
* The `file:` URI of an absolute path.
|
|
424
|
+
*
|
|
425
|
+
* @remarks
|
|
426
|
+
* The path is taken literally apart from its `.` and `..` segments, which
|
|
427
|
+
* are removed so the URI round-trips: `toPath(fromPath(p))` is `p`
|
|
428
|
+
* normalized, and `p` itself for an already-normalized path (a Windows
|
|
429
|
+
* drive letter lowercased). Under `"win32"`, `\` and `/` both separate,
|
|
430
|
+
* and a UNC path `\\server\share\…` becomes `file://server/share/…`
|
|
431
|
+
* (`file:////localhost/share/…` for a server named `localhost`, since
|
|
432
|
+
* `file://localhost/` is the local machine).
|
|
433
|
+
*
|
|
434
|
+
* A path that is not absolute on `options.platform` (`a/b`, `C:a`, or a
|
|
435
|
+
* rooted `\a` with no drive), one holding a NUL, a lone surrogate, a Win32
|
|
436
|
+
* device or namespace path (`\\?\…`, `\\.\…`, `\??\…`), a UNC path
|
|
437
|
+
* whose server is not a plain hostname or whose share is missing, `.` or
|
|
438
|
+
* `..`, or, under `"win32"`, a segment naming a DOS device (`con`,
|
|
439
|
+
* `nul.txt`), holding a `:` after the drive (`b:stream`) or ending in a
|
|
440
|
+
* dot or a space throws a `TypeError`: these are
|
|
441
|
+
* programmer errors, since a server's own paths are absolute, and no URI
|
|
442
|
+
* could name them faithfully. Resolve a relative path first, with the
|
|
443
|
+
* platform's path module.
|
|
444
|
+
*
|
|
445
|
+
* @param path - An absolute path in `options.platform`'s syntax.
|
|
446
|
+
* @param options - The path syntax to read; defaults to `"posix"`.
|
|
447
|
+
* @returns The URI, e.g. `"file:///home/me/a%20b.ts"`.
|
|
448
|
+
*/
|
|
449
|
+
static fromPath(path: string, options?: LspUriOptions): string;
|
|
450
|
+
}
|
|
451
|
+
export type { LspExitHost, LspFrameDecoded, LspMessage, LspSessionEnd, LspUriOptions, LspUriPlatform };
|
|
309
452
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LspFrame, LspFrameError, LspFrameErrorCode } from "./LspFrame.js";
|
|
2
2
|
import { LspStdio } from "./LspStdio.js";
|
|
3
|
+
import { LspUri } from "./LspUri.js";
|
|
3
4
|
|
|
4
|
-
export { LspFrame, LspFrameError, LspFrameErrorCode, LspStdio };
|
|
5
|
+
export { LspFrame, LspFrameError, LspFrameErrorCode, LspStdio, LspUri };
|
package/package.json
CHANGED