@effected/lsp 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
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/LspFrame.js ADDED
@@ -0,0 +1,277 @@
1
+ import { Effect, Result, Schema, Stream } from "effect";
2
+
3
+ //#region src/LspFrame.ts
4
+ /** The longest header block, terminator excluded, the decoder buffers before failing `HeaderTooLarge`. */
5
+ const MAX_HEADER_BYTES = 8192;
6
+ /** How many bytes of an offending header an error echoes. */
7
+ const EXCERPT_BYTES = 80;
8
+ const CR = 13;
9
+ const LF = 10;
10
+ const EMPTY = /* @__PURE__ */ new Uint8Array(0);
11
+ const END = /* @__PURE__ */ new Uint8Array(0);
12
+ const encoder = new TextEncoder();
13
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
14
+ const lenient = new TextDecoder("utf-8");
15
+ const JsonValue = Schema.fromJsonString(Schema.Unknown);
16
+ const decodeJson = Schema.decodeUnknownResult(JsonValue);
17
+ const encodeJson = Schema.encodeUnknownSync(JsonValue);
18
+ const FIELD_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
19
+ const DIGITS = /^[0-9]+$/;
20
+ /**
21
+ * Why a byte stream is not a sequence of LSP base-protocol frames.
22
+ *
23
+ * @remarks
24
+ * - `MissingContentLength`: the header block has no `Content-Length` field.
25
+ * - `InvalidContentLength`: a `Content-Length` that is not one decimal,
26
+ * non-negative safe integer, or a second `Content-Length` field.
27
+ * - `InvalidHeader`: a header line that is not `Name: value`, or a header
28
+ * byte outside ASCII. A server that writes a log line to stdout before its
29
+ * first frame lands here, because the stray text joins the header block.
30
+ * - `HeaderTooLarge`: more than 8 KiB without the `\r\n\r\n` terminator. The
31
+ * decoder refuses to buffer an unterminated header without bound.
32
+ * - `InvalidBody`: the body is not valid UTF-8 or not valid JSON.
33
+ * - `Truncated`: the input ended inside a frame.
34
+ *
35
+ * @public
36
+ */
37
+ const LspFrameErrorCode = Schema.Literals([
38
+ "MissingContentLength",
39
+ "InvalidContentLength",
40
+ "InvalidHeader",
41
+ "HeaderTooLarge",
42
+ "InvalidBody",
43
+ "Truncated"
44
+ ]);
45
+ /**
46
+ * A byte stream that is not a sequence of LSP base-protocol frames.
47
+ *
48
+ * @remarks
49
+ * `offset` is the stream position of the offending frame's first byte, so a
50
+ * caller can point at it; `excerpt` echoes up to 80 bytes of the frame's
51
+ * start, JSON-quoted so control characters stay visible; `cause` carries the
52
+ * UTF-8 or JSON failure of an `InvalidBody` as the original value, never a
53
+ * stringified copy. `message` is derived from those fields when read.
54
+ *
55
+ * @public
56
+ */
57
+ var LspFrameError = class extends Schema.TaggedError()("LspFrameError", {
58
+ /** Which rule the frame broke; see {@link (LspFrameErrorCode:variable)}. */
59
+ code: LspFrameErrorCode,
60
+ /** The stream position of the offending frame's first byte. */
61
+ offset: Schema.Number,
62
+ /** Up to 80 bytes of the frame's start, JSON-quoted. */
63
+ excerpt: Schema.optionalKey(Schema.String),
64
+ /** The UTF-8 or JSON failure behind an `InvalidBody`. */
65
+ cause: Schema.optionalKey(Schema.Defect())
66
+ }) {
67
+ get message() {
68
+ const at = `the LSP frame at byte ${this.offset}`;
69
+ const detail = this.code === "MissingContentLength" ? `${at} has no Content-Length header` : this.code === "InvalidContentLength" ? `${at} has a Content-Length that is not one non-negative decimal integer` : this.code === "InvalidHeader" ? `${at} has a header line that is not an ASCII "Name: value" field` : this.code === "HeaderTooLarge" ? `${at} has no \\r\\n\\r\\n header terminator within ${MAX_HEADER_BYTES} bytes` : this.code === "InvalidBody" ? `${at} has a body that is not UTF-8 JSON` : `the input ended inside ${at}`;
70
+ return this.excerpt === void 0 ? detail : `${detail}: ${this.excerpt}`;
71
+ }
72
+ };
73
+ const excerptOf = (bytes, start) => JSON.stringify(lenient.decode(bytes.subarray(start, Math.min(bytes.length, start + EXCERPT_BYTES))));
74
+ const frameError = (code, offset, bytes, start) => LspFrameError.make({
75
+ code,
76
+ offset,
77
+ excerpt: excerptOf(bytes, start)
78
+ });
79
+ /** The index of the `\r\n\r\n` that ends the header starting at `from`, or -1, scanning at most the header cap. */
80
+ const headerEnd = (bytes, from) => {
81
+ const limit = Math.min(bytes.length - 3, from + MAX_HEADER_BYTES + 1);
82
+ for (let i = from; i < limit; i++) if (bytes[i] === CR && bytes[i + 1] === LF && bytes[i + 2] === CR && bytes[i + 3] === LF) return i;
83
+ return -1;
84
+ };
85
+ const isOptionalWhitespace = (code) => code === 32 || code === 9;
86
+ /**
87
+ * `value` without leading and trailing spaces and tabs (a header value's optional whitespace). An index scan, not a
88
+ * regex: a trailing-whitespace pattern backtracks quadratically over a long run of whitespace followed by anything
89
+ * else, and header bytes are untrusted input.
90
+ */
91
+ const trimOptionalWhitespace = (value) => {
92
+ let start = 0;
93
+ let end = value.length;
94
+ while (start < end && isOptionalWhitespace(value.charCodeAt(start))) start++;
95
+ while (end > start && isOptionalWhitespace(value.charCodeAt(end - 1))) end--;
96
+ return value.slice(start, end);
97
+ };
98
+ /** The `Content-Length` of the header block `bytes[from, to)`, or the code it breaks. */
99
+ const contentLength = (bytes, from, to) => {
100
+ let text = "";
101
+ for (let i = from; i < to; i++) {
102
+ const byte = bytes[i] ?? 0;
103
+ if (byte > 127) return "InvalidHeader";
104
+ text += String.fromCharCode(byte);
105
+ }
106
+ if (text === "") return "MissingContentLength";
107
+ let length;
108
+ for (const line of text.split("\r\n")) {
109
+ const colon = line.indexOf(":");
110
+ if (colon <= 0 || line.includes("\r") || line.includes("\n")) return "InvalidHeader";
111
+ const name = line.slice(0, colon);
112
+ if (!FIELD_NAME.test(name)) return "InvalidHeader";
113
+ if (name.toLowerCase() !== "content-length") continue;
114
+ const value = trimOptionalWhitespace(line.slice(colon + 1));
115
+ if (length !== void 0 || !DIGITS.test(value)) return "InvalidContentLength";
116
+ const parsed = Number(value);
117
+ if (!Number.isSafeInteger(parsed)) return "InvalidContentLength";
118
+ length = parsed;
119
+ }
120
+ return length ?? "MissingContentLength";
121
+ };
122
+ const concatBytes = (left, right) => {
123
+ if (left.length === 0) return right;
124
+ if (right.length === 0) return left;
125
+ const joined = new Uint8Array(left.length + right.length);
126
+ joined.set(left, 0);
127
+ joined.set(right, left.length);
128
+ return joined;
129
+ };
130
+ const decodeResult = (bytes, offset = 0) => {
131
+ const messages = [];
132
+ let position = 0;
133
+ while (position < bytes.length) {
134
+ const start = position;
135
+ const end = headerEnd(bytes, start);
136
+ if (end === -1 ? bytes.length - start - 3 > MAX_HEADER_BYTES : end - start > MAX_HEADER_BYTES) return Result.fail(frameError("HeaderTooLarge", offset + start, bytes, start));
137
+ if (end === -1) break;
138
+ const length = contentLength(bytes, start, end);
139
+ if (typeof length === "string") return Result.fail(frameError(length, offset + start, bytes, start));
140
+ const bodyStart = end + 4;
141
+ if (bodyStart + length > bytes.length) break;
142
+ const body = bytes.subarray(bodyStart, bodyStart + length);
143
+ let text;
144
+ try {
145
+ text = utf8.decode(body);
146
+ } catch (cause) {
147
+ return Result.fail(LspFrameError.make({
148
+ code: "InvalidBody",
149
+ offset: offset + start,
150
+ excerpt: excerptOf(bytes, start),
151
+ cause
152
+ }));
153
+ }
154
+ const value = decodeJson(text);
155
+ if (Result.isFailure(value)) return Result.fail(LspFrameError.make({
156
+ code: "InvalidBody",
157
+ offset: offset + start,
158
+ excerpt: excerptOf(bytes, start),
159
+ cause: value.failure
160
+ }));
161
+ messages.push(value.success);
162
+ position = bodyStart + length;
163
+ }
164
+ return Result.succeed({
165
+ messages,
166
+ rest: bytes.slice(position)
167
+ });
168
+ };
169
+ const decodeAllResult = (input) => {
170
+ const bytes = typeof input === "string" ? encoder.encode(input) : input;
171
+ return Result.flatMap(decodeResult(bytes), ({ messages, rest }) => rest.length === 0 ? Result.succeed(messages) : Result.fail(frameError("Truncated", bytes.length - rest.length, rest, 0)));
172
+ };
173
+ /**
174
+ * The LSP base protocol's framing: a `Content-Length` header block, a blank
175
+ * line, and a UTF-8 JSON body of exactly that many bytes.
176
+ *
177
+ * @remarks
178
+ * `Content-Length` counts **bytes**, never characters. A hand-rolled frame
179
+ * that writes `body.length` is correct for ASCII and wrong by one for every
180
+ * extra byte of a multi-byte character, so the server reads into the next
181
+ * frame and the session fails far from the cause; {@link LspFrame.encode}
182
+ * counts the UTF-8 encoding.
183
+ *
184
+ * The decoder is incremental and pure: {@link LspFrame.decodeResult} takes a
185
+ * buffer and returns every complete frame plus the bytes of an incomplete
186
+ * trailing one, so a chunk boundary may fall anywhere — inside a header,
187
+ * inside a body, inside a multi-byte character — and one chunk may carry
188
+ * several frames. {@link LspFrame.decodeStream} threads that state across a
189
+ * `Stream` of chunks.
190
+ *
191
+ * Header fields end `\r\n` and the block ends `\r\n\r\n`; field names are
192
+ * case-insensitive; unknown fields such as `Content-Type` are accepted and
193
+ * ignored. A header longer than 8 KiB without its terminator fails rather
194
+ * than buffering without bound.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * import { LspFrame } from "@effected/lsp";
199
+ * import { Result } from "effect";
200
+ *
201
+ * const bytes = LspFrame.encode({ jsonrpc: "2.0", method: "exit" });
202
+ * const decoded = LspFrame.decodeAllResult(bytes);
203
+ * console.log(Result.isSuccess(decoded) ? decoded.success : decoded.failure.message);
204
+ * // => [ { jsonrpc: '2.0', method: 'exit' } ]
205
+ * ```
206
+ *
207
+ * @public
208
+ */
209
+ var LspFrame = class {
210
+ constructor() {}
211
+ /**
212
+ * Frame one message: `Content-Length: N\r\n\r\n` followed by its JSON text,
213
+ * where `N` is the UTF-8 byte length of that text.
214
+ *
215
+ * @remarks
216
+ * A value JSON cannot encode (a `bigint`, a cycle) is a caller bug and
217
+ * throws; nothing a server sends reaches this function.
218
+ */
219
+ static encode(message) {
220
+ const body = encoder.encode(encodeJson(message));
221
+ const header = encoder.encode(`Content-Length: ${body.length}\r\n\r\n`);
222
+ return concatBytes(header, body);
223
+ }
224
+ /**
225
+ * Decode every complete frame at the front of `bytes`, returning their
226
+ * bodies and the bytes of an incomplete trailing frame.
227
+ *
228
+ * @remarks
229
+ * The sync primitive; {@link LspFrame.decode} is its spanned `Effect`
230
+ * form. `offset` is the stream position of `bytes[0]` (default 0), so an
231
+ * error names a stream position rather than a buffer index. To decode a
232
+ * stream by hand, keep `rest`, prepend it to the next chunk, and advance
233
+ * `offset` by the bytes consumed.
234
+ */
235
+ static decodeResult(bytes, offset) {
236
+ return decodeResult(bytes, offset);
237
+ }
238
+ /** The `Effect` form of {@link LspFrame.decodeResult}, behind the `LspFrame.decode` span. */
239
+ static decode = Effect.fn("LspFrame.decode")((bytes, offset) => Effect.fromResult(decodeResult(bytes, offset)));
240
+ /**
241
+ * Decode a complete buffer — a collected stdout — into its messages.
242
+ *
243
+ * @remarks
244
+ * A string is UTF-8 encoded first, so its byte counts are honoured.
245
+ * Leftover bytes after the last complete frame fail `Truncated`. The sync
246
+ * primitive; {@link LspFrame.decodeAll} is the spanned `Effect` form.
247
+ */
248
+ static decodeAllResult(input) {
249
+ return decodeAllResult(input);
250
+ }
251
+ /** The `Effect` form of {@link LspFrame.decodeAllResult}, behind the `LspFrame.decodeAll` span. */
252
+ static decodeAll = Effect.fn("LspFrame.decodeAll")((input) => Effect.fromResult(decodeAllResult(input)));
253
+ /**
254
+ * Decode a stream of byte chunks into a stream of message bodies.
255
+ *
256
+ * @remarks
257
+ * Chunk boundaries may fall anywhere. A malformed frame fails the stream
258
+ * with an {@link LspFrameError}; a stream that ends inside a frame fails
259
+ * `Truncated`, so a server that dies mid-write is never read as a clean end.
260
+ */
261
+ static decodeStream(self) {
262
+ return Stream.concat(self, Stream.succeed(END)).pipe(Stream.mapAccumEffect(() => ({
263
+ rest: EMPTY,
264
+ offset: 0
265
+ }), (state, chunk) => {
266
+ if (chunk === END) return state.rest.length === 0 ? Effect.succeed([state, []]) : Effect.fail(frameError("Truncated", state.offset, state.rest, 0));
267
+ const buffer = concatBytes(state.rest, chunk);
268
+ return Effect.map(Effect.fromResult(decodeResult(buffer, state.offset)), ({ messages, rest }) => [{
269
+ rest,
270
+ offset: state.offset + buffer.length - rest.length
271
+ }, messages]);
272
+ }));
273
+ }
274
+ };
275
+
276
+ //#endregion
277
+ export { LspFrame, LspFrameError, LspFrameErrorCode };
package/LspProbe.js ADDED
@@ -0,0 +1,126 @@
1
+ import { LspTestFailure } from "./LspTestFailure.js";
2
+ import { truncate } from "./internal/messages.js";
3
+ import { spawnParts } from "./LspProcess.js";
4
+ import { Duration, Effect, Ref } from "effect";
5
+
6
+ //#region src/LspProbe.ts
7
+ /**
8
+ * The smallest proof that an installed Language Server bin boots: the whole
9
+ * LSP lifecycle over stdio, and exit code 0.
10
+ *
11
+ * @remarks
12
+ * The exchange is `initialize` (id 1), its response, the `initialized`
13
+ * notification, `shutdown` (id 2), its response, and the `exit`
14
+ * notification; then the probe waits for the server to exit. The caller
15
+ * asserts `response.error === undefined`, `exitCode === 0` and, for a clean
16
+ * boot, `stderr === ""` — this is the LSP half of a packed-install proof, the
17
+ * twin of `@effected/mcp/testing`'s `McpProbe`. Every frame is encoded with
18
+ * `LspFrame.encode` from the main entry, so `Content-Length` counts bytes.
19
+ *
20
+ * - `initialized` is sent because the specification requires it before any
21
+ * other request, `shutdown` included; it is skipped only when `initialize`
22
+ * answered with an error, and the sequence still ends `shutdown`, `exit`.
23
+ * - Stdin stays open after `exit`. The server must terminate itself on
24
+ * `exit`, as an editor never closes the pipe first; a server that waits
25
+ * for stdin to close fails with `TimedOut` instead of passing.
26
+ * - A request the server sends (`client/registerCapability`, say) is
27
+ * recorded in `messages` and never answered.
28
+ * - Never hangs: stdout that ends before a response fails `StreamEnded` with
29
+ * the exit code and stderr; bytes that are not a frame fail `InvalidFrame`;
30
+ * and the whole exchange runs under `timeout` (30 seconds by default),
31
+ * which fails `TimedOut` naming the step it was waiting on. The child is
32
+ * killed when the probe's scope closes.
33
+ * - The timeout reads `Clock`, so under `it.effect`'s virtual clock it
34
+ * never fires on its own: run the probe under `it.live`.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { LspProbe } from "@effected/lsp/testing";
39
+ * import { Effect } from "effect";
40
+ * import { ChildProcess } from "effect/process";
41
+ *
42
+ * const program = Effect.gen(function* () {
43
+ * const probe = yield* LspProbe.initialize(ChildProcess.make(process.execPath, ["./bin/lsp.js", "--stdio"]));
44
+ * // A clean boot: no JSON-RPC error, exit code 0, empty stderr.
45
+ * return probe.response.error === undefined && probe.exitCode === 0 && probe.stderr === "";
46
+ * });
47
+ * // Requires a `ChildProcessSpawner` from the platform layer.
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ var LspProbe = class {
53
+ constructor() {}
54
+ /**
55
+ * Spawn `command`, run `initialize` → `initialized` → `shutdown` → `exit`,
56
+ * and collect every server message, stderr and the exit code.
57
+ */
58
+ static initialize = Effect.fn("LspProbe.initialize")(function* (command, options = {}) {
59
+ return yield* Effect.scoped(probe(command, options));
60
+ });
61
+ };
62
+ const probe = (command, options) => Effect.gen(function* () {
63
+ const stage = yield* Ref.make("the id-1 initialize response");
64
+ const server = yield* spawnParts(command, {
65
+ pending: Ref.get(stage),
66
+ settle: true
67
+ });
68
+ const messages = [];
69
+ const responseTo = (id) => Effect.map(server.readUntilResponse(id), ({ response, seen }) => {
70
+ messages.push(...seen);
71
+ return response;
72
+ });
73
+ return yield* Effect.gen(function* () {
74
+ yield* server.send({
75
+ jsonrpc: "2.0",
76
+ id: 1,
77
+ method: "initialize",
78
+ params: {
79
+ processId: options.processId ?? null,
80
+ rootUri: options.rootUri ?? null,
81
+ capabilities: options.capabilities ?? {},
82
+ ...options.initializationOptions === void 0 ? {} : { initializationOptions: options.initializationOptions }
83
+ }
84
+ });
85
+ const response = yield* responseTo(1);
86
+ if (response.error === void 0) yield* server.send({
87
+ jsonrpc: "2.0",
88
+ method: "initialized",
89
+ params: {}
90
+ });
91
+ yield* Ref.set(stage, "the id-2 shutdown response");
92
+ yield* server.send({
93
+ jsonrpc: "2.0",
94
+ id: 2,
95
+ method: "shutdown"
96
+ });
97
+ const shutdown = yield* responseTo(2);
98
+ yield* Ref.set(stage, "the server's exit after the exit notification");
99
+ yield* server.send({
100
+ jsonrpc: "2.0",
101
+ method: "exit"
102
+ });
103
+ const exitCode = yield* server.exitCode;
104
+ yield* Ref.set(stage, "the end of stdout after the server exited");
105
+ while (true) if (!(yield* Effect.catchIf(Effect.map(server.nextMessage, (message) => messages.push(message) > 0), (failure) => failure.reason === "StreamEnded", () => Effect.succeed(false)))) break;
106
+ return {
107
+ response,
108
+ shutdown,
109
+ messages,
110
+ stderr: yield* server.stderrFinal,
111
+ exitCode
112
+ };
113
+ }).pipe(Effect.timeoutOrElse({
114
+ duration: options.timeout ?? Duration.seconds(30),
115
+ orElse: () => Effect.gen(function* () {
116
+ const text = yield* server.stderrSoFar;
117
+ return yield* new LspTestFailure({
118
+ reason: "TimedOut",
119
+ message: `timed out waiting for ${yield* Ref.get(stage)}; stderr so far: ${text === "" ? "(empty)" : truncate(text)}`
120
+ });
121
+ })
122
+ }));
123
+ });
124
+
125
+ //#endregion
126
+ export { LspProbe };