diff --git a/nimlangserver.nim b/nimlangserver.nim index 13bf63f..29d8a00 100644 --- a/nimlangserver.nim +++ b/nimlangserver.nim @@ -66,6 +66,7 @@ proc registerRoutes(srv: RpcSocketServer, ls: LanguageServer) = srv.register("extension/suggest", wrapRpc(partial(extensionSuggest, ls))) srv.register("extension/tasks", wrapRpc(partial(tasks, ls))) srv.register("extension/runTask", wrapRpc(partial(runTask, ls))) + srv.register("extension/listTests", wrapRpc(partial(listTests, ls))) #Notifications srv.register("$/cancelRequest", wrapRpc(partial(cancelRequest, ls))) srv.register("initialized", wrapRpc(partial(initialized, ls))) diff --git a/protocol/types.nim b/protocol/types.nim index 102b136..d954911 100644 --- a/protocol/types.nim +++ b/protocol/types.nim @@ -1,5 +1,6 @@ import json import options +import tables type OptionalSeq*[T] = Option[seq[T]] @@ -1042,3 +1043,22 @@ type RunTaskResult* = object command*: seq[string] #command and args output*: seq[string] #output lines + + TestInfo* = object + name*: string + line*: int + file*: string + + TestSuiteInfo* = object + name*: string #The suite name, empty if it's a global test + tests*: seq[TestInfo] + + TestProjectInfo* = object + entryPoints*: seq[string] + suites*: Table[string, TestSuiteInfo] + + ListTestsParams* = object + entryPoints*: seq[string] #can be patterns? if empty we could do the same as nimble does or just run `nimble test args` + + ListTestsResult* = object + projectInfo*: TestProjectInfo \ No newline at end of file diff --git a/routes.nim b/routes.nim index 4e35875..6d640f8 100644 --- a/routes.nim +++ b/routes.nim @@ -19,8 +19,9 @@ import std/[strscans, times, json, parseutils, strutils], ls, stew/[byteutils], - nimexpand - + nimexpand, + testrunner + import macros except error proc getNphPath(): Option[string] = @@ -847,6 +848,16 @@ proc runTask*( debug "Ran nimble cmd/task", command = $params.command, output = $result.output await process.shutdownChildProcess() +proc listTests*( + ls: LanguageServer, params: ListTestsParams +): Future[ListTestsResult] {.async.} = + let config = await ls.getWorkspaceConfiguration() + let nimPath = config.getNimPath() + if nimPath.isNone: + return ListTestsResult(projectInfo: TestProjectInfo(entryPoints: params.entryPoints, suites: initTable[string, TestSuiteInfo]())) + let testProjectInfo = await ls.listTestsForEntryPoint(params.entryPoints, nimPath.get()) + result.projectInfo = testProjectInfo + #Notifications proc initialized*(ls: LanguageServer, _: JsonNode): Future[void] {.async.} = debug "Client initialized." diff --git a/testrunner.nim b/testrunner.nim new file mode 100644 index 0000000..29b00f7 --- /dev/null +++ b/testrunner.nim @@ -0,0 +1,58 @@ +import std/[os, osproc, strscans, tables, sequtils, enumerate, strutils] +import chronos, chronos/asyncproc +import protocol/types +import ls +import chronicles +import stew/byteutils +import utils + +proc extractTestInfo*(rawOutput: string): TestProjectInfo = + result.suites = initTable[string, TestSuiteInfo]() + let lines = rawOutput.split("\n") + var currentSuite = "" + + for i, line in enumerate(lines): + var name, file, ignore: string + var lineNumber: int + if scanf(line, "Suite: $*", name): + currentSuite = name.strip() + result.suites[currentSuite] = TestSuiteInfo(name: currentSuite) + # echo "Found suite: ", currentSuite + + elif scanf(line, "$*Test: $*", ignore, name): + let insideSuite = line.startsWith("\t") + # Use currentSuite if inside a suite, empty string if not + let suiteName = if insideSuite: currentSuite else: "" + + #File is always next line of a test + if scanf(lines[i+1], "$*File:$*:$i", ignore, file, lineNumber): + var testInfo = TestInfo(name: name.strip(), file: file.strip(), line: lineNumber) + # echo "Adding test: ", testInfo.name, " to suite: ", suiteName + result.suites[suiteName].tests.add(testInfo) + +proc listTestsForEntryPoint*( + ls: LanguageServer, entryPoints: seq[string], nimPath: string +): Future[TestProjectInfo] {.async.} = + #For now only one entry point is supported + assert entryPoints.len == 1 + let entryPoint = entryPoints[0] + if not fileExists(entryPoint): + error "Entry point does not exist", entryPoint = entryPoint + return TestProjectInfo() + let process = await startProcess( + nimPath, + arguments = @["c", "-d:unittest2ListTests", "-r", "--listFullPaths", entryPoints[0]], + options = {UsePath}, + stderrHandle = AsyncProcess.Pipe, + stdoutHandle = AsyncProcess.Pipe, + ) + try: + let res = await process.waitForExit(15.seconds) + if res != 0: + error "Failed to list tests", nimPath = nimPath, entryPoint = entryPoints[0], res = res + error "An error occurred while listing tests", error = string.fromBytes(process.stderrStream.read().await) + else: + let rawOutput = string.fromBytes(process.stdoutStream.read().await) + result = extractTestInfo(rawOutput) + finally: + await shutdownChildProcess(process) diff --git a/tests/textensions.nim b/tests/textensions.nim index dcae438..31fb50f 100644 --- a/tests/textensions.nim +++ b/tests/textensions.nim @@ -6,6 +6,7 @@ import json_rpc/[rpcclient] import chronicles import lspsocketclient import chronos/asyncproc +import testhelpers suite "Nimlangserver extensions": let cmdParams = CommandLineParams(transport: some socket, port: getNextFreePort()) @@ -70,3 +71,30 @@ suite "Nimlangserver extensions": check tasks.len == 3 check tasks[0].name == "helloWorld" check tasks[0].description == "hello world" + + test "calling extension/test should return all existing tests": + #We first need to initialize the nimble project + let projectDir = getCurrentDir() / "tests" / "projects" / "testrunner" + cd projectDir: + let (output, _) = execNimble("install", "-l") + discard execNimble("setup") + + let initParams = + InitializeParams %* { + "processId": %getCurrentProcessId(), + "rootUri": fixtureUri("projects/testrunner/"), + "capabilities": + {"window": {"workDoneProgress": true}, "workspace": {"configuration": true}}, + } + let initializeResult = waitFor client.initialize(initParams) + + let listTestsParams = ListTestsParams(entryPoints: @["tests/projects/testrunner/tests/sampletests.nim".absolutePath]) + let tests = client.call("extension/listTests", jsonutils.toJson(listTestsParams)).waitFor().jsonTo( + ListTestsResult + ) + let testProjectInfo = tests.projectInfo + check testProjectInfo.suites.len == 3 + check testProjectInfo.suites["Sample Tests"].tests.len == 1 + check testProjectInfo.suites["Sample Tests"].tests[0].name == "Sample Test" + check testProjectInfo.suites["Sample Tests"].tests[0].file == "sampletests.nim" + check testProjectInfo.suites["Sample Tests"].tests[0].line == 4 diff --git a/tests/ttestrunner.nim b/tests/ttestrunner.nim index 2d6e08b..92ff187 100644 --- a/tests/ttestrunner.nim +++ b/tests/ttestrunner.nim @@ -1,48 +1,7 @@ import unittest import std/[os, osproc, strscans, tables, sequtils, enumerate, strutils] import testhelpers - -type - TestInfo* = object - name*: string - line*: int - file*: string - - TestSuiteInfo* = object - name*: string #The suite name, empty if it's a global test - tests*: seq[TestInfo] - - TestProjectInfo* = object - entryPoints*: seq[string] - suites*: Table[string, TestSuiteInfo] - - - - -proc extractTestInfo*(rawOutput: string): TestProjectInfo = - result.suites = initTable[string, TestSuiteInfo]() - let lines = rawOutput.split("\n") - var currentSuite = "" - - for i, line in enumerate(lines): - var name, file, ignore: string - var lineNumber: int - if scanf(line, "Suite: $*", name): - currentSuite = name.strip() - result.suites[currentSuite] = TestSuiteInfo(name: currentSuite) - # echo "Found suite: ", currentSuite - - elif scanf(line, "$*Test: $*", ignore, name): - let insideSuite = line.startsWith("\t") - # Use currentSuite if inside a suite, empty string if not - let suiteName = if insideSuite: currentSuite else: "" - - #File is always next line of a test - if scanf(lines[i+1], "$*File:$*:$i", ignore, file, lineNumber): - var testInfo = TestInfo(name: name.strip(), file: file.strip(), line: lineNumber) - # echo "Adding test: ", testInfo.name, " to suite: ", suiteName - result.suites[suiteName].tests.add(testInfo) - +import testrunner suite "Test Parser":