@effected/lsp 0.4.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/LspFrame.js +1 -2
- package/LspProbe.js +1 -2
- package/LspProcess.js +1 -2
- package/LspStdio.js +1 -2
- package/LspTestFailure.js +1 -2
- package/LspUri.js +266 -0
- package/README.md +16 -0
- package/index.d.ts +145 -8
- package/index.js +2 -1
- package/internal/LaunchFailed.js +1 -2
- package/internal/messages.js +1 -2
- package/package.json +1 -1
- package/testing.d.ts +0 -8
- package/tsdoc-metadata.json +1 -1
package/LspFrame.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Result, Schema, Stream } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/LspFrame.ts
|
|
4
4
|
/** The longest header block, terminator excluded, the decoder buffers before failing `HeaderTooLarge`. */
|
|
5
5
|
const MAX_HEADER_BYTES = 8192;
|
|
6
6
|
/** How many bytes of an offending header an error echoes. */
|
|
@@ -273,5 +273,4 @@ var LspFrame = class {
|
|
|
273
273
|
}
|
|
274
274
|
};
|
|
275
275
|
|
|
276
|
-
//#endregion
|
|
277
276
|
export { LspFrame, LspFrameError, LspFrameErrorCode };
|
package/LspProbe.js
CHANGED
|
@@ -3,7 +3,7 @@ import { truncate } from "./internal/messages.js";
|
|
|
3
3
|
import { spawnParts } from "./LspProcess.js";
|
|
4
4
|
import { Duration, Effect, Ref } from "effect";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// src/LspProbe.ts
|
|
7
7
|
/**
|
|
8
8
|
* The smallest proof that an installed Language Server bin boots: the whole
|
|
9
9
|
* LSP lifecycle over stdio, and exit code 0.
|
|
@@ -122,5 +122,4 @@ const probe = (command, options) => Effect.gen(function* () {
|
|
|
122
122
|
}));
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
//#endregion
|
|
126
125
|
export { LspProbe };
|
package/LspProcess.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isLspMessage, notJsonRpc, truncate } from "./internal/messages.js";
|
|
|
4
4
|
import { Cause, Deferred, Duration, Effect, Option, Queue, Ref, Result, Stream, SubscriptionRef } from "effect";
|
|
5
5
|
import { ChildProcessSpawner } from "effect/process";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// src/LspProcess.ts
|
|
8
8
|
const concat = (chunks) => {
|
|
9
9
|
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
|
10
10
|
let at = 0;
|
|
@@ -278,5 +278,4 @@ const spawnParts = (command, options = {}) => Effect.gen(function* () {
|
|
|
278
278
|
};
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
-
//#endregion
|
|
282
281
|
export { LspProcess, spawnParts };
|
package/LspStdio.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LaunchFailed } from "./internal/LaunchFailed.js";
|
|
2
2
|
import { Cause, Effect, Exit, References, Runtime } from "effect";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/LspStdio.ts
|
|
5
5
|
/**
|
|
6
6
|
* Run an Effect Language Server over stdio without ever writing a log line or
|
|
7
7
|
* a failure report onto stdout, which is the LSP wire, and exit with the code
|
|
@@ -113,5 +113,4 @@ var LspStdio = class LspStdio {
|
|
|
113
113
|
}
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
//#endregion
|
|
117
116
|
export { LspStdio };
|
package/LspTestFailure.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/LspTestFailure.ts
|
|
4
4
|
/**
|
|
5
5
|
* Why {@link LspProbe.initialize} could not prove a Language Server boots.
|
|
6
6
|
*
|
|
@@ -33,5 +33,4 @@ var LspTestFailure = class extends Schema.TaggedError()("LspTestFailure", {
|
|
|
33
33
|
message: Schema.String
|
|
34
34
|
}) {};
|
|
35
35
|
|
|
36
|
-
//#endregion
|
|
37
36
|
export { LspTestFailure };
|
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,5 +1,4 @@
|
|
|
1
|
-
import { Effect, Result, Runtime, Schema, Stream } from "effect";
|
|
2
|
-
//#region src/LspFrame.d.ts
|
|
1
|
+
import { Effect, Option, Result, Runtime, Schema, Stream } from "effect";
|
|
3
2
|
/**
|
|
4
3
|
* Why a byte stream is not a sequence of LSP base-protocol frames.
|
|
5
4
|
*
|
|
@@ -142,8 +141,6 @@ export declare class LspFrame {
|
|
|
142
141
|
*/
|
|
143
142
|
static decodeStream<E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<unknown, E | LspFrameError, R>;
|
|
144
143
|
}
|
|
145
|
-
//#endregion
|
|
146
|
-
//#region src/LspMessage.d.ts
|
|
147
144
|
/**
|
|
148
145
|
* A JSON-RPC 2.0 message as an LSP frame body carries it: a request (an `id`
|
|
149
146
|
* and a `method`), a notification (a `method`, no `id`) or a response (an
|
|
@@ -176,8 +173,6 @@ interface LspMessage {
|
|
|
176
173
|
readonly data?: unknown;
|
|
177
174
|
};
|
|
178
175
|
}
|
|
179
|
-
//#endregion
|
|
180
|
-
//#region src/LspStdio.d.ts
|
|
181
176
|
/**
|
|
182
177
|
* How a Language Server session ended, as the server's own message loop
|
|
183
178
|
* observed it. {@link LspStdio.exitCode} maps it to the exit code the LSP
|
|
@@ -310,6 +305,148 @@ export declare class LspStdio {
|
|
|
310
305
|
*/
|
|
311
306
|
static teardown(host: LspExitHost): Runtime.Teardown;
|
|
312
307
|
}
|
|
313
|
-
|
|
314
|
-
|
|
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 };
|
|
315
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/internal/LaunchFailed.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Runtime } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/internal/LaunchFailed.ts
|
|
4
4
|
/**
|
|
5
5
|
* The failure `LspStdio.launch` re-raises after reporting a failed server on
|
|
6
6
|
* stderr itself. `errorReported: false` keeps `runMain` from reporting it a
|
|
@@ -31,5 +31,4 @@ var LaunchFailed = class extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
//#endregion
|
|
35
34
|
export { LaunchFailed };
|
package/internal/messages.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LspTestFailure } from "../LspTestFailure.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/internal/messages.ts
|
|
4
4
|
/** How much text a failure message echoes. */
|
|
5
5
|
const ECHO = 2e3;
|
|
6
6
|
/**
|
|
@@ -26,5 +26,4 @@ const notJsonRpc = (message) => new LspTestFailure({
|
|
|
26
26
|
message: `the server sent a frame that is not a JSON-RPC 2.0 message: ${truncate(JSON.stringify(message) ?? String(message))}`
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
//#endregion
|
|
30
29
|
export { isLspMessage, notJsonRpc, truncate };
|
package/package.json
CHANGED
package/testing.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Duration, Effect, PlatformError, Schema, Scope } from "effect";
|
|
2
2
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
3
|
-
//#region src/LspMessage.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* A JSON-RPC 2.0 message as an LSP frame body carries it: a request (an `id`
|
|
6
5
|
* and a `method`), a notification (a `method`, no `id`) or a response (an
|
|
@@ -33,8 +32,6 @@ interface LspMessage {
|
|
|
33
32
|
readonly data?: unknown;
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
|
-
//#endregion
|
|
37
|
-
//#region src/LspTestFailure.d.ts
|
|
38
35
|
declare const LspTestFailure_base: Schema.Class<LspTestFailure, Schema.TaggedStruct<"LspTestFailure", {
|
|
39
36
|
/** Which failure occurred; see the list above. */
|
|
40
37
|
readonly reason: Schema.Literals<readonly ["StreamEnded", "InvalidFrame", "NotJsonRpc", "TimedOut"]>;
|
|
@@ -62,8 +59,6 @@ declare const LspTestFailure_base: Schema.Class<LspTestFailure, Schema.TaggedStr
|
|
|
62
59
|
* @public
|
|
63
60
|
*/
|
|
64
61
|
export declare class LspTestFailure extends LspTestFailure_base {}
|
|
65
|
-
//#endregion
|
|
66
|
-
//#region src/LspProbe.d.ts
|
|
67
62
|
/**
|
|
68
63
|
* Options for {@link LspProbe.initialize}.
|
|
69
64
|
*
|
|
@@ -153,8 +148,6 @@ export declare class LspProbe {
|
|
|
153
148
|
*/
|
|
154
149
|
static readonly initialize: (command: ChildProcess.Command, options?: LspProbeOptions) => Effect.Effect<LspProbeResult, LspTestFailure | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
|
|
155
150
|
}
|
|
156
|
-
//#endregion
|
|
157
|
-
//#region src/LspProcess.d.ts
|
|
158
151
|
/**
|
|
159
152
|
* Options for {@link LspProcess.stderrUntil}.
|
|
160
153
|
*
|
|
@@ -302,6 +295,5 @@ export declare class LspProcess {
|
|
|
302
295
|
/** Spawn `command` for the life of the current scope. */
|
|
303
296
|
static readonly spawn: (command: ChildProcess.Command) => Effect.Effect<LspProcess, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope>;
|
|
304
297
|
}
|
|
305
|
-
//#endregion
|
|
306
298
|
export type { LspMessage, LspProbeOptions, LspProbeResult, LspProcessStderrUntilOptions };
|
|
307
299
|
//# sourceMappingURL=testing.d.ts.map
|