testLanguageServer
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)); })