testLanguageServer

tcmts.cli.test.e2e.lsp.testLanguageServer · #o2c6buay · term

the language server: status lenses, save commands, watch values, hovers, tcm diagnostics

import { plainEnv, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { at, client, titles } from "#codebase/tcmts/cli/test/e2e/lsp";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const testLanguageServer = () =>
  withChildren(async (keep) => {
    const dir = tempDir("lsp");
    spawnSync(process.execPath, [tcmBin(), "init"], { cwd: dir, env: plainEnv() });
    const file = path.join(dir, "scratch.ts");
    const uri = pathToFileURL(file).href;
    const text = `// tcm: namespace lib
/** Square a number. */
export const square = (x: number): number => x * x;
export const testSquare = () => {
  if (square(3) !== 9) throw new Error("bad");
};

square(12);
`;
    fs.writeFileSync(file, text);

    const c = client(keep);
    const init = await c.request("initialize", {
      processId: null,
      rootUri: pathToFileURL(dir).href,
      capabilities: {},
      clientInfo: { name: "test" },
    });
    assert.ok(
      init.capabilities.codeLensProvider &&
        init.capabilities.inlayHintProvider &&
        init.capabilities.hoverProvider,
    );
    assert.deepEqual(init.capabilities.executeCommandProvider.commands, [
      "tcm.add",
      "tcm.update",
      "tcm.mv",
      "tcm.edit",
      "tcm.editInto",
      "tcm.dataMigrate",
      "tcm.upgrade",
      "tcm.release",
      "tcm.releaseDraft",
      "tcm.pack",
    ]);
    c.notify("initialized", {});
    c.notify("textDocument/didOpen", {
      textDocument: { uri, languageId: "typescript", version: 1, text },
    });
    const doc = { textDocument: { uri } };

    // what each definition would be, with the action that saves it; watch values and test results inline
    let lenses = await c.request("textDocument/codeLens", doc);
    assert.deepEqual(titles(lenses).slice(0, 2), ["+ new lib.square", "Add to codebase"]);
    assert.equal(lenses[0].range.start.line, 2);
    const hints = await c.waitFor(async () => {
      const h = await c.request("textDocument/inlayHint", {
        ...doc,
        range: { start: { line: 0, character: 0 }, end: { line: 20, character: 0 } },
      });
      return h.length ? h : undefined;
    });
    assert.equal(hints[0].label, "⧩ 144");
    assert.equal(hints[0].position.line, 7);
    lenses = await c.request("textDocument/codeLens", doc);
    // the run may have finished before the lens was asked for, in which case it reports the cached result
    assert.ok(
      titles(lenses).some((t) => /^✓ passed( \(cached\))?$/.test(t)),
      titles(lenses).join(" | "),
    );

    // the Add action saves to the codebase; the lenses then show the hash
    const added = await c.request("workspace/executeCommand", {
      command: "tcm.add",
      arguments: [uri],
    });
    assert.equal(added, true);
    await c.waitFor(() =>
      c.notes.find(
        (n) => n.method === "window/showMessage" && /\+ new\s+lib\.square/.test(n.params.message),
      ),
    );
    lenses = await c.request("textDocument/codeLens", doc);
    assert.match(titles(lenses)[0]!, /^lib\.square #[a-z2-7]{8}$/);

    // an unsaved edit: the lens says what saving would do, and saving refuses until the file is saved
    const edited = text.replace("x * x", "x ** 2");
    c.notify("textDocument/didChange", {
      textDocument: { uri, version: 2 },
      contentChanges: [{ text: edited }],
    });
    lenses = await c.request("textDocument/codeLens", doc);
    assert.match(titles(lenses)[0]!, /^~ update lib\.square \(was #[a-z2-7]{8}\)$/);
    assert.equal(titles(lenses)[1], "Update codebase");
    assert.equal(
      await c.request("workspace/executeCommand", { command: "tcm.update", arguments: [uri] }),
      null,
    );
    await c.waitFor(() =>
      c.notes.find(
        (n) => n.method === "window/showMessage" && /Save scratch\.ts first/.test(n.params.message),
      ),
    );

    // hovers: a definition in the file, and a codebase name imported elsewhere
    c.notify("textDocument/didChange", {
      textDocument: { uri, version: 3 },
      contentChanges: [{ text }],
    });
    const own = await c.request("textDocument/hover", {
      ...doc,
      position: at(text, "square =", 2),
    });
    assert.match(own.contents.value, /\*\*lib\.square\*\* `#[a-z2-7]{8}`/);
    assert.match(own.contents.value, /Runs anywhere\./);
    assert.match(own.contents.value, /Square a number\./);
    const appFile = path.join(dir, "app.ts");
    const appUri = pathToFileURL(appFile).href;
    const app = `import { square } from "#codebase/lib";\n\nexport const area = (r: number) => 3 * square(r);\n`;
    fs.writeFileSync(appFile, app);
    c.notify("textDocument/didOpen", {
      textDocument: { uri: appUri, languageId: "typescript", version: 1, text: app },
    });
    const imported = await c.request("textDocument/hover", {
      textDocument: { uri: appUri },
      position: at(app, "square(r)", 1),
    });
    assert.match(imported.contents.value, /\*\*lib\.square\*\*/);
    assert.match(imported.contents.value, /1 named dependent\n/, "testSquare uses it");

    // tcm's own errors are diagnostics; type errors are TypeScript's to report, and stop hashing
    const withVar = text + "var counter = 0;\n";
    c.notify("textDocument/didChange", {
      textDocument: { uri, version: 4 },
      contentChanges: [{ text: withVar }],
    });
    const diag = await c.waitFor(() =>
      c.notes.findLast(
        (n) =>
          n.method === "textDocument/publishDiagnostics" &&
          n.params.uri === uri &&
          n.params.version === 4,
      ),
    );
    assert.equal(diag.params.diagnostics[0].source, "tcm");
    assert.match(diag.params.diagnostics[0].message, /top-level `var`/);
    assert.equal(diag.params.diagnostics[0].range.start.line, 8);
    const typeError = text.replace("square(12)", 'square("12")');
    c.notify("textDocument/didChange", {
      textDocument: { uri, version: 5 },
      contentChanges: [{ text: typeError }],
    });
    lenses = await c.request("textDocument/codeLens", doc);
    assert.deepEqual(titles(lenses), ["tcm: 1 type error; nothing hashed until they're fixed"]);

    await c.request("shutdown", null);
    c.notify("exit", null);
    await new Promise((r) => c.child.on("exit", r));
  })

uses