diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c83e263 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8ce4949 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/src/client.ts b/src/client.ts index c2428fc..53042bb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,23 @@ -import { workspace, WorkspaceFolder, Uri, window, OutputChannel } from "vscode"; +import { + workspace, + WorkspaceFolder, + Uri, + window, + tests, + TestRunProfileKind, + Range, + TestItem, + TestMessage, + TestController, + OutputChannel, + CancellationToken, + TestRunRequest, +} from "vscode"; import { LanguageClient, LanguageClientOptions, + ServerCapabilities, ServerOptions, TextDocumentFilter, } from "vscode-languageclient/node"; @@ -10,6 +25,33 @@ import { import { extensionName, languageId } from "./constants"; import findNargo from "./find-nargo"; +type NargoCapabilities = { + nargo?: { + tests?: { + fetch: boolean; + run: boolean; + update: boolean; + }; + }; +}; + +type NargoTests = { + package: string; + uri: string; + tests?: { + id: string; + label: string; + uri: string; + range: Range; + }[]; +}; + +type RunTestResult = { + id: string; + result: "pass" | "fail" | "error"; + message: string; +}; + function globFromUri(uri: Uri, glob: string) { // globs always need to use `/` return `${uri.fsPath}${glob}`.replaceAll("\\", "/"); @@ -40,6 +82,11 @@ export default class Client extends LanguageClient { #args: string[]; #output: OutputChannel; + // This function wasn't added until vscode 1.81.0 so fake the type + #testController: TestController & { + invalidateTestResults?: (item: TestItem) => void; + }; + constructor(uri: Uri, workspaceFolder?: WorkspaceFolder) { let outputChannel = window.createOutputChannel(extensionName, languageId); @@ -78,6 +125,153 @@ export default class Client extends LanguageClient { this.#command = command; this.#args = args; this.#output = outputChannel; + + // TODO: Figure out how to do type-safe onNotification + this.onNotification("nargo/tests/update", (testData: NargoTests) => { + this.#updateTests(testData); + }); + + this.registerFeature({ + fillClientCapabilities: () => {}, + initialize: (capabilities: ServerCapabilities & NargoCapabilities) => { + outputChannel.appendLine(`${JSON.stringify(capabilities)}`); + if (typeof capabilities.nargo?.tests !== "undefined") { + this.#testController = tests.createTestController( + // We prefix with our ID namespace but we also tie these to the URI since they need to be unique + `NoirWorkspaceTests-${uri.toString()}`, + "Noir Workspace Tests" + ); + + if (capabilities.nargo.tests.fetch) { + // TODO: reload a single test if provided as the function argument + this.#testController.resolveHandler = async (test) => { + await this.#fetchTests(); + }; + this.#testController.refreshHandler = async (token) => { + await this.#refreshTests(token); + }; + } + + if (capabilities.nargo.tests.run) { + this.#testController.createRunProfile( + "Run Tests", + TestRunProfileKind.Run, + async (request, token) => { + await this.#runTest(request, token); + }, + true + ); + } + } + }, + getState: () => { + return { kind: "static" }; + }, + dispose: () => { + if (this.#testController) { + this.#testController.dispose(); + } + }, + }); + } + + async #fetchTests() { + const response = await this.sendRequest("nargo/tests", {}); + + response.forEach((testData) => { + this.#createTests(testData); + }); + } + + async #refreshTests(token: CancellationToken) { + const response = await this.sendRequest( + "nargo/tests", + {}, + token + ); + response.forEach((testData) => { + this.#updateTests(testData); + }); + } + + async #runTest(request: TestRunRequest, token: CancellationToken) { + const run = this.#testController.createTestRun(request); + const queue: TestItem[] = []; + + // Loop through all included tests, or all known tests, and add them to our queue + if (request.include) { + request.include.forEach((test) => queue.push(test)); + } else { + this.#testController.items.forEach((test) => queue.push(test)); + } + + while (queue.length > 0 && !token.isCancellationRequested) { + const test = queue.pop()!; + + // Skip tests the user asked to exclude + if (request.exclude?.includes(test)) { + continue; + } + + // We don't run our test headers since they are just for grouping + // but this is fine because the test pass/fail icons are propagated upward + if (test.parent) { + // If we have these tests, the server will be able to run them with this message + const { id, result, message } = await this.sendRequest( + "nargo/tests/run", + { + id: test.id, + }, + token + ); + + // TODO: Handle `test.id !== id`. I'm not sure if it is possible for this to happen in normal usage + + if (result === "pass") { + run.passed(test); + continue; + } + + if (result === "fail" || result === "error") { + run.failed(test, new TestMessage(message)); + continue; + } + } + + // After tests are run (if any), we add any children to the queue + test.children.forEach((test) => queue.push(test)); + } + + run.end(); + } + + #createTests(testData: NargoTests) { + let pkg = this.#testController.createTestItem( + testData.package, + testData.package + ); + + testData.tests.forEach((test) => { + let item = this.#testController.createTestItem( + test.id, + test.label, + Uri.parse(test.uri) + ); + item.range = test.range; + pkg.children.add(item); + }); + + this.#testController.items.add(pkg); + } + + #updateTests(testData: NargoTests) { + // This function wasn't added until vscode 1.81.0 so we check for it + if (typeof this.#testController.invalidateTestResults === "function") { + let pkg = this.#testController.items.get(testData.package); + this.#testController.invalidateTestResults(pkg); + } + + this.#createTests(testData); } async start(): Promise {