char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somexperiment: use native typescript lsp supportc7a9569

main
17.6 KiB421 linesraw
1const assert = require("node:assert/strict");
2const test = require("node:test");
3const fs = require("node:fs");
4const path = require("node:path");
5const vm = require("node:vm");
6const { setImmediate } = require("node:timers/promises");
7
8function event() {
9  const listeners = new Set();
10  return {
11    listen(callback) { listeners.add(callback); return { dispose: () => listeners.delete(callback) }; },
12    fire: (...args) => Promise.all([...listeners].map((listener) => listener(...args))),
13  };
14}
15
16function fixture(options = {}) {
17  const uri = (file) => ({ scheme: "file", fsPath: file, toString: () => `file://${file}` });
18  const root = path.resolve("workspace");
19  const folder = (file) => ({ uri: uri(file) });
20  const config = { backend: "/native/tsc", executable: "avril", options: [], autoStart: true, ...options.config };
21  const configurations = [];
22  const errors = [];
23  const commands = new Map();
24  const executedCommands = [];
25  const contentProviders = new Map();
26  const clients = [];
27  const watchers = [];
28  const configs = new Set(options.files || [path.join(root, "avril.conf")]);
29  const trust = event();
30  const folders = event();
31  const settings = event();
32  const State = { Stopped: 1, Starting: 2, Running: 3 };
33  const controls = { failStart: false, startGate: undefined };
34  const workspace = {
35    isTrusted: options.trusted ?? true,
36    workspaceFolders: [folder(root)],
37    fs: { stat: async (uri) => {
38      if (!configs.has(uri.fsPath)) throw Object.assign(new Error("Missing"), { code: "FileNotFound" });
39      return { type: 1 };
40    } },
41    getConfiguration: () => ({ get: (key, fallback) => config[key] ?? fallback }),
42    getWorkspaceFolder: (uri) => workspace.workspaceFolders.filter((folder) => {
43      const relative = path.relative(folder.uri.fsPath, uri.fsPath);
44      return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
45    }).sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length)[0],
46    onDidGrantWorkspaceTrust: trust.listen,
47    onDidChangeWorkspaceFolders: folders.listen,
48    onDidChangeConfiguration: settings.listen,
49    registerTextDocumentContentProvider: (scheme, provider) => {
50      contentProviders.set(scheme, provider);
51      return { dispose: () => contentProviders.delete(scheme) };
52    },
53    createFileSystemWatcher: (pattern) => {
54      const created = event();
55      const deleted = event();
56      const watcher = {
57        pattern, created, deleted, disposed: false,
58        onDidCreate: created.listen, onDidDelete: deleted.listen,
59        dispose() { this.disposed = true; },
60      };
61      watchers.push(watcher);
62      return watcher;
63    },
64  };
65  const vscode = {
66    workspace,
67    Uri: {
68      joinPath: (base, ...parts) => uri(path.join(base.fsPath, ...parts)),
69      parse: (value) => {
70        const parsed = new URL(value);
71        return {
72          scheme: parsed.protocol.slice(0, -1),
73          fsPath: parsed.protocol === "file:" ? decodeURIComponent(parsed.pathname) : parsed.pathname,
74          toString: () => parsed.href,
75        };
76      },
77    },
78    FileType: { File: 1 },
79    RelativePattern: class { constructor(folder, pattern) { this.folder = folder; this.pattern = pattern; } },
80    extensions: { getExtension: () => options.noTypeScript ? undefined : {
81      activate: async () => ({ getAPI: () => ({ configurePlugin: (name, configuration) => {
82        configurations.push({ name, ...JSON.parse(JSON.stringify(configuration)) });
83      } }) }),
84    } },
85    commands: {
86      registerCommand: (name, handler) => {
87        commands.set(name, handler);
88        return { dispose: () => commands.delete(name) };
89      },
90      executeCommand: async (...args) => {
91        executedCommands.push(args);
92        return "executed";
93      },
94    },
95    window: { showErrorMessage: (message) => errors.push(message) },
96  };
97  class LanguageClient {
98    constructor(id, name, server, clientOptions) {
99      this.server = server;
100      this.options = clientOptions;
101      this.requests = [];
102      this.notifications = [];
103      this.sendNotification = async (method, params) => { this.notifications.push({ method, params }); };
104      this.protocol2CodeConverter = {
105        asUri: (value) => vscode.Uri.parse(value),
106        asPosition: (value) => ({ kind: "Position", ...value }),
107        asLocation: (value) => ({
108          kind: "Location",
109          uri: vscode.Uri.parse(value.uri),
110          range: value.range,
111        }),
112      };
113      this.sendRequest = async (method, params) => {
114        this.requests.push({ method, params });
115        return "contents";
116      };
117      this.state = State.Stopped;
118      this.changed = event();
119      this.onDidChangeState = this.changed.listen;
120      clients.push(this);
121    }
122    async transition(state) { this.state = state; await this.changed.fire({ newState: state }); }
123    async start() {
124      await this.transition(State.Starting);
125      await controls.startGate;
126      if (controls.failStart) throw new Error("Startup failed");
127      await this.transition(State.Running);
128    }
129    needsStop() { return this.state !== State.Stopped; }
130    async stop() { await this.transition(State.Stopped); }
131  }
132  const module = { exports: {} };
133  vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../extension.cjs"), "utf8"), {
134    module,
135    require(name) {
136      if (name === "vscode") return vscode;
137      if (name === "vscode-languageclient/node") return { LanguageClient, State };
138      return require(name);
139    },
140  });
141  const context = { subscriptions: [] };
142  return {
143    ...module.exports, context, root, uri, folder, config, configs, configurations, errors,
144    commands, executedCommands, contentProviders, clients, watchers, workspace, trust, folders, settings, controls, State,
145    parseUri: (value) => vscode.Uri.parse(value),
146    activate: () => module.exports.activate(context),
147    changed: () => settings.fire({ affectsConfiguration: () => true }),
148    start: () => commands.get("avril.startLanguageServer")(),
149    stop: () => commands.get("avril.stopLanguageServer")(),
150  };
151}
152
153test("root configuration auto-starts and mutes only the active workspace", async (t) => {
154  const f = fixture();
155  t.after(f.deactivate);
156  f.workspace.workspaceFolders.push(f.folder(path.resolve("other")));
157  await f.activate();
158  assert.equal(f.clients.length, 1);
159  assert.equal(f.clients[0].server.options.cwd, f.root);
160  assert.deepEqual(f.configurations.at(-1).liveRoots, [f.root]);
161  assert.deepEqual(f.configurations.at(-1).workspaceRoots, [f.root, path.resolve("other")]);
162  await f.stop();
163  assert.deepEqual(f.configurations.at(-1).liveRoots, []);
164  await f.changed();
165  assert.equal(f.clients.length, 1, "explicit stop must survive configuration events");
166  await f.start();
167  assert.equal(f.clients.length, 2);
168});
169
170test("nested configs and configs in other workspace folders do not claim the first folder", async (t) => {
171  const f = fixture({ files: [path.resolve("workspace/nested/avril.conf"), path.resolve("other/avril.conf")] });
172  t.after(f.deactivate);
173  f.workspace.workspaceFolders.push(f.folder(path.resolve("other")));
174  await f.activate();
175  await f.watchers[0].created.fire(f.uri(path.resolve("workspace/nested/avril.conf")));
176  assert.equal(f.clients.length, 0);
177  assert.equal(f.configurations.length, 0);
178});
179
180test("root creation and removal start and stop an automatic server", async (t) => {
181  const f = fixture({ files: [] });
182  t.after(f.deactivate);
183  await f.activate();
184  const config = f.uri(path.join(f.root, "avril.conf"));
185  f.configs.add(config.fsPath);
186  await f.watchers[0].created.fire(config);
187  assert.equal(f.clients.length, 1);
188  f.configs.delete(config.fsPath);
189  await f.watchers[0].deleted.fire(config);
190  assert.equal(f.clients[0].state, f.State.Stopped);
191  assert.deepEqual(f.configurations.at(-1).liveRoots, []);
192});
193
194test("trust, backend configuration, and autoStart opt-out gate automatic startup", async (t) => {
195  const f = fixture({ trusted: false, config: { backend: "", autoStart: false } });
196  t.after(f.deactivate);
197  await f.activate();
198  await assert.rejects(f.start(), /trusted/);
199  f.workspace.isTrusted = true;
200  await f.trust.fire();
201  assert.equal(f.clients.length, 0);
202  f.config.autoStart = true;
203  await f.changed();
204  assert.equal(f.clients.length, 0);
205  f.config.backend = "/native/tsc";
206  await f.changed();
207  assert.equal(f.clients.length, 1);
208  f.config.autoStart = false;
209  await f.changed();
210  assert.equal(f.clients[0].state, f.State.Stopped);
211  await f.start();
212  assert.equal(f.clients.length, 2, "manual startup remains available with autoStart disabled");
213});
214
215test("failed startup and unexpected termination leave built-in TypeScript usable", async (t) => {
216  const f = fixture();
217  t.after(f.deactivate);
218  f.controls.failStart = true;
219  await f.activate();
220  assert.match(f.errors[0], /Startup failed/);
221  assert.ok(f.configurations.every((configuration) => configuration.liveRoots.length === 0));
222  assert.equal(f.watchers[1].disposed, true);
223  f.controls.failStart = false;
224  await f.start();
225  await f.clients.at(-1).transition(f.State.Stopped);
226  assert.deepEqual(f.configurations.at(-1).liveRoots, []);
227  await f.start();
228  assert.deepEqual(f.configurations.at(-1).liveRoots, [f.root]);
229});
230
231test("concurrent startup requests do not duplicate clients and stop waits for startup", async (t) => {
232  const f = fixture();
233  t.after(f.deactivate);
234  let resume;
235  f.controls.startGate = new Promise((resolve) => { resume = resolve; });
236  const activation = f.activate();
237  const start = f.start();
238  await setImmediate();
239  assert.equal(f.clients.length, 1);
240  const stop = f.stop();
241  resume();
242  await Promise.all([activation, start, stop]);
243  assert.equal(f.clients.length, 1);
244  assert.equal(f.clients[0].state, f.State.Stopped);
245  assert.deepEqual(f.configurations.at(-1).liveRoots, []);
246});
247
248test("nested workspace folders retain their own language service", async (t) => {
249  const f = fixture();
250  t.after(f.deactivate);
251  f.workspace.workspaceFolders.push(f.folder(path.join(f.root, "node")));
252  await f.activate();
253  const middleware = f.clients[0].options.middleware;
254  let calls = 0;
255  const next = () => { calls++; };
256  const nested = { uri: f.uri(path.join(f.root, "node", "main.ts")) };
257  middleware.didOpen(nested, next);
258  for (const name of [
259    "provideHover", "provideDefinition", "provideCompletionItem", "provideSignatureHelp",
260    "provideReferences", "provideDocumentHighlights", "provideRenameEdits", "prepareRename",
261    "provideDocumentSymbols", "provideFoldingRanges", "provideSelectionRanges",
262    "provideDocumentFormattingEdits", "provideDocumentRangeFormattingEdits",
263    "provideDocumentSemanticTokens", "provideDocumentSemanticTokensEdits",
264    "provideDocumentRangeSemanticTokens", "provideInlayHints", "prepareTypeHierarchy", "prepareCallHierarchy",
265    "provideLinkedEditingRange", "provideImplementation", "provideTypeDefinition", "provideCodeActions", "provideCodeLenses",
266  ]) {
267    assert.equal(typeof middleware[name], "function", `Missing ownership gate for ${name}`);
268    middleware[name](nested, {}, {}, {}, next);
269  }
270  middleware.provideTypeHierarchySupertypes({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next);
271  middleware.provideTypeHierarchySubtypes({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next);
272  middleware.provideCallHierarchyIncomingCalls({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next);
273  middleware.provideCallHierarchyOutgoingCalls({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next);
274  assert.equal(calls, 0, "avril must not claim nested workspace documents or hierarchy items");
275  middleware.provideWorkspaceSymbols("Square", {}, next);
276  middleware.resolveCompletionItem({}, {}, next);
277  middleware.resolveCodeAction({}, {}, next);
278  assert.equal(calls, 3, "Workspace and opaque-item requests are served by the configured root project");
279
280  const owned = { uri: f.uri(path.join(f.root, "main.ts")) };
281  middleware.didOpen(owned, next);
282  middleware.provideSignatureHelp(owned, {}, {}, {}, next);
283  middleware.provideReferences(owned, {}, { includeDeclaration: true }, {}, next);
284  middleware.provideRenameEdits(owned, {}, "renamed", {}, next);
285  middleware.prepareTypeHierarchy(owned, {}, {}, next);
286  middleware.provideTypeHierarchySupertypes({ uri: owned.uri }, {}, next);
287  middleware.prepareCallHierarchy(owned, {}, {}, next);
288  middleware.provideCallHierarchyIncomingCalls({ uri: owned.uri }, {}, next);
289  middleware.provideCallHierarchyOutgoingCalls({ uri: owned.uri }, {}, next);
290  assert.equal(calls, 12, "avril should serve every owned document request and hierarchy item");
291});
292
293test("resolved reference code lenses invoke only the converted built-in command", async (t) => {
294  const f = fixture({ config: { preferences: { referencesCodeLensEnabled: true, referencesCodeLensShowOnAllFunctions: true } } });
295  t.after(f.deactivate);
296  await f.activate();
297  const client = f.clients[0];
298  assert.deepEqual(JSON.parse(JSON.stringify(client.options.initializationOptions)), {
299    preferences: {
300      referencesCodeLensEnabled: true,
301      referencesCodeLensShowOnAllFunctions: true,
302    },
303  });
304  const middleware = client.options.middleware;
305  const sourceDocument = f.uri(path.join(f.root, "main.ts"));
306  const source = sourceDocument.toString();
307  const position = { line: 2, character: 4 };
308  const locations = [{
309    uri: f.uri(path.join(f.root, "dep.ts")).toString(),
310    range: { start: { line: 4, character: 1 }, end: { line: 4, character: 6 } },
311  }];
312  class CodeLens { get isResolved() { return Boolean(this.command); } }
313  const lens = Object.assign(new CodeLens(), { range: {}, data: { uri: source } });
314  const [provided] = await middleware.provideCodeLenses(
315    { uri: sourceDocument },
316    {},
317    async () => [lens],
318  );
319  assert.ok(provided instanceof CodeLens, "conversion must preserve the language client's subclass");
320  assert.equal(provided.isResolved, false);
321  assert.equal(provided.data.uri, source);
322  assert.equal(provided.data.avrilDocumentUri, source);
323  const resolved = await middleware.resolveCodeLens(
324    provided,
325    {},
326    async (codeLens) => Object.assign(codeLens, {
327      command: {
328        title: "1 reference",
329        command: "editor.action.showReferences",
330        arguments: [source, position, locations],
331      },
332    }),
333  );
334  assert.ok(resolved instanceof CodeLens);
335  assert.equal(resolved.isResolved, true);
336  assert.equal(resolved.command.command, "avril.showReferences");
337  await f.commands.get(resolved.command.command)(...resolved.command.arguments);
338  assert.equal(f.executedCommands.length, 1);
339  assert.equal(f.executedCommands[0][0], "editor.action.showReferences");
340  assert.equal(f.executedCommands[0][1].toString(), source);
341  assert.equal(f.executedCommands[0][2].kind, "Position");
342  assert.deepEqual(JSON.parse(JSON.stringify(f.executedCommands[0][2])), { kind: "Position", ...position });
343  assert.equal(f.executedCommands[0][3][0].kind, "Location");
344  assert.equal(f.executedCommands[0][3][0].uri.toString(), locations[0].uri);
345  assert.deepEqual(
346    JSON.parse(JSON.stringify(f.executedCommands[0][3][0].range)),
347    locations[0].range,
348  );
349
350  const outside = f.uri(path.resolve("outside/main.ts")).toString();
351  let nextCalled = false;
352  assert.equal(await middleware.resolveCodeLens(
353    { data: { avrilDocumentUri: outside } },
354    {},
355    () => { nextCalled = true; },
356  ), undefined);
357  assert.equal(nextCalled, false, "unowned code lenses must not be resolved");
358  assert.throws(
359    () => f.commands.get("avril.showReferences")(outside, position, locations),
360    /outside its workspace/,
361  );
362
363  const unexpected = await middleware.resolveCodeLens(
364    { data: { avrilDocumentUri: source } },
365    {},
366    async (codeLens) => ({ ...codeLens, command: { title: "Unexpected", command: "workbench.action.closeWindow" } }),
367  );
368  assert.equal(unexpected, undefined, "native commands other than showReferences must not escape");
369  assert.equal(f.executedCommands.length, 1);
370});
371
372test("native preferences preserve defaults and update the active session", async (t) => {
373  const f = fixture();
374  t.after(f.deactivate);
375  await f.activate();
376  assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].options.initializationOptions)), { preferences: {} });
377  f.config.preferences = { includeInlayParameterNameHints: "all" };
378  await f.changed();
379  assert.equal(f.clients.length, 1);
380  assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].notifications.at(-1))), {
381    method: "workspace/didChangeConfiguration",
382    params: { settings: { preferences: f.config.preferences } },
383  });
384});
385
386test("read-only document providers preserve encoded URI query identity", async (t) => {
387  const f = fixture();
388  t.after(f.deactivate);
389  await f.activate();
390  for (const original of [
391    "avril-https://example.test/module.ts?x%3Dy",
392    "avril-https://example.test/module.ts?x%253Dy",
393  ]) {
394    const uri = f.parseUri(original);
395    const content = await f.contentProviders.get("avril-https").provideTextDocumentContent(uri, {});
396    assert.equal(content, "contents");
397    assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].requests.at(-1))), {
398      method: "avril/readDocument",
399      params: { uri: original },
400    });
401  }
402});
403
404test("changing the first workspace folder releases the old server", async (t) => {
405  const f = fixture();
406  t.after(f.deactivate);
407  await f.activate();
408  f.workspace.workspaceFolders = [f.folder(path.resolve("other"))];
409  await f.folders.fire();
410  assert.equal(f.clients[0].state, f.State.Stopped);
411  assert.deepEqual(f.configurations.at(-1).liveRoots, []);
412});
413
414test("manual startup still works without a root config or built-in TypeScript extension", async (t) => {
415  const f = fixture({ files: [], noTypeScript: true });
416  t.after(f.deactivate);
417  await f.activate();
418  await f.start();
419  assert.equal(f.clients[0].state, f.State.Running);
420  assert.deepEqual(f.errors, []);
421});