Skip to content

Commit

Permalink
feat: add support for position type 'dir'
Browse files Browse the repository at this point in the history
This adds substantial performance improvements on being able to run all
tests in a Go project using one 'go test' command, instead of executing
on a per-test basis.
  • Loading branch information
fredrikaverpil committed Jun 16, 2024
1 parent ee6cd4c commit 993bb3d
Show file tree
Hide file tree
Showing 6 changed files with 497 additions and 4 deletions.
41 changes: 41 additions & 0 deletions lua/neotest-golang/convert.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,45 @@ function M.to_gotest_test_name(pos_id)
return test_name
end

--- Escape characters, for usage of string as pattern in Lua..
--- - `.` (matches any character)
--- - `%` (used to escape special characters)
--- - `+` (matches 1 or more of the previous character or class)
--- - `*` (matches 0 or more of the previous character or class)
--- - `-` (matches 0 or more of the previous character or class, in the shortest sequence)
--- - `?` (makes the previous character or class optional)
--- - `^` (at the start of a pattern, matches the start of the string; in a character class `[]`, negates the class)
--- - `$` (matches the end of the string)
--- - `[]` (defines a character class)
--- - `()` (defines a capture)
--- - `:` (used in certain pattern items like `%b()`)
--- - `=` (used in certain pattern items like `%b()`)
--- - `<` (used in certain pattern items like `%b<>`)
--- - `>` (used in certain pattern items like `%b<>`)
--- @param str string
function M.to_lua_pattern(str)
local special_characters = {
".",
"%",
"+",
"*",
"-",
"?",
"^",
"$",
"[",
"]",
"(",
")",
":",
"=",
"<",
">",
}
for _, character in ipairs(special_characters) do
str = str:gsub("%" .. character, "%%%" .. character)
end
return str
end

return M
15 changes: 11 additions & 4 deletions lua/neotest-golang/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

local options = require("neotest-golang.options")
local ast = require("neotest-golang.ast")
local runspec_dir = require("neotest-golang.runspec_dir")
local runspec_file = require("neotest-golang.runspec_file")
local runspec_test = require("neotest-golang.runspec_test")
local results_dir = require("neotest-golang.results_dir")
local results_test = require("neotest-golang.results_test")

local M = {}
Expand Down Expand Up @@ -93,12 +95,12 @@ function M.Adapter.build_spec(args)
-- 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 -- TODO: to implement, delegate on to next strategy
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 -- TODO: to implement, delegate on to next strategy
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.
Expand All @@ -118,7 +120,6 @@ end
--- Process the test command output and result. Populate test outcome into the
--- Neotest internal tree structure.
---
--- TODO: implement parsing of 'dir' strategy results.
--- TODO: implement parsing of 'file' strategy results.
---
--- @async
Expand All @@ -127,7 +128,13 @@ end
--- @param tree neotest.Tree
--- @return table<string, neotest.Result> | nil
function M.Adapter.results(spec, result, tree)
if spec.context.test_type == "test" then
if spec.context.test_type == "dir" then
-- A test command executed a directory of tests and the output/status must
-- now be processed.
local results = results_dir.results(spec, result, tree)
M.workaround_neotest_issue_391(result)
return results
elseif spec.context.test_type == "test" then
-- A test command executed a single test and the output/status must now be
-- processed.
local results = results_test.results(spec, result, tree)
Expand Down
295 changes: 295 additions & 0 deletions lua/neotest-golang/results_dir.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
local async = require("neotest.async")

local convert = require("neotest-golang.convert")
local json = require("neotest-golang.json")
local utils = require("neotest-golang.utils")

--- @class TestData
--- @field status neotest.ResultStatus
--- @field short? string Shortened output string
--- @field errors? neotest.Error[]
--- @field neotest_data neotest.Position
--- @field gotest_data GoTestData
--- @field duplicate_test_detected boolean

--- @class GoTestData
--- @field name string Go test name.
--- @field pkg string Go package.
--- @field output? string[] Go test output.

local M = {}

--- Process the results from the test command executing all tests in a
--- directory.
--- @param spec neotest.RunSpec
--- @param result neotest.StrategyResult
--- @param tree neotest.Tree
--- @return table<string, neotest.Result>
function M.results(spec, result, tree)
--- The raw output from the 'go test -json' command.
--- @type table
local raw_output = async.fn.readfile(result.output)

--- The 'go test' JSON output, converted into a lua table.
--- @type table
local gotest_output = json.process_json(raw_output)

--- Internal data structure to store test result data.
--- @type table<string, TestData>
local res = M.aggregate_data(tree, gotest_output)

M.show_warnings(res)

-- DEBUG: enable the following to see the internal test result data.
-- vim.notify(vim.inspect(res), vim.log.levels.DEBUG)

local neotest_results = M.to_neotest_results(spec, result, res, gotest_output)

-- DEBUG: enable the following to see the final Neotest result.
-- vim.notify(vim.inspect(neotest_results), vim.log.levels.DEBUG)

return neotest_results
end

--- Aggregate neotest data and 'go test' output data.
--- @param tree neotest.Tree
--- @param gotest_output table
--- @return table<string, TestData>
function M.aggregate_data(tree, gotest_output)
local res = M.gather_neotest_data_and_set_defaults(tree)
res = M.decorate_with_go_package_and_test_name(res, gotest_output)
res = M.decorate_with_go_test_results(res, gotest_output)
return res
end

--- Generate the internal test result data which will be used by neotest-golang
--- before handing over the final results onto Neotest.
--- @param tree neotest.Tree
--- @return table<string, TestData>
function M.gather_neotest_data_and_set_defaults(tree)
--- Internal data structure to store test result data.
--- @type table<string, TestData>
local res = {}

--- Table storing the name of the test (position.id) and the number of times
--- it was found in the tree.
--- @type table<string, number>
local dupes = {}

for _, node in tree:iter_nodes() do
--- @type neotest.Position
local pos = node:data()

if pos.type == "test" then
res[pos.id] = {
status = "skipped",
errors = {},
neotest_data = pos,
gotest_data = {
name = "",
pkg = "",
output = {},
},
duplicate_test_detected = false,
}

-- detect duplicate test names
if dupes[pos.id] == nil then
dupes[pos.id] = 1
else
dupes[pos.id] = dupes[pos.id] + 1
res[pos.id].duplicate_test_detected = true
end
end
end
return res
end

--- Decorate the internal test result data with go package and test name.
--- This is an important step to associate the test results with the tree nodes
--- as the 'go test' JSON output contains keys 'Package' and 'Test'.
--- @param res table<string, TestData>
--- @param gotest_output table
--- @return table<string, TestData>
function M.decorate_with_go_package_and_test_name(res, gotest_output)
for pos_id, test_data in pairs(res) do
for _, line in ipairs(gotest_output) do
if line.Action == "run" and line.Test ~= nil then
local folderpath = vim.fn.fnamemodify(test_data.neotest_data.path, ":h")
local match = nil
local common_path = utils.find_common_path(line.Package, folderpath)

if common_path ~= "" then
local tweaked_pos_od = pos_id:gsub(" ", "_")
tweaked_pos_od = tweaked_pos_od:gsub('"', "")
tweaked_pos_od = tweaked_pos_od:gsub("::", "/")

local combined_pattern = convert.to_lua_pattern(common_path)
.. "/(.-)/"
.. convert.to_lua_pattern(line.Test)
.. "$"

match = tweaked_pos_od:match(combined_pattern)
end
if match ~= nil then
test_data.gotest_data.pkg = line.Package
test_data.gotest_data.name = line.Test
break -- avoid iterating over the rest of the 'go test' output lines
end
end
end
end

return res
end

--- Decorate the internal test result data with data from the 'go test' output.
--- @param res table<string, TestData>
--- @param gotest_output table
--- @return table<string, TestData>
function M.decorate_with_go_test_results(res, gotest_output)
for pos_id, test_data in pairs(res) do
for _, line in ipairs(gotest_output) do
if
test_data.gotest_data.pkg == line.Package
and test_data.gotest_data.name == line.Test
then
-- record test status
if line.Action == "pass" then
test_data.status = "passed"
elseif line.Action == "fail" then
test_data.status = "failed"
elseif line.Action == "output" then
test_data.gotest_data.output =
vim.list_extend(test_data.gotest_data.output, { line.Output })

-- determine test filename
local test_filename = "_test.go" -- approximate test filename
if test_data.neotest_data ~= nil then
-- node data is available, get the exact test filename
local test_filepath = test_data.neotest_data.path
test_filename = vim.fn.fnamemodify(test_filepath, ":t")
end

-- search for error message and line number
local matched_line_number =
string.match(line.Output, test_filename .. ":(%d+):")
if matched_line_number ~= nil then
local line_number = tonumber(matched_line_number)
local message =
string.match(line.Output, test_filename .. ":%d+: (.*)")
if line_number ~= nil and message ~= nil then
table.insert(test_data.errors, {
line = line_number - 1, -- neovim lines are 0-indexed
message = message,
})
end
end
end
end
end
end
return res
end

--- Show warnings.
--- @param d table<string, TestData>
--- @return nil
function M.show_warnings(d)
-- warn if Go package/test is missing from tree node.
-- TODO: make configurable to skip this or use different log level?
for pos_id, test_data in pairs(d) do
if test_data.gotest_data.pkg == "" or test_data.gotest_data.name == "" then
vim.notify(
"Unable to associate go package/test with neotest tree node: " .. pos_id,
vim.log.levels.WARN
)
end
end

-- warn about duplicate tests
-- TODO: make debug level configurable
for pos_id, test_data in pairs(d) do
if test_data.duplicate_test_detected == true then
vim.notify(
"Duplicate test name detected: "
.. test_data.gotest_data.pkg
.. "/"
.. test_data.gotest_data.name,
vim.log.levels.WARN
)
end
end
end

--- Populate final Neotest results based on internal test result data.
--- @param spec neotest.RunSpec
--- @param result neotest.StrategyResult
--- @param res table<string, TestData>
--- @param gotest_output table
--- @return table<string, neotest.Result>
function M.to_neotest_results(spec, result, res, gotest_output)
--- Neotest results.
--- @type table<string, neotest.Result>
local neotest_results = {}

-- populate all test results onto the Neotest format.
for pos_id, test_data in pairs(res) do
local test_output_path = vim.fs.normalize(async.fn.tempname())
async.fn.writefile(test_data.gotest_data.output, test_output_path)
neotest_results[pos_id] = {
status = test_data.status,
errors = test_data.errors,
output = test_output_path, -- NOTE: could be slow when running many tests?
}
end

neotest_results =
M.decorate_with_command_data(spec, result, gotest_output, neotest_results)

return neotest_results
end

--- Decorate the final Neotest results with the data from the test command that
--- was executed.
--- @param spec neotest.RunSpec
--- @param result neotest.StrategyResult
--- @param gotest_output table
--- @param neotest_results table<string, neotest.Result>
--- @return table<string, neotest.Result>
function M.decorate_with_command_data(
spec,
result,
gotest_output,
neotest_results
)
--- Test command (e.g. 'go test') status.
--- @type neotest.ResultStatus
local test_command_status = "skipped"
if result.code == 0 then
test_command_status = "passed"
else
test_command_status = "failed"
end

--- Full 'go test' output (parsed from JSON).
--- @type table
local full_output = {}
local test_command_output_path = vim.fs.normalize(async.fn.tempname())
for _, line in ipairs(gotest_output) do
if line.Action == "output" then
table.insert(full_output, line.Output)
end
end
async.fn.writefile(full_output, test_command_output_path)

-- register properties on the directory node that was run
neotest_results[spec.context.id] = {
status = test_command_status,
output = test_command_output_path,
}

return neotest_results
end

return M
Loading

0 comments on commit 993bb3d

Please sign in to comment.