diff --git a/lua/neotest-golang/convert.lua b/lua/neotest-golang/convert.lua index 30307c3c..89d8fbaa 100644 --- a/lua/neotest-golang/convert.lua +++ b/lua/neotest-golang/convert.lua @@ -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 diff --git a/lua/neotest-golang/init.lua b/lua/neotest-golang/init.lua index 0531a406..1f1cce82 100644 --- a/lua/neotest-golang/init.lua +++ b/lua/neotest-golang/init.lua @@ -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 = {} @@ -78,27 +80,43 @@ function M.Adapter.build_spec(args) return end - -- Below is the main logic of figuring out how to execute test commands. - -- In short, a command can be constructed (also referred to as a "runspec", - -- based on whether the command runs all tests in a dir, file or if it runs - -- only a single test. + -- 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 + -- - "test": A single test + -- - "namespace": ? (unclear to me at this point what this is) + -- Depending on the current position type, different ways to build the + -- runspec are used. -- - -- If e.g. a directory of tests ('dir') is to be executed, but the function - -- returns nil, Neotest will try to instead use the 'file' strategy. If that - -- also returns nil, Neotest will finally try the 'test' strategy. - -- This means that if it is decided that e.g. the 'file' strategy won't work, - -- a fallback can be done, to instead rely on the 'test' strategy. + -- 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, 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. + -- + -- NOTE: Right now the adapter is not yet complete, and cannot + -- handle the 'file' position type. The goal is to try to support this, + -- as it is not efficient to run all tests in a file individually, when you + -- might want to run all tests in the file using one 'go test' command. 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 -- 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. @@ -109,7 +127,8 @@ function M.Adapter.build_spec(args) end vim.notify( - "Unknown Neotest execution strategy, cannot build runspec with: " + "Unknown Neotest test position type, " + .. "cannot build runspec with position type: " .. pos.type, vim.log.levels.ERROR ) @@ -118,8 +137,7 @@ 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. +--- TODO: implement parsing of 'file' position type results. --- --- @async --- @param spec neotest.RunSpec @@ -127,7 +145,13 @@ end --- @param tree neotest.Tree --- @return table | nil function M.Adapter.results(spec, result, tree) - if spec.context.test_type == "test" then + if spec.context.pos_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.pos_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) @@ -136,8 +160,8 @@ function M.Adapter.results(spec, result, tree) end vim.notify( - "Cannot process test results due to unknown test strategy:" - .. spec.context.test_type, + "Cannot process test results due to unknown Neotest position type:" + .. spec.context.pos_type, vim.log.levels.ERROR ) end diff --git a/lua/neotest-golang/results_dir.lua b/lua/neotest-golang/results_dir.lua new file mode 100644 index 00000000..d0f72149 --- /dev/null +++ b/lua/neotest-golang/results_dir.lua @@ -0,0 +1,303 @@ +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 +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 + local res = M.aggregate_data(spec, 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 +function M.aggregate_data(spec, tree, gotest_output) + local res = M.gather_neotest_data_and_set_defaults(tree) + res = M.decorate_with_go_package_and_test_name(spec, 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 +function M.gather_neotest_data_and_set_defaults(tree) + --- Internal data structure to store test result data. + --- @type table + local res = {} + + --- Table storing the name of the test (position.id) and the number of times + --- it was found in the tree. + --- @type table + 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 spec neotest.RunSpec +--- @param res table +--- @param gotest_output table +--- @return table +function M.decorate_with_go_package_and_test_name(spec, res, gotest_output) + -- Look for tests not in the "main" package. + 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) + local tweaked_pos_id = pos_id:gsub(" ", "_") + tweaked_pos_id = tweaked_pos_id:gsub('"', "") + tweaked_pos_id = tweaked_pos_id:gsub("::", "/") + if common_path ~= "" then + -- tests not in the "main" package + local combined_pattern = convert.to_lua_pattern(common_path) + .. "/(.-)/" + .. convert.to_lua_pattern(line.Test) + .. "$" + match = tweaked_pos_id:find(combined_pattern, 1, false) + elseif common_path == "" then + -- tests in the "main" package + local pattern = convert.to_lua_pattern(spec.cwd) + .. "/(.-)/" + .. convert.to_lua_pattern(line.Test) + .. "$" + match = tweaked_pos_id:find(pattern, 1, false) + end + + if match ~= nil then + test_data.gotest_data.pkg = line.Package + test_data.gotest_data.name = line.Test + break -- avoid iterating more JSON lines for this test + end + end + end + end + + return res +end + +--- Decorate the internal test result data with data from the 'go test' output. +--- @param res table +--- @param gotest_output table +--- @return table +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 +--- @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 +--- @param gotest_output table +--- @return table +function M.to_neotest_results(spec, result, res, gotest_output) + --- Neotest results. + --- @type table + 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 +--- @return table +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 diff --git a/lua/neotest-golang/runspec_dir.lua b/lua/neotest-golang/runspec_dir.lua new file mode 100644 index 00000000..d1dc1382 --- /dev/null +++ b/lua/neotest-golang/runspec_dir.lua @@ -0,0 +1,102 @@ +local options = require("neotest-golang.options") + +local M = {} + +--- Build runspec for a directory. +--- +--- Strategy: +--- 1. Find the go.mod file from pos.path. +--- 2. Run `go test` from the directory containing the go.mod file. +--- 3. Use the relative path from the go.mod file to pos.path as the test pattern. +--- @param pos neotest.Position +--- @return neotest.RunSpec | nil +function M.build(pos) + local go_mod_filepath = M.find_file_upwards("go.mod", pos.path) + if go_mod_filepath == nil then + vim.notify( + "The selected folder cannot be correlated to a Go project. " + .. "Will now attempt to run tests on a per-test basis instead.", + vim.log.levels.WARN + ) + return nil -- Deletgates away from the 'dir' position type + end + + local go_mod_folderpath = vim.fn.fnamemodify(go_mod_filepath, ":h") + local cwd = go_mod_folderpath + + -- calculate the relative path to pos.path from cwd + local relative_path = M.remove_base_path(cwd, pos.path) + local test_pattern = "./..." + if relative_path ~= "" then + test_pattern = "./" .. relative_path .. "/..." + end + + return M.build_dir_test_runspec(pos, cwd, test_pattern) +end + +--- Find a file upwards in the directory tree and return its path, if found. +--- @param filename string +--- @param start_path string +--- @return string | nil +function M.find_file_upwards(filename, start_path) + local scan = require("plenary.scandir") + local cwd = vim.fn.getcwd() + local found_filepath = nil + while start_path ~= cwd do + local files = scan.scan_dir( + start_path, + { search_pattern = filename, hidden = true, depth = 1 } + ) + if #files > 0 then + found_filepath = files[1] + break + end + start_path = vim.fn.fnamemodify(start_path, ":h") -- go up one directory + end + return found_filepath +end + +function M.remove_base_path(base_path, target_path) + if string.find(target_path, base_path, 1, true) == 1 then + return string.sub(target_path, string.len(base_path) + 2) + end + + return target_path +end + +--- Build runspec for a directory of tests +--- @param pos neotest.Position +--- @param cwd string +--- @param test_pattern string +--- @return neotest.RunSpec +function M.build_dir_test_runspec(pos, cwd, test_pattern) + local gotest = { + "go", + "test", + "-json", + } + + --- @type table + local go_test_args = { + test_pattern, + } + + local combined_args = + vim.list_extend(vim.deepcopy(options._go_test_args), go_test_args) + local gotest_command = vim.list_extend(vim.deepcopy(gotest), combined_args) + + --- @type neotest.RunSpec + local run_spec = { + command = gotest_command, + cwd = cwd, + context = { + id = pos.id, + test_filepath = pos.path, + pos_type = "dir", + }, + } + + return run_spec +end + +return M diff --git a/lua/neotest-golang/runspec_file.lua b/lua/neotest-golang/runspec_file.lua index 85de9fd3..10c72193 100644 --- a/lua/neotest-golang/runspec_file.lua +++ b/lua/neotest-golang/runspec_file.lua @@ -15,7 +15,7 @@ function M.build(pos, tree) context = { id = pos.id, skip = true, - test_type = "test", -- TODO: to be implemented as "file" later + pos_type = "test", -- TODO: to be implemented as "file" later }, } return run_spec diff --git a/lua/neotest-golang/runspec_test.lua b/lua/neotest-golang/runspec_test.lua index 84de5e47..0f9ce87f 100644 --- a/lua/neotest-golang/runspec_test.lua +++ b/lua/neotest-golang/runspec_test.lua @@ -37,7 +37,7 @@ function M.build(pos, strategy) context = { id = pos.id, test_filepath = pos.path, - test_type = "test", + pos_type = "test", }, } diff --git a/lua/neotest-golang/utils.lua b/lua/neotest-golang/utils.lua index 9ad7a85c..63322a72 100644 --- a/lua/neotest-golang/utils.lua +++ b/lua/neotest-golang/utils.lua @@ -7,4 +7,23 @@ function M.table_is_empty(t) return next(t) == nil end +--- Find the common path of two folderpaths. +--- @param path1 string +--- @param path2 string +--- @return string +function M.find_common_path(path1, path2) + local common = {} + local path1_parts = vim.split(path1, "/") + local path2_parts = vim.split(path2, "/") + for i = #path1_parts, 1, -1 do + if path1_parts[i] == path2_parts[#path2_parts] then + table.insert(common, 1, path1_parts[i]) + table.remove(path2_parts) + else + break + end + end + return table.concat(common, "/") +end + return M diff --git a/tests/unit/results_dir_spec.lua b/tests/unit/results_dir_spec.lua new file mode 100644 index 00000000..b40b9e19 --- /dev/null +++ b/tests/unit/results_dir_spec.lua @@ -0,0 +1,46 @@ +local utils = require("neotest-golang.utils") + +describe("Common parts of Go package and folderpath", function() + it("Go package is repo", function() + local go_package = "github.com/fredrikaverpil/my-service" + local test_file_folderpath = "/Users/fredrik/code/work/private/my-service" + + local common_path = utils.find_common_path(go_package, test_file_folderpath) + assert.are_equal(common_path, "my-service") + end) + + it("Go package is repo sub-folder", function() + local go_package = "github.com/fredrikaverpil/my-service/backend" + local test_file_folderpath = + "/Users/fredrik/code/work/private/my-service/backend" + + local common_path = utils.find_common_path(go_package, test_file_folderpath) + assert.are_equal(common_path, "my-service/backend") + end) + + it("Go package is deep repo sub folder", function() + local go_package = + "github.com/fredrikaverpil/my-service/backend/internal/outbound/spanner" + local test_file_folderpath = + "/Users/fredrik/code/work/private/my-service/backend/internal/outbound/spanner" + + local common_path = utils.find_common_path(go_package, test_file_folderpath) + assert.are_equal( + common_path, + "my-service/backend/internal/outbound/spanner" + ) + end) + + it( + "Go package does not share a common path with the tests folderpath", + function() + local go_package = "github.com/fredrikaverpil/neotest-golang" + local test_file_folderpath = + "/Users/fredrik/code/work/public/neotest-golang/go/test" + + local common_path = + utils.find_common_path(go_package, test_file_folderpath) + assert.are_equal(common_path, "") + end + ) +end)