flecto 3.0.2 → 4.0.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/src/lsp.js ADDED
@@ -0,0 +1,407 @@
1
+ import { dirname, sep } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { Worker } from 'worker_threads';
4
+
5
+ /**
6
+ * `flecto lsp` (#142): a Language Server Protocol server over stdio that
7
+ * publishes Flecto's findings and semantic changes as diagnostics while a
8
+ * config file is being edited.
9
+ *
10
+ * The protocol loop does no analysis itself. Each document is analyzed in a
11
+ * worker thread (lsp-worker.js → lsp-analysis.js), which is what makes the two
12
+ * requirements hold at keystroke rate:
13
+ *
14
+ * - **Debounced**: edits to a document restart a short timer, and only the
15
+ * version that is current when it fires is analyzed.
16
+ * - **Cancellable**: an analysis still running when a newer version of the same
17
+ * document is ready is abandoned by terminating the worker — a synchronous
18
+ * parse of a large manifest cannot be interrupted any other way — and one
19
+ * that runs past the timeout is stopped the same way. A pack regex that
20
+ * backtracks forever therefore costs one warning diagnostic, not a wedged
21
+ * server.
22
+ *
23
+ * Results are published only if the document is still open at the version that
24
+ * was analyzed, so a slow answer never overwrites a newer one.
25
+ *
26
+ * Nothing but JSON-RPC is written to `output`: the worker's own stdout and
27
+ * stderr — a plugin that logs, a pack warning — are routed to `log`.
28
+ */
29
+
30
+ /** JSON-RPC error codes the server uses. */
31
+ const ERRORS = { methodNotFound: -32601, serverNotInitialized: -32002, invalidRequest: -32600 };
32
+
33
+ /**
34
+ * @typedef {{ diagnostics?: object[], error?: string, timedOut?: boolean, cancelled?: boolean }} RunResult
35
+ *
36
+ * @typedef {{
37
+ * run: (job: Record<string, unknown>) => Promise<RunResult>,
38
+ * cancel: () => void,
39
+ * dispose: () => void,
40
+ * }} Executor
41
+ */
42
+
43
+ /**
44
+ * Run analyses in a worker thread, one at a time.
45
+ * @param {{ timeoutMs: number, log: (message: string) => void }} options
46
+ * @returns {Executor}
47
+ */
48
+ export function createWorkerExecutor({ timeoutMs, log }) {
49
+ /** @type {Worker | null} */
50
+ let worker = null;
51
+ /** @type {{ id: number, owner: Worker, resolve: (result: RunResult) => void, timer: NodeJS.Timeout } | null} */
52
+ let pending = null;
53
+ let nextId = 1;
54
+
55
+ const settle = (/** @type {RunResult} */ result) => {
56
+ if (!pending) return;
57
+ clearTimeout(pending.timer);
58
+ const { resolve } = pending;
59
+ pending = null;
60
+ resolve(result);
61
+ };
62
+
63
+ const stop = (/** @type {RunResult} */ result) => {
64
+ const current = worker;
65
+ worker = null;
66
+ settle(result);
67
+ if (current) void current.terminate();
68
+ };
69
+
70
+ const spawn = () => {
71
+ const created = new Worker(new URL('./lsp-worker.js', import.meta.url), { stdout: true, stderr: true });
72
+ created.stdout.on('data', (chunk) => log(String(chunk).trimEnd()));
73
+ created.stderr.on('data', (chunk) => log(String(chunk).trimEnd()));
74
+ created.on('message', (message) => {
75
+ if (pending && message?.id === pending.id) {
76
+ settle(message.error ? { error: message.error } : { diagnostics: message.diagnostics ?? [] });
77
+ }
78
+ });
79
+ // A terminated worker's `exit` arrives asynchronously, often after the next
80
+ // job has started on its replacement, so a worker only ever settles a job
81
+ // it was given.
82
+ created.on('error', (err) => {
83
+ if (worker === created) worker = null;
84
+ if (pending?.owner === created) settle({ error: err?.message ?? String(err) });
85
+ });
86
+ created.on('exit', () => {
87
+ if (worker === created) worker = null;
88
+ if (pending?.owner === created) settle({ error: 'the analysis worker exited unexpectedly' });
89
+ });
90
+ return created;
91
+ };
92
+
93
+ return {
94
+ run(job) {
95
+ worker ??= spawn();
96
+ const id = nextId++;
97
+ const target = worker;
98
+ return new Promise((resolve) => {
99
+ pending = {
100
+ id,
101
+ owner: target,
102
+ resolve,
103
+ timer: setTimeout(() => stop({ timedOut: true }), timeoutMs),
104
+ };
105
+ target.postMessage({ ...job, id });
106
+ });
107
+ },
108
+ cancel() {
109
+ if (pending) stop({ cancelled: true });
110
+ },
111
+ dispose() {
112
+ stop({ cancelled: true });
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * @param {string} uri
119
+ * @returns {string | null}
120
+ */
121
+ function uriToPath(uri) {
122
+ if (typeof uri !== 'string' || !uri.startsWith('file:')) return null;
123
+ try {
124
+ return fileURLToPath(uri);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Apply one `didChange` content change. The server asks for full-document
132
+ * sync, but a client that sends ranged edits anyway is handled rather than
133
+ * silently diverging.
134
+ * @param {string} text
135
+ * @param {{ text: string, range?: { start: { line: number, character: number }, end: { line: number, character: number } } }} change
136
+ * @returns {string}
137
+ */
138
+ function applyChange(text, change) {
139
+ if (!change.range) return change.text;
140
+ const offsetOf = ({ line, character }) => {
141
+ let offset = 0;
142
+ for (let current = 0; current < line; current++) {
143
+ const next = text.indexOf('\n', offset);
144
+ if (next === -1) return text.length;
145
+ offset = next + 1;
146
+ }
147
+ return Math.min(offset + character, text.length);
148
+ };
149
+ return text.slice(0, offsetOf(change.range.start)) + change.text + text.slice(offsetOf(change.range.end));
150
+ }
151
+
152
+ /**
153
+ * Start the server on a pair of streams.
154
+ * @param {{
155
+ * input: NodeJS.ReadableStream,
156
+ * output: NodeJS.WritableStream,
157
+ * version: string,
158
+ * cwd: string,
159
+ * settings: import('./lsp-analysis.js').LspSettings,
160
+ * debounceMs?: number,
161
+ * timeoutMs?: number,
162
+ * executor?: Executor,
163
+ * log?: (message: string) => void,
164
+ * onExit?: (code: number) => void,
165
+ * }} options
166
+ * @returns {{ done: Promise<number> }} resolves with the exit code once the
167
+ * client sends `exit` or closes the stream
168
+ */
169
+ export function startLanguageServer(options) {
170
+ const { input, output, version, settings } = options;
171
+ const debounceMs = options.debounceMs ?? 250;
172
+ const log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
173
+ const executor = options.executor ?? createWorkerExecutor({ timeoutMs: options.timeoutMs ?? 10_000, log });
174
+
175
+ /** @type {Map<string, { text: string, version: number }>} */
176
+ const docs = new Map();
177
+ /** @type {Map<string, NodeJS.Timeout>} */
178
+ const timers = new Map();
179
+ /** @type {Set<string>} */
180
+ const queued = new Set();
181
+ /** @type {{ uri: string, version: number } | null} */
182
+ let running = null;
183
+ let initialized = false;
184
+ let shutdownRequested = false;
185
+ let exited = false;
186
+ /** @type {string[]} */
187
+ let roots = [options.cwd];
188
+ let finish = (/** @type {number} */ _code) => {};
189
+ const done = new Promise((resolve) => { finish = resolve; });
190
+
191
+ const send = (message) => {
192
+ const body = JSON.stringify({ jsonrpc: '2.0', ...message });
193
+ output.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
194
+ };
195
+ const respond = (id, result) => send({ id, result });
196
+ const fail = (id, code, message) => send({ id, error: { code, message } });
197
+ const publish = (uri, docVersion, diagnostics) => send({
198
+ method: 'textDocument/publishDiagnostics',
199
+ params: { uri, ...(docVersion === null ? {} : { version: docVersion }), diagnostics },
200
+ });
201
+
202
+ /**
203
+ * The workspace folder that owns a file: the longest one containing it, so
204
+ * `.flectorc` and `policies/` resolve from the right project in a multi-root
205
+ * workspace.
206
+ * @param {string} path
207
+ * @returns {string}
208
+ */
209
+ const rootFor = (path) => roots
210
+ .filter((root) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep))
211
+ .sort((a, b) => b.length - a.length)[0] ?? dirname(path);
212
+
213
+ const pump = () => {
214
+ if (running || queued.size === 0 || exited) return;
215
+ const [uri] = queued;
216
+ queued.delete(uri);
217
+ const doc = docs.get(uri);
218
+ const path = uriToPath(uri);
219
+ if (!doc || !path) {
220
+ pump();
221
+ return;
222
+ }
223
+ const job = { root: rootFor(path), path, text: doc.text, settings };
224
+ running = { uri, version: doc.version };
225
+ const analyzed = running;
226
+ void executor.run(job).then((result) => {
227
+ running = null;
228
+ const current = docs.get(uri);
229
+ if (!result.cancelled && current && current.version === analyzed.version && !exited) {
230
+ if (result.timedOut) {
231
+ publish(uri, analyzed.version, [{
232
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
233
+ severity: 2,
234
+ source: 'flecto',
235
+ code: 'timeout',
236
+ message: 'Flecto stopped analyzing this file because it took too long. A policy pack regex that'
237
+ + ' backtracks on a long value is the usual cause; `flecto ci` would hang on it too.',
238
+ }]);
239
+ } else if (result.error) {
240
+ publish(uri, analyzed.version, [{
241
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
242
+ severity: 1,
243
+ source: 'flecto',
244
+ code: 'error',
245
+ message: `Flecto could not analyze this file: ${result.error}`,
246
+ }]);
247
+ } else {
248
+ publish(uri, analyzed.version, result.diagnostics ?? []);
249
+ }
250
+ }
251
+ pump();
252
+ });
253
+ };
254
+
255
+ const enqueue = (uri) => {
256
+ queued.add(uri);
257
+ // A newer version of the document being analyzed makes that analysis
258
+ // worthless; stop it rather than wait for it.
259
+ if (running?.uri === uri && docs.get(uri)?.version !== running.version) executor.cancel();
260
+ pump();
261
+ };
262
+
263
+ const schedule = (uri) => {
264
+ clearTimeout(timers.get(uri));
265
+ timers.set(uri, setTimeout(() => {
266
+ timers.delete(uri);
267
+ enqueue(uri);
268
+ }, debounceMs));
269
+ };
270
+
271
+ const exit = (code) => {
272
+ if (exited) return;
273
+ exited = true;
274
+ for (const timer of timers.values()) clearTimeout(timer);
275
+ executor.dispose();
276
+ finish(code);
277
+ options.onExit?.(code);
278
+ };
279
+
280
+ /** @param {any} message */
281
+ const handle = (message) => {
282
+ if (!message || typeof message !== 'object' || typeof message.method !== 'string') {
283
+ if (message && message.id !== undefined && message.method === undefined) return; // a response to us
284
+ if (message?.id !== undefined) fail(message.id, ERRORS.invalidRequest, 'Invalid request');
285
+ return;
286
+ }
287
+ const { id, method, params } = message;
288
+ const isRequest = id !== undefined && id !== null;
289
+
290
+ if (method === 'initialize') {
291
+ const folders = Array.isArray(params?.workspaceFolders) ? params.workspaceFolders.map((f) => uriToPath(f?.uri)) : [];
292
+ const rootUriPath = uriToPath(params?.rootUri);
293
+ const found = [...folders, rootUriPath, typeof params?.rootPath === 'string' ? params.rootPath : null]
294
+ .filter((folder) => typeof folder === 'string' && folder.length > 0);
295
+ if (found.length > 0) roots = [...new Set(found)];
296
+ initialized = true;
297
+ respond(id, {
298
+ capabilities: {
299
+ positionEncoding: 'utf-16',
300
+ textDocumentSync: { openClose: true, change: 1, save: { includeText: false } },
301
+ },
302
+ serverInfo: { name: 'flecto', version },
303
+ });
304
+ return;
305
+ }
306
+ if (method === 'exit') {
307
+ exit(shutdownRequested ? 0 : 1);
308
+ return;
309
+ }
310
+ if (!initialized) {
311
+ if (isRequest) fail(id, ERRORS.serverNotInitialized, 'Server not initialized');
312
+ return;
313
+ }
314
+
315
+ switch (method) {
316
+ case 'initialized':
317
+ return;
318
+ case 'shutdown':
319
+ shutdownRequested = true;
320
+ for (const timer of timers.values()) clearTimeout(timer);
321
+ timers.clear();
322
+ queued.clear();
323
+ executor.cancel();
324
+ respond(id, null);
325
+ return;
326
+ case 'textDocument/didOpen': {
327
+ const doc = params?.textDocument;
328
+ if (typeof doc?.uri !== 'string' || typeof doc.text !== 'string') return;
329
+ docs.set(doc.uri, { text: doc.text, version: Number(doc.version) || 0 });
330
+ schedule(doc.uri);
331
+ return;
332
+ }
333
+ case 'textDocument/didChange': {
334
+ const uri = params?.textDocument?.uri;
335
+ const doc = docs.get(uri);
336
+ if (!doc || !Array.isArray(params.contentChanges)) return;
337
+ let { text } = doc;
338
+ for (const change of params.contentChanges) {
339
+ if (typeof change?.text === 'string') text = applyChange(text, change);
340
+ }
341
+ docs.set(uri, { text, version: Number(params.textDocument.version) || doc.version + 1 });
342
+ schedule(uri);
343
+ return;
344
+ }
345
+ case 'textDocument/didSave':
346
+ case 'workspace/didChangeWatchedFiles': {
347
+ // A saved `.flectorc`, pack, or baseline changes what every open
348
+ // document should show, and the analysis reads those from disk.
349
+ for (const uri of docs.keys()) schedule(uri);
350
+ return;
351
+ }
352
+ case 'textDocument/didClose': {
353
+ const uri = params?.textDocument?.uri;
354
+ clearTimeout(timers.get(uri));
355
+ timers.delete(uri);
356
+ queued.delete(uri);
357
+ docs.delete(uri);
358
+ if (running?.uri === uri) executor.cancel();
359
+ if (typeof uri === 'string') publish(uri, null, []);
360
+ return;
361
+ }
362
+ default:
363
+ // Notifications we do not handle ($/cancelRequest, $/setTrace, …) are
364
+ // ignored, as the protocol allows; unknown requests get an error.
365
+ if (isRequest) fail(id, ERRORS.methodNotFound, `Unhandled method ${method}`);
366
+ }
367
+ };
368
+
369
+ // Content-Length framing. Lengths are in bytes, so the buffer is kept as
370
+ // bytes and only a complete body is decoded.
371
+ let buffer = Buffer.alloc(0);
372
+ input.on('data', (chunk) => {
373
+ buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')]);
374
+ for (;;) {
375
+ const headerEnd = buffer.indexOf('\r\n\r\n');
376
+ if (headerEnd === -1) return;
377
+ const header = buffer.subarray(0, headerEnd).toString('ascii');
378
+ const length = /Content-Length:\s*(\d+)/iu.exec(header);
379
+ if (!length) {
380
+ // Unframed garbage: drop through the header terminator and resync.
381
+ buffer = buffer.subarray(headerEnd + 4);
382
+ continue;
383
+ }
384
+ const bodyStart = headerEnd + 4;
385
+ const bodyEnd = bodyStart + Number(length[1]);
386
+ if (buffer.length < bodyEnd) return;
387
+ const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
388
+ buffer = buffer.subarray(bodyEnd);
389
+ let message;
390
+ try {
391
+ message = JSON.parse(body);
392
+ } catch {
393
+ send({ id: null, error: { code: -32700, message: 'Parse error' } });
394
+ continue;
395
+ }
396
+ try {
397
+ handle(message);
398
+ } catch (err) {
399
+ log(`flecto lsp: ${err?.stack ?? err}`);
400
+ if (message?.id !== undefined) fail(message.id, -32603, 'Internal error');
401
+ }
402
+ }
403
+ });
404
+ input.on('end', () => exit(shutdownRequested ? 0 : 1));
405
+
406
+ return { done };
407
+ }