@remnic/coding-graph 9.3.759
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP degradation codes — shaped like {@link SearchDegradation} from
|
|
3
|
+
* @remnic/core/search/port (issue #1536, CLAUDE.md rule 34).
|
|
4
|
+
*
|
|
5
|
+
* Every failure mode produces a DISTINCT code so callers (index_status,
|
|
6
|
+
* remnic doctor) can render per-language LSP state without conflating
|
|
7
|
+
* "server not installed" with "server timed out" or "protocol violation".
|
|
8
|
+
*
|
|
9
|
+
* The codes are the load-bearing signal — `detail` is optional and never
|
|
10
|
+
* carries `error.message` (which may contain absolute paths — rule 11).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Backend identifier — always `"lsp"` so callers can distinguish LSP
|
|
15
|
+
* degradations from QMD/search degradations in a unified handler.
|
|
16
|
+
*/
|
|
17
|
+
export type LspBackend = "lsp";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Distinct failure codes (rule 34 — never `[]`-on-error).
|
|
21
|
+
*
|
|
22
|
+
* - `server_missing` — the configured server binary is not on PATH
|
|
23
|
+
* or is not executable (probe phase).
|
|
24
|
+
* - `handshake_timeout` — `initialize` did not complete within
|
|
25
|
+
* `lsp.timeoutMs`.
|
|
26
|
+
* - `handshake_error` — server responded to `initialize` with an
|
|
27
|
+
* error or a malformed response.
|
|
28
|
+
* - `request_timeout` — a `textDocument/definition` request did
|
|
29
|
+
* not receive a response within the
|
|
30
|
+
* per-request deadline.
|
|
31
|
+
* - `request_error` — server returned a JSON-RPC error response
|
|
32
|
+
* for a resolution request.
|
|
33
|
+
* - `protocol_error` — malformed JSON-RPC frame (bad header,
|
|
34
|
+
* unparseable JSON, unknown method).
|
|
35
|
+
* - `server_crashed` — the child process exited unexpectedly
|
|
36
|
+
* mid-run.
|
|
37
|
+
* - `budget_exhausted` — `lsp.maxRequestsPerRun` reached; remaining
|
|
38
|
+
* call sites keep their Phase A resolution.
|
|
39
|
+
* - `not_enabled` — `lsp.enabled` is `false`; no probe attempted.
|
|
40
|
+
* - `unknown_language` — the file's language has no registered
|
|
41
|
+
* server spec and none was overridden in
|
|
42
|
+
* `lsp.servers`.
|
|
43
|
+
*/
|
|
44
|
+
export type LspDegradationCode =
|
|
45
|
+
| "server_missing"
|
|
46
|
+
| "handshake_timeout"
|
|
47
|
+
| "handshake_error"
|
|
48
|
+
| "request_timeout"
|
|
49
|
+
| "request_error"
|
|
50
|
+
| "protocol_error"
|
|
51
|
+
| "server_crashed"
|
|
52
|
+
| "budget_exhausted"
|
|
53
|
+
| "not_enabled"
|
|
54
|
+
| "unknown_language";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tagged degradation — mirrors the shape of {@link SearchDegradation}.
|
|
58
|
+
* `ok: false` results from the client/registry/resolution surface carry
|
|
59
|
+
* this object so consumers can switch on `code` programmatically.
|
|
60
|
+
*/
|
|
61
|
+
export interface LspDegradation {
|
|
62
|
+
readonly backend: LspBackend;
|
|
63
|
+
readonly code: LspDegradationCode;
|
|
64
|
+
readonly detail?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tagged-result helpers — the shared discriminated-union pattern used
|
|
69
|
+
* throughout the coding-graph package (rule 34). Every LSP surface
|
|
70
|
+
* returns `{ ok: true; … }` on success or `{ ok: false; degradation }`
|
|
71
|
+
* on failure — never throws, never returns `[]` to mean "error".
|
|
72
|
+
*/
|
|
73
|
+
export type LspResult<T> =
|
|
74
|
+
| ({ readonly ok: true } & T)
|
|
75
|
+
| { readonly ok: false; readonly degradation: LspDegradation };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Construct a degradation object. Kept as a factory rather than a class
|
|
79
|
+
* so callers can spread it into a result without `new`.
|
|
80
|
+
*/
|
|
81
|
+
export function lspDegradation(
|
|
82
|
+
code: LspDegradationCode,
|
|
83
|
+
detail?: string,
|
|
84
|
+
): LspDegradation {
|
|
85
|
+
return detail !== undefined ? { backend: "lsp", code, detail } : { backend: "lsp", code };
|
|
86
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fake LSP server — a scripted JSON-RPC-over-stdio responder for tests.
|
|
4
|
+
*
|
|
5
|
+
* Checked into the package's test fixtures. Synthetic responses only —
|
|
6
|
+
* it does NOT analyze code. Instead it reads a scenario from argv and
|
|
7
|
+
* produces canned LSP-shaped responses that match the real protocol
|
|
8
|
+
* wire format (rule 33 — cite spec version: LSP 3.17).
|
|
9
|
+
*
|
|
10
|
+
* Scenario is passed as the LAST argv element (after `--`):
|
|
11
|
+
*
|
|
12
|
+
* node fake-server.mjs -- <scenario-name>
|
|
13
|
+
*
|
|
14
|
+
* Scenarios:
|
|
15
|
+
* happy — full handshake + returns definition locations.
|
|
16
|
+
* missing — exits immediately with code 1.
|
|
17
|
+
* handshake_timeout — accepts initialize request but never responds.
|
|
18
|
+
* request_timeout — completes handshake but never responds to definition.
|
|
19
|
+
* protocol_error — sends a malformed frame after receiving initialize.
|
|
20
|
+
* crash_after_start — completes handshake, then exits mid-run.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import process from "node:process";
|
|
24
|
+
|
|
25
|
+
const scenario = process.argv[process.argv.length - 1];
|
|
26
|
+
|
|
27
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Persistent stdin buffer — survives across readFrame calls so data from
|
|
29
|
+
// a combined chunk that contains multiple frames is not lost.
|
|
30
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
let stdinBuf = "";
|
|
33
|
+
let stdinWaiters = [];
|
|
34
|
+
|
|
35
|
+
process.stdin.setEncoding("utf8");
|
|
36
|
+
process.stdin.on("data", (chunk) => {
|
|
37
|
+
stdinBuf += chunk;
|
|
38
|
+
// Notify all waiters that new data arrived.
|
|
39
|
+
const waiters = stdinWaiters;
|
|
40
|
+
stdinWaiters = [];
|
|
41
|
+
for (const w of waiters) w();
|
|
42
|
+
});
|
|
43
|
+
process.stdin.on("end", () => {
|
|
44
|
+
const waiters = stdinWaiters;
|
|
45
|
+
stdinWaiters = [];
|
|
46
|
+
for (const w of waiters) w();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Wait for more stdin data to arrive. */
|
|
50
|
+
function waitForData() {
|
|
51
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
52
|
+
stdinWaiters.push(resolve);
|
|
53
|
+
return promise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Frame I/O helpers — read/write Content-Length-prefixed messages.
|
|
58
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function writeFrame(obj) {
|
|
61
|
+
const body = JSON.stringify(obj);
|
|
62
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
63
|
+
process.stdout.write(`Content-Length: ${byteLength}\r\n\r\n${body}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readFrame() {
|
|
67
|
+
// Loop until we have a complete frame in the persistent buffer.
|
|
68
|
+
// eslint-disable-next-line no-constant-condition
|
|
69
|
+
while (true) {
|
|
70
|
+
const sep = stdinBuf.indexOf("\r\n\r\n");
|
|
71
|
+
if (sep < 0) {
|
|
72
|
+
// No complete header yet — wait for more data.
|
|
73
|
+
if (stdinBuf === null) return null; // stdin ended
|
|
74
|
+
await waitForData();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const header = stdinBuf.slice(0, sep);
|
|
78
|
+
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
|
79
|
+
if (!match) {
|
|
80
|
+
// Malformed — treat as end of stream.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const contentLength = Number(match[1]);
|
|
84
|
+
const bodyStart = sep + 4;
|
|
85
|
+
const bodyEnd = bodyStart + contentLength;
|
|
86
|
+
if (stdinBuf.length < bodyEnd) {
|
|
87
|
+
// Body not fully received — wait for more.
|
|
88
|
+
await waitForData();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const body = stdinBuf.slice(bodyStart, bodyEnd);
|
|
92
|
+
// Consume the frame from the persistent buffer, keeping any residual.
|
|
93
|
+
stdinBuf = stdinBuf.slice(bodyEnd);
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(body);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Scenario handlers
|
|
104
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
switch (scenario) {
|
|
108
|
+
case "missing":
|
|
109
|
+
process.exit(1);
|
|
110
|
+
|
|
111
|
+
case "handshake_timeout":
|
|
112
|
+
await readFrame(); // consume initialize, never respond
|
|
113
|
+
await new Promise(() => {}); // hang forever
|
|
114
|
+
return;
|
|
115
|
+
|
|
116
|
+
case "request_timeout": {
|
|
117
|
+
const init = await readFrame();
|
|
118
|
+
writeFrame({
|
|
119
|
+
jsonrpc: "2.0",
|
|
120
|
+
id: init.id,
|
|
121
|
+
result: {
|
|
122
|
+
capabilities: { definitionProvider: true },
|
|
123
|
+
serverInfo: { name: "fake-timeout" },
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await readFrame(); // initialized
|
|
127
|
+
await readFrame(); // definition request — never respond
|
|
128
|
+
await new Promise(() => {});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "protocol_error": {
|
|
133
|
+
await readFrame(); // initialize
|
|
134
|
+
process.stdout.write("THIS IS NOT A VALID LSP FRAME\r\n\r\n");
|
|
135
|
+
process.exit(0);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "crash_after_start": {
|
|
140
|
+
const init = await readFrame();
|
|
141
|
+
writeFrame({
|
|
142
|
+
jsonrpc: "2.0",
|
|
143
|
+
id: init.id,
|
|
144
|
+
result: {
|
|
145
|
+
capabilities: { definitionProvider: true },
|
|
146
|
+
serverInfo: { name: "fake-crash" },
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
await readFrame(); // initialized
|
|
150
|
+
process.exit(1); // crash mid-run
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "happy":
|
|
155
|
+
default: {
|
|
156
|
+
const init = await readFrame();
|
|
157
|
+
if (!init) return;
|
|
158
|
+
writeFrame({
|
|
159
|
+
jsonrpc: "2.0",
|
|
160
|
+
id: init.id,
|
|
161
|
+
result: {
|
|
162
|
+
capabilities: { definitionProvider: true },
|
|
163
|
+
serverInfo: { name: "fake-happy", version: "1.0.0" },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
await readFrame(); // initialized
|
|
167
|
+
await readFrame(); // didOpen
|
|
168
|
+
|
|
169
|
+
// Answer definition/shutdown/exit until stdin closes.
|
|
170
|
+
// eslint-disable-next-line no-constant-condition
|
|
171
|
+
while (true) {
|
|
172
|
+
const req = await readFrame();
|
|
173
|
+
if (!req) return;
|
|
174
|
+
if (req.method === "textDocument/definition") {
|
|
175
|
+
const envLoc = process.env.FAKE_LSP_DEFINITION;
|
|
176
|
+
const result = envLoc
|
|
177
|
+
? JSON.parse(envLoc)
|
|
178
|
+
: [
|
|
179
|
+
{
|
|
180
|
+
uri: "file:///fake/src/target.ts",
|
|
181
|
+
range: {
|
|
182
|
+
start: { line: 0, character: 0 },
|
|
183
|
+
end: { line: 0, character: 10 },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
writeFrame({ jsonrpc: "2.0", id: req.id, result });
|
|
188
|
+
} else if (req.method === "shutdown") {
|
|
189
|
+
writeFrame({ jsonrpc: "2.0", id: req.id, result: null });
|
|
190
|
+
} else if (req.method === "exit") {
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch(() => process.exit(1));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framing edge-case tests (issue #1555 step 2 — prove-fail-before).
|
|
3
|
+
*
|
|
4
|
+
* Covers the off-by-one and split-buffer traps that make LSP framing
|
|
5
|
+
* the most error-prone part of the client:
|
|
6
|
+
* - single complete frame
|
|
7
|
+
* - frame split across multiple chunks (body boundary)
|
|
8
|
+
* - header split across chunks (separator boundary)
|
|
9
|
+
* - multiple frames in one chunk
|
|
10
|
+
* - partial frame (body not yet complete) → zero messages
|
|
11
|
+
* - Content-Length counts UTF-8 BYTES not UTF-16 code units
|
|
12
|
+
* - malformed header (no Content-Length) → decode error
|
|
13
|
+
* - invalid JSON body → decode error
|
|
14
|
+
* - empty feed → zero messages, no throw
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
LspFrameDecoder,
|
|
21
|
+
encodeLspFrame,
|
|
22
|
+
} from "./framing.js";
|
|
23
|
+
|
|
24
|
+
test("encodeLspFrame: Content-Length header counts UTF-8 bytes, not UTF-16 code units", () => {
|
|
25
|
+
// The emoji 𝕏 is 4 UTF-8 bytes (F0 9D 95 8F) but 2 UTF-16 code units.
|
|
26
|
+
const frame = encodeLspFrame({ method: "test", params: { text: "𝕏" } });
|
|
27
|
+
// The body is the JSON-serialized object. We check that the header
|
|
28
|
+
// Content-Length matches Buffer.byteLength of the body.
|
|
29
|
+
const headerEnd = frame.indexOf("\r\n\r\n");
|
|
30
|
+
const header = frame.slice(0, headerEnd);
|
|
31
|
+
const body = frame.slice(headerEnd + 4);
|
|
32
|
+
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
|
33
|
+
assert.ok(match, "header must contain Content-Length");
|
|
34
|
+
const declared = Number(match[1]);
|
|
35
|
+
const actual = Buffer.byteLength(body, "utf8");
|
|
36
|
+
assert.equal(declared, actual, "Content-Length must match UTF-8 byte count");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("decode: single complete frame in one chunk", () => {
|
|
40
|
+
const dec = new LspFrameDecoder();
|
|
41
|
+
const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
|
|
42
|
+
const result = dec.feed(frame);
|
|
43
|
+
assert.equal(result.ok, true);
|
|
44
|
+
if (result.ok) {
|
|
45
|
+
assert.equal(result.messages.length, 1);
|
|
46
|
+
assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: { ok: true } });
|
|
47
|
+
}
|
|
48
|
+
assert.equal(dec.hasResidual, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("decode: frame body split across two chunks", () => {
|
|
52
|
+
const dec = new LspFrameDecoder();
|
|
53
|
+
const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
|
|
54
|
+
const headerEnd = frame.indexOf("\r\n\r\n") + 4;
|
|
55
|
+
const header = frame.slice(0, headerEnd);
|
|
56
|
+
const body = frame.slice(headerEnd);
|
|
57
|
+
const mid = Math.floor(body.length / 2);
|
|
58
|
+
|
|
59
|
+
// First chunk: header + half the body → zero messages.
|
|
60
|
+
let result = dec.feed(header + body.slice(0, mid));
|
|
61
|
+
assert.equal(result.ok, true);
|
|
62
|
+
if (result.ok) assert.equal(result.messages.length, 0);
|
|
63
|
+
|
|
64
|
+
// Second chunk: rest of body → one message.
|
|
65
|
+
result = dec.feed(body.slice(mid));
|
|
66
|
+
assert.equal(result.ok, true);
|
|
67
|
+
if (result.ok) {
|
|
68
|
+
assert.equal(result.messages.length, 1);
|
|
69
|
+
assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: { ok: true } });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("decode: header separator split across two chunks", () => {
|
|
74
|
+
const dec = new LspFrameDecoder();
|
|
75
|
+
const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: "x" });
|
|
76
|
+
// Split right in the middle of \r\n\r\n.
|
|
77
|
+
const sepIdx = frame.indexOf("\r\n\r\n");
|
|
78
|
+
const splitPoint = sepIdx + 2; // after first \r\n, before second \r\n
|
|
79
|
+
|
|
80
|
+
let result = dec.feed(frame.slice(0, splitPoint));
|
|
81
|
+
assert.equal(result.ok, true);
|
|
82
|
+
if (result.ok) assert.equal(result.messages.length, 0);
|
|
83
|
+
|
|
84
|
+
result = dec.feed(frame.slice(splitPoint));
|
|
85
|
+
assert.equal(result.ok, true);
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
assert.equal(result.messages.length, 1);
|
|
88
|
+
assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: "x" });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("decode: multiple frames in one chunk", () => {
|
|
93
|
+
const dec = new LspFrameDecoder();
|
|
94
|
+
const f1 = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: "a" });
|
|
95
|
+
const f2 = encodeLspFrame({ jsonrpc: "2.0", id: 2, result: "b" });
|
|
96
|
+
const f3 = encodeLspFrame({ jsonrpc: "2.0", id: 3, result: "c" });
|
|
97
|
+
|
|
98
|
+
const result = dec.feed(f1 + f2 + f3);
|
|
99
|
+
assert.equal(result.ok, true);
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
assert.equal(result.messages.length, 3);
|
|
102
|
+
assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: "a" });
|
|
103
|
+
assert.deepEqual(result.messages[1], { jsonrpc: "2.0", id: 2, result: "b" });
|
|
104
|
+
assert.deepEqual(result.messages[2], { jsonrpc: "2.0", id: 3, result: "c" });
|
|
105
|
+
}
|
|
106
|
+
assert.equal(dec.hasResidual, false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("decode: partial header (no separator yet) → zero messages, no throw", () => {
|
|
110
|
+
const dec = new LspFrameDecoder();
|
|
111
|
+
const result = dec.feed("Content-Length: 10\r\n");
|
|
112
|
+
assert.equal(result.ok, true);
|
|
113
|
+
if (result.ok) assert.equal(result.messages.length, 0);
|
|
114
|
+
assert.equal(dec.hasResidual, true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("decode: malformed header (no Content-Length) → decode error", () => {
|
|
118
|
+
const dec = new LspFrameDecoder();
|
|
119
|
+
const result = dec.feed("Content-Type: text/plain\r\n\r\n{}");
|
|
120
|
+
assert.equal(result.ok, false);
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
assert.equal(result.error.kind, "malformed_header");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("decode: invalid JSON body → decode error", () => {
|
|
127
|
+
const dec = new LspFrameDecoder();
|
|
128
|
+
const body = "{ not valid json";
|
|
129
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
130
|
+
const frame = `Content-Length: ${byteLength}\r\n\r\n${body}`;
|
|
131
|
+
const result = dec.feed(frame);
|
|
132
|
+
assert.equal(result.ok, false);
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
assert.equal(result.error.kind, "json_parse_error");
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("decode: empty feed → zero messages, no throw", () => {
|
|
139
|
+
const dec = new LspFrameDecoder();
|
|
140
|
+
const result = dec.feed("");
|
|
141
|
+
assert.equal(result.ok, true);
|
|
142
|
+
if (result.ok) assert.equal(result.messages.length, 0);
|
|
143
|
+
assert.equal(dec.hasResidual, false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("decode: UTF-8 multi-byte body — Content-Length matches byte count", () => {
|
|
147
|
+
const dec = new LspFrameDecoder();
|
|
148
|
+
const obj = { jsonrpc: "2.0", id: 1, result: "𝕏𝕏𝕏" };
|
|
149
|
+
const frame = encodeLspFrame(obj);
|
|
150
|
+
const result = dec.feed(frame);
|
|
151
|
+
assert.equal(result.ok, true);
|
|
152
|
+
if (result.ok) {
|
|
153
|
+
assert.equal(result.messages.length, 1);
|
|
154
|
+
assert.deepEqual(result.messages[0], obj);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("decode: sequential frames after partial — scan offset resets correctly", () => {
|
|
159
|
+
// Regression guard: after extracting a frame that was split, the
|
|
160
|
+
// scanOffset must reset so the next frame's separator is found from
|
|
161
|
+
// the beginning of the new buffer, not from a stale offset.
|
|
162
|
+
const dec = new LspFrameDecoder();
|
|
163
|
+
const f1 = encodeLspFrame({ id: 1 });
|
|
164
|
+
const f2 = encodeLspFrame({ id: 2 });
|
|
165
|
+
|
|
166
|
+
// Feed f1 in two chunks to force a split-extract cycle.
|
|
167
|
+
const mid = Math.floor(f1.length / 2);
|
|
168
|
+
dec.feed(f1.slice(0, mid));
|
|
169
|
+
let result = dec.feed(f1.slice(mid));
|
|
170
|
+
assert.equal(result.ok, true);
|
|
171
|
+
if (result.ok) assert.equal(result.messages.length, 1);
|
|
172
|
+
|
|
173
|
+
// Now feed f2 — it should parse cleanly from the reset offset.
|
|
174
|
+
result = dec.feed(f2);
|
|
175
|
+
assert.equal(result.ok, true);
|
|
176
|
+
if (result.ok) {
|
|
177
|
+
assert.equal(result.messages.length, 1);
|
|
178
|
+
assert.deepEqual(result.messages[0], { id: 2 });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed LSP framing — `Content-Length: <N>\r\n\r\n` headers
|
|
3
|
+
* followed by exactly `<N>` bytes of JSON body (LSP 3.17 §6.1 — Base Protocol).
|
|
4
|
+
*
|
|
5
|
+
* This is the one module where off-by-one and split-buffer bugs hide.
|
|
6
|
+
* The parser tracks a RUNNING OFFSET — it never re-scans bytes a
|
|
7
|
+
* previous scan already confirmed separator-free (rule 32).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Encoder — serialize a JSON-RPC message into a Content-Length frame.
|
|
12
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Encode a JSON-RPC message as a Content-Length-prefixed frame ready to
|
|
16
|
+
* write to the server's stdin. Uses UTF-8 — the LSP base protocol
|
|
17
|
+
* mandates UTF-8 content encoding (§6.1).
|
|
18
|
+
*/
|
|
19
|
+
export function encodeLspFrame(message: unknown): string {
|
|
20
|
+
const body = JSON.stringify(message);
|
|
21
|
+
// Content-Length counts UTF-8 BYTES, not JS UTF-16 code units. Node's
|
|
22
|
+
// Buffer.byteLength accounts for multi-byte sequences.
|
|
23
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
24
|
+
return `Content-Length: ${byteLength}\r\n\r\n${body}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Decoder — streaming parser that accumulates chunks and yields complete
|
|
29
|
+
// messages. Never throws on partial input — it just returns fewer messages
|
|
30
|
+
// and retains the residual for the next feed() call.
|
|
31
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reason for a decode failure. A `protocol_error` degradation surfaces
|
|
35
|
+
* the specific reason so the caller can distinguish "bad header" from
|
|
36
|
+
* "unparseable JSON body".
|
|
37
|
+
*/
|
|
38
|
+
export type FrameDecodeErrorKind =
|
|
39
|
+
| "malformed_header" // header line is not `Content-Length: <digits>`
|
|
40
|
+
| "json_parse_error"; // body is not valid JSON
|
|
41
|
+
|
|
42
|
+
export interface FrameDecodeError {
|
|
43
|
+
readonly kind: FrameDecodeErrorKind;
|
|
44
|
+
readonly detail: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FrameDecodeSuccess {
|
|
48
|
+
readonly ok: true;
|
|
49
|
+
readonly messages: unknown[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type FrameDecodeResult = FrameDecodeSuccess | { readonly ok: false; readonly error: FrameDecodeError };
|
|
53
|
+
|
|
54
|
+
// Sentinel for the header/body separator — `\r\n\r\n` (as bytes).
|
|
55
|
+
const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
|
|
56
|
+
const HEADER_SEPARATOR_LEN = HEADER_SEPARATOR.length;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Streaming LSP frame decoder. Feed raw Buffer or string chunks via
|
|
60
|
+
* {@link feed}; each call returns the complete JSON messages parsed
|
|
61
|
+
* since the last call, plus any error if a frame was malformed.
|
|
62
|
+
*
|
|
63
|
+
* Works with BYTES internally because Content-Length counts UTF-8 bytes
|
|
64
|
+
* (LSP 3.17 §6.1), not UTF-16 code units. A string-based buffer would
|
|
65
|
+
* mis-slice any body containing multi-byte characters (𝕏, emoji, CJK).
|
|
66
|
+
*
|
|
67
|
+
* The decoder maintains a running byte-buffer and a scan offset. After
|
|
68
|
+
* each feed, consumed bytes are sliced away so the buffer never grows
|
|
69
|
+
* unbounded across a long session (rule 11 — no unbounded state).
|
|
70
|
+
*/
|
|
71
|
+
export class LspFrameDecoder {
|
|
72
|
+
private buffer = Buffer.alloc(0);
|
|
73
|
+
/**
|
|
74
|
+
* Scan offset into {@link buffer}. The header scan resumes here on
|
|
75
|
+
* the next feed() — never re-scans bytes already confirmed to not
|
|
76
|
+
* contain the separator (rule 32). Reset to 0 after each consumed
|
|
77
|
+
* frame because slicing the buffer discards those bytes.
|
|
78
|
+
*/
|
|
79
|
+
private scanOffset = 0;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Feed a raw chunk (Buffer or string) from the server's stdout.
|
|
83
|
+
* Returns all complete messages parsed from the accumulated buffer
|
|
84
|
+
* since the last call, or the first decode error encountered (the
|
|
85
|
+
* decoder stops on error — a protocol violation means the stream is
|
|
86
|
+
* corrupt and further parsing is undefined).
|
|
87
|
+
*/
|
|
88
|
+
feed(chunk: Buffer | string): FrameDecodeResult {
|
|
89
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
90
|
+
this.buffer = this.buffer.length === 0 ? Buffer.from(buf) : Buffer.concat([this.buffer, buf]);
|
|
91
|
+
const messages: unknown[] = [];
|
|
92
|
+
|
|
93
|
+
// Loop: extract as many complete frames as the current buffer holds.
|
|
94
|
+
// eslint-disable-next-line no-constant-condition
|
|
95
|
+
while (true) {
|
|
96
|
+
const sepIdx = this.buffer.indexOf(HEADER_SEPARATOR, this.scanOffset);
|
|
97
|
+
if (sepIdx < 0) {
|
|
98
|
+
// No complete header yet. Advance scanOffset past the bytes
|
|
99
|
+
// already confirmed separator-free (rule 32). The separator is
|
|
100
|
+
// 4 bytes, so a partial match could start up to 3 bytes before
|
|
101
|
+
// the current end.
|
|
102
|
+
this.scanOffset = Math.max(0, this.buffer.length - (HEADER_SEPARATOR_LEN - 1));
|
|
103
|
+
return { ok: true, messages };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const headerBlock = this.buffer.subarray(0, sepIdx).toString("utf8");
|
|
107
|
+
const contentLength = parseContentLength(headerBlock);
|
|
108
|
+
if (contentLength === null) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: {
|
|
112
|
+
kind: "malformed_header",
|
|
113
|
+
detail: `header block has no valid Content-Length: ${JSON.stringify(headerBlock)}`,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const bodyStart = sepIdx + HEADER_SEPARATOR_LEN;
|
|
119
|
+
const bodyEnd = bodyStart + contentLength;
|
|
120
|
+
if (this.buffer.length < bodyEnd) {
|
|
121
|
+
// Body not fully received yet. Back up scanOffset to just before
|
|
122
|
+
// the separator so the next feed re-finds it.
|
|
123
|
+
this.scanOffset = Math.max(0, sepIdx - (HEADER_SEPARATOR_LEN - 1));
|
|
124
|
+
return { ok: true, messages };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyEnd);
|
|
128
|
+
let parsed: unknown;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(bodyBytes.toString("utf8"));
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: {
|
|
135
|
+
kind: "json_parse_error",
|
|
136
|
+
detail: `body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
messages.push(parsed);
|
|
141
|
+
|
|
142
|
+
// Slice consumed bytes off the front — subarray returns a view
|
|
143
|
+
// into the same memory; concat on the next feed replaces it.
|
|
144
|
+
this.buffer = this.buffer.subarray(bodyEnd);
|
|
145
|
+
this.scanOffset = 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True if there is un-consumed residual data in the buffer. */
|
|
150
|
+
get hasResidual(): boolean {
|
|
151
|
+
return this.buffer.length > 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reset the decoder to a clean state (test seam). */
|
|
155
|
+
reset(): void {
|
|
156
|
+
this.buffer = Buffer.alloc(0);
|
|
157
|
+
this.scanOffset = 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse the `Content-Length` value from a header block. Returns the byte
|
|
163
|
+
* count or null if the header is absent or malformed. LSP headers are
|
|
164
|
+
* case-insensitive and may appear in any order, but Content-Length is
|
|
165
|
+
* mandatory (§6.1).
|
|
166
|
+
*/
|
|
167
|
+
function parseContentLength(headerBlock: string): number | null {
|
|
168
|
+
const lines = headerBlock.split("\r\n");
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
const match = /^Content-Length:\s*(\d+)\s*$/i.exec(line);
|
|
171
|
+
if (match) {
|
|
172
|
+
const n = Number(match[1]);
|
|
173
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|