Skip to content

Commit

Permalink
refactor(runners): allow for custom runners
Browse files Browse the repository at this point in the history
  • Loading branch information
fredrikaverpil committed Oct 14, 2024
1 parent ea1c3a7 commit 38433a8
Show file tree
Hide file tree
Showing 17 changed files with 413 additions and 211 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,17 @@ return {
}
```

## Custom test runners

In neotest-golang, test runners are treated as first class citizens. The `go`
and `gotestsum` runners are provided out of the box and serves as examples of
how you could implement your own. See
[`options.lua`](lua/neotest-golang/options.lua) for more details.

This enables supplying your own functions for building the test command and
processing the test output. This feature is not for the faint of heart, as test
output processing is usually a quite complex story.

## 🙏 PRs are welcome

Improvement suggestion PRs to this repo are very much welcome, and I encourage
Expand Down
123 changes: 5 additions & 118 deletions lua/neotest-golang/init.lua
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
--- This is the main entry point for the neotest-golang adapter. It follows the
--- Neotest interface: https://github.com/nvim-neotest/neotest/blob/master/lua/neotest/adapters/interface.lua

local logger = require("neotest-golang.logging")
local options = require("neotest-golang.options")
local query = require("neotest-golang.query")
local runspec = require("neotest-golang.runspec")
local process = require("neotest-golang.process")
local lib = require("neotest-golang.lib")

local M = {}

Expand Down Expand Up @@ -63,68 +61,8 @@ end
--- @param args neotest.RunArgs
--- @return neotest.RunSpec | neotest.RunSpec[] | nil
function M.Adapter.build_spec(args)
--- The tree object, describing the AST-detected tests and their positions.
--- @type neotest.Tree
local tree = args.tree

--- The position object, describing the current directory, file or test.
--- @type neotest.Position
local pos = args.tree:data() -- NOTE: causes <file> is not accessible by the current user!

if not tree then
logger.error("Unexpectedly did not receive a neotest.Tree.")
return
end

-- Below is the main logic of figuring out how to execute tests. In short,
-- a "runspec" is defined for each command to execute.
-- Neotest also distinguishes between different "position types":
-- - "dir": A directory of tests
-- - "file": A single test file
-- - "namespace": A set of tests, collected under the same namespace
-- - "test": A single test
--
-- If a valid runspec is built and returned from this function, it will be
-- executed by Neotest. But if, for some reason, this function returns nil,
-- Neotest will call this function again, but using the next position type
-- (in this order: dir, file, namespace, test). This gives the ability to
-- have fallbacks.
-- For example, if a runspec cannot be built for a file of tests, we can
-- instead try to build a runspec for each individual test file. The end
-- result would in this case produce multiple commands to execute (for each
-- test) rather than one command for the file.
-- The idea here is not to have such fallbacks take place in the future, but
-- while this adapter is being developed, it can be useful to have such
-- functionality.

if pos.type == "dir" and pos.path == vim.fn.getcwd() then
-- A runspec is to be created, based on running all tests in the given
-- directory. In this case, the directory is also the current working
-- directory.
return runspec.dir.build(pos)
elseif pos.type == "dir" then
-- A runspec is to be created, based on running all tests in the given
-- directory. In this case, the directory is a sub-directory of the current
-- working directory.
return runspec.dir.build(pos)
elseif pos.type == "file" then
-- A runspec is to be created, based on on running all tests in the given
-- file.
return runspec.file.build(pos, tree, args.strategy)
elseif pos.type == "namespace" then
-- A runspec is to be created, based on running all tests in the given
-- namespace.
return runspec.namespace.build(pos)
elseif pos.type == "test" then
-- A runspec is to be created, based on on running the given test.
return runspec.test.build(pos, args.strategy)
end

logger.error(
"Unknown Neotest position type, "
.. "cannot build runspec with position type: "
.. pos.type
)
local runner = lib.cmd.runner_fallback(options.get().runner)
return options.get().runners[runner].build_spec(args)
end

--- Process the test command output and result. Populate test outcome into the
Expand All @@ -135,59 +73,8 @@ end
--- @param tree neotest.Tree
--- @return table<string, neotest.Result> | nil
function M.Adapter.results(spec, result, tree)
local pos = tree:data()

if pos.type == "dir" then
-- A test command executed a directory of tests and the output/status must
-- now be processed.
local results = process.test_results(spec, result, tree)
M.workaround_neotest_issue_391(result)
return results
elseif pos.type == "file" then
-- A test command executed a file of tests and the output/status must
-- now be processed.
local results = process.test_results(spec, result, tree)
M.workaround_neotest_issue_391(result)
return results
elseif pos.type == "namespace" then
-- A test command executed a namespace and the output/status must now be
-- processed.
local results = process.test_results(spec, result, tree)
M.workaround_neotest_issue_391(result)
return results
elseif pos.type == "test" then
-- A test command executed a single test and the output/status must now be
-- processed.
local results = process.test_results(spec, result, tree)
M.workaround_neotest_issue_391(result)
return results
end

logger.error(
"Cannot process test results due to unknown Neotest position type:"
.. pos.type
)
end

--- Workaround, to avoid JSON in output panel, erase contents of output.
--- @param result neotest.StrategyResult
function M.workaround_neotest_issue_391(result)
-- FIXME: once output is processed, erase file contents, so to avoid JSON in
-- output panel. This is a workaround for now, only because of
-- https://github.com/nvim-neotest/neotest/issues/391

-- NOTE: when emptying the file with vim.fn.writefil, this error was hit
-- when debugging:
-- E5560: Vimscript function must not be called in a lua loop callback
-- vim.fn.writefile({ "" }, result.output)

if result.output ~= nil then -- and vim.fn.filereadable(result.output) == 1 then
local file = io.open(result.output, "w")
if file ~= nil then
file:write("")
file:close()
end
end
local runner = lib.cmd.runner_fallback(options.get().runner)
return options.get().runners[runner].results(spec, result, tree)
end

--- Adapter options.
Expand Down
67 changes: 15 additions & 52 deletions lua/neotest-golang/lib/cmd.lua
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
--- Helper functions building the command to execute.

local async = require("neotest.async")

local logger = require("neotest-golang.logging")
local options = require("neotest-golang.options")
local json = require("neotest-golang.lib.json")
Expand Down Expand Up @@ -46,73 +44,38 @@ function M.golist_command()
return cmd
end

function M.test_command_in_package(package_or_path)
local go_test_required_args = { package_or_path }
local cmd, json_filepath = M.test_command(go_test_required_args)
return cmd, json_filepath
end

function M.test_command_in_package_with_regexp(package_or_path, regexp)
local go_test_required_args = { package_or_path, "-run", regexp }
local cmd, json_filepath = M.test_command(go_test_required_args)
return cmd, json_filepath
end
--- @class TestCommandData
--- @field package_name string | nil The Go package name.
--- @field position neotest.Position The position of the test.
--- @field regexp string | nil The regular expression to filter tests.

function M.test_command(go_test_required_args)
--- Generate the test command to execute.
--- @param cmd_data TestCommandData
--- @return table<string>, string | nil
function M.test_command(cmd_data)
--- The runner to use for running tests.
--- @type string
local runner = M.runner_fallback(options.get().runner)

--- The filepath to write test output JSON to, if using `gotestsum`.
--- Optional and custom filepath for writing test output.
--- @type string | nil
local json_filepath = nil
local test_output_filepath = nil

--- The final test command to execute.
--- @type table<string>
local cmd = {}

if runner == "go" then
cmd = M.go_test(go_test_required_args)
elseif runner == "gotestsum" then
json_filepath = vim.fs.normalize(async.fn.tempname())
cmd = M.gotestsum(go_test_required_args, json_filepath)
end

cmd, test_output_filepath = options.get().runners[runner].cmd(cmd_data)
logger.info("Test command: " .. table.concat(cmd, " "))

return cmd, json_filepath
end

function M.go_test(go_test_required_args)
local cmd = { "go", "test", "-json" }
local args = options.get().go_test_args
if type(args) == "function" then
args = args()
end
cmd = vim.list_extend(vim.deepcopy(cmd), args)
cmd = vim.list_extend(vim.deepcopy(cmd), go_test_required_args)
return cmd
end

function M.gotestsum(go_test_required_args, json_filepath)
local cmd = { "gotestsum", "--jsonfile=" .. json_filepath }
local gotestsum_args = options.get().gotestsum_args
if type(gotestsum_args) == "function" then
gotestsum_args = gotestsum_args()
end
local go_test_args = options.get().go_test_args
if type(go_test_args) == "function" then
go_test_args = go_test_args()
end
cmd = vim.list_extend(vim.deepcopy(cmd), gotestsum_args)
cmd = vim.list_extend(vim.deepcopy(cmd), { "--" })
cmd = vim.list_extend(vim.deepcopy(cmd), go_test_args)
cmd = vim.list_extend(vim.deepcopy(cmd), go_test_required_args)
return cmd
return cmd, test_output_filepath
end

function M.runner_fallback(executable)
if M.system_has(executable) == false then
logger.warn(
"Runner not found: " .. executable .. ". Will fall back to 'go'."
)
options.set({ runner = "go" })
return options.get().runner
end
Expand Down
57 changes: 55 additions & 2 deletions lua/neotest-golang/options.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ local logger = require("neotest-golang.logging")

local M = {}

local opts = {
runner = "go", -- or "gotestsum"
local defaults = {
runner = "go", -- corresponds to a key in the 'runners' table
go_test_args = { "-v", "-race", "-count=1" }, -- NOTE: can also be a function
gotestsum_args = { "--format=standard-verbose" }, -- NOTE: can also be a function
go_list_args = {}, -- NOTE: can also be a function
Expand All @@ -20,6 +20,59 @@ local opts = {
dev_notifications = false,
}

local runner_defaults = {
runners = {
go = {
build_spec = function(args)
local build_runspec =
require("neotest-golang.runners.gotest.build_runspec")
return build_runspec.build_gotest_spec(args)
end,
---@param cmd_data TestCommandData
cmd = function(cmd_data)
local build_testcmd =
require("neotest-golang.runners.gotest.build_testcmd")
return build_testcmd.test_command_builder(cmd_data, defaults)
end,
output_filepath = function()
return nil
end,
results = function(spec, result, tree)
local process_output =
require("neotest-golang.runners.gotest.process_output")
return process_output.process_gotest_results(spec, result, tree)
end,
},
gotestsum = {
build_spec = function(args)
-- gotestsum uses the same logic to build the runspec as the 'go' runner
local build_runspec =
require("neotest-golang.runners.gotest.build_runspec")
return build_runspec.build_gotest_spec(args)
end,
---@param cmd_data TestCommandData
cmd = function(cmd_data)
local build_testcmd =
require("neotest-golang.runners.gotestsum.build_testcmd")
return build_testcmd.test_command_builder(cmd_data, defaults)
end,
output_filepath = function()
local async = require("neotest.async")
local json_filepath = vim.fs.normalize(async.fn.tempname())
return json_filepath
end,
results = function(spec, result, tree)
-- gotestsum uses the same logic to process the results as the 'go' runner
local process_output =
require("neotest-golang.runners.gotest.process_output")
return process_output.process_gotest_results(spec, result, tree)
end,
},
},
}

local opts = vim.tbl_extend("force", defaults, runner_defaults)

function M.setup(user_opts)
if type(user_opts) == "table" and not vim.tbl_isempty(user_opts) then
for k, v in pairs(user_opts) do
Expand Down
Loading

0 comments on commit 38433a8

Please sign in to comment.