diff --git a/lua/neotest-golang/results_dir.lua b/lua/neotest-golang/results_dir.lua index 3c923784..cc0bf579 100644 --- a/lua/neotest-golang/results_dir.lua +++ b/lua/neotest-golang/results_dir.lua @@ -4,130 +4,170 @@ local convert = require("neotest-golang.convert") local json = require("neotest-golang.json") local utils = require("neotest-golang.utils") +--- @class InternalResult +--- @field status neotest.ResultStatus +--- @field output? string[] Go test output. +--- @field short? string Shortened output string +--- @field errors? neotest.Error[] +--- @field neotest_node_data neotest.Position +--- @field go_test_data GoTestData +--- @field duplicate_test_detected boolean + +--- @class GoTestData +--- @field name string +--- @field package string + 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 function M.results(spec, result, tree) - --- The raw output from the 'go test' command. + --- The raw output from the 'go test -json' command. --- @type table local raw_output = async.fn.readfile(result.output) - --- The 'go test -json' output. + --- The 'go test' JSON output, converted into a lua table. --- @type table - local jsonlines = json.process_json(raw_output) - - --- Internal table to hold all test result data. - --- @type table, table> - local internal_results = {} - - -- record neotest node data - local duplicates = {} - for idx, node in tree:iter_nodes() do - local node_data = node:data() - - if node_data.type == "test" then - -- Example node: - -- { - -- id = "/Users/fredrik/code/public/neotest-golang/backend/internal/core/model/something_test.go::TestSomething:"a sub test", - -- name = "a sub test", - -- path = "/Users/fredrik/code/public/neotest-golang/backend/internal/core/model/something_test.go", - -- range = { 12, 0, 164, 1 }, - -- type = "test" - -- }, - - internal_results[node_data.id] = { - status = "skipped", - output = {}, - errors = {}, - neotest_node_data = node_data, - go_test_data = {}, - duplicate_test_detected = false, + local gotest_output = json.process_json(raw_output) + + --- Internal data structure to store test results. + --- @type table + local d = M.aggregate_data(tree, gotest_output) + + M.show_warnings(d) + + local neotest_results = M.to_neotest_results(spec, result, d, gotest_output) + + -- FIXME: once output is parsed, 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 + vim.fn.writefile({ "" }, result.output) + + -- DEBUG: enable the following to see the collected data + -- vim.notify(vim.inspect(internal_results), vim.log.levels.DEBUG) + + return neotest_results +end + +function M.aggregate_data(tree, gotest_output) + local d = M.gather_neotest_data_and_set_defaults(tree) + d = M.decorate_with_go_package_and_test_name(d, gotest_output) + d = M.decorate_with_go_test_results(d, gotest_output) + return d +end + +--- Generate the internal 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 results. + --- @type table + local d = {} + + --- 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 + d[pos.id] = { + status = "skipped", -- default + output = {}, -- default -- TODO: move into go_test_data + errors = {}, -- default -- TODO: move into go_test_data + neotest_node_data = pos, -- TODO: rename to neotest_position_data + go_test_data = { + name = "", -- default + package = "", -- default + }, -- default + duplicate_test_detected = false, -- default } - -- detect duplicates - if duplicates[node_data.id] == nil then - duplicates[node_data.id] = 1 + -- detect duplicate test names + if dupes[pos.id] == nil then + dupes[pos.id] = 1 else - duplicates[node_data.id] = duplicates[node_data.id] + 1 - internal_results[node_data.id].duplicate_test_detected = true + dupes[pos.id] = dupes[pos.id] + 1 + d[pos.id].duplicate_test_detected = true end end end + return d +end - -- associate neotest node data with go test package and test name - for neotest_node_id in pairs(internal_results) do - for _, line in ipairs(jsonlines) do - -- Example line: - -- { - -- Action = "pass", - -- Elapsed = 0, - -- Package = "github.com/fredrikaverpil/neotest-golang/internal/core/model", - -- Test = "TestSomething/a_sub_test", - -- Time = "2024-06-13T22:33:28.302953+02:00" - -- } - +--- Decorate the internal results 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 d table +--- @param gotest_output table +--- @return table +function M.decorate_with_go_package_and_test_name(d, gotest_output) + for pos_id in pairs(d) do + for _, line in ipairs(gotest_output) do if line.Action == "run" and line.Test ~= nil then - -- remove filename from path - local folderpath = vim.fn.fnamemodify( - internal_results[neotest_node_id].neotest_node_data.path, - ":h" - ) -- TODO: would be nicer if this was handled by the common_parts function - + local folderpath = + vim.fn.fnamemodify(d[pos_id].neotest_node_data.path, ":h") local match = nil - local partial_path = utils.find_common_path(line.Package, folderpath) + local common_path = utils.find_common_path(line.Package, folderpath) - if partial_path ~= "" then - local tweaked_neotest_node_id = neotest_node_id:gsub(" ", "_") + if common_path ~= "" then + local tweaked_neotest_node_id = pos_id:gsub(" ", "_") tweaked_neotest_node_id = tweaked_neotest_node_id:gsub('"', "") tweaked_neotest_node_id = tweaked_neotest_node_id:gsub("::", "/") - local combined_pattern = convert.to_lua_pattern(partial_path) + local combined_pattern = convert.to_lua_pattern(common_path) .. "/(.-)/" .. convert.to_lua_pattern(line.Test) .. "$" - -- TODO: how to handle root level of package, when there is no common path - match = tweaked_neotest_node_id:match(combined_pattern) end if match ~= nil then - internal_results[neotest_node_id].go_test_data = { + d[pos_id].go_test_data = { package = line.Package, name = line.Test, } - - break + break -- avoid iterating over the rest of the 'go test' output lines end end end end - for neotest_node_id in pairs(internal_results) do - for _, line in ipairs(jsonlines) do + return d +end + +--- Decorate the internal results with data from the 'go test' output. +--- @param d table +--- @param gotest_output table +--- @return table +function M.decorate_with_go_test_results(d, gotest_output) + for pos_id in pairs(d) do + for _, line in ipairs(gotest_output) do if - internal_results[neotest_node_id].go_test_data.package == line.Package - and internal_results[neotest_node_id].go_test_data.name == line.Test + d[pos_id].go_test_data.package == line.Package + and d[pos_id].go_test_data.name == line.Test then -- record test status if line.Action == "pass" then - internal_results[neotest_node_id].status = "passed" + d[pos_id].status = "passed" elseif line.Action == "fail" then - internal_results[neotest_node_id].status = "failed" + d[pos_id].status = "failed" elseif line.Action == "output" then -- append line.Output to output field - internal_results[neotest_node_id].output = vim.list_extend( - internal_results[neotest_node_id].output, - { line.Output } - ) + d[pos_id].output = vim.list_extend(d[pos_id].output, { line.Output }) -- determine test filename local test_filename = "_test.go" -- approximate test filename - if internal_results[neotest_node_id].neotest_node_data ~= nil then + if d[pos_id].neotest_node_data ~= nil then -- node data is available, get the exact test filename - local test_filepath = - internal_results[neotest_node_id].neotest_node_data.path + local test_filepath = d[pos_id].neotest_node_data.path test_filename = vim.fn.fnamemodify(test_filepath, ":t") end @@ -139,7 +179,7 @@ function M.results(spec, result, tree) local message = string.match(line.Output, test_filename .. ":%d+: (.*)") if line_number ~= nil and message ~= nil then - table.insert(internal_results[neotest_node_id].errors, { + table.insert(d[pos_id].errors, { line = line_number - 1, -- neovim lines are 0-indexed message = message, }) @@ -149,14 +189,19 @@ function M.results(spec, result, tree) end end end + return d +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 neotest_node_id in pairs(internal_results) do - if internal_results[neotest_node_id].go_test_data.name == "" then + for pos_id in pairs(d) do + if d[pos_id].go_test_data.name == "" then vim.notify( - "Unable to associate go package/test with neotest tree node: " - .. neotest_node_id, + "Unable to associate go package/test with neotest tree node: " .. pos_id, vim.log.levels.WARN ) end @@ -166,30 +211,39 @@ function M.results(spec, result, tree) -- warn about duplicate tests -- TODO: make debug level configurable - for neotest_node_id in pairs(internal_results) do - local test_properties = internal_results[neotest_node_id] - if test_properties.duplicate_test_detected == true then + for pos_id in pairs(d) do + local test_data = d[pos_id] + if test_data.duplicate_test_detected == true then vim.notify( "Duplicate test name detected: " - .. test_properties.go_test_data.package + .. test_data.go_test_data.package .. "/" - .. test_properties.go_test_data.name, + .. test_data.go_test_data.name, vim.log.levels.WARN ) end end +end - -- convert internal results to neotest results +--- Convert internal results to Neotest results. +--- @param spec neotest.RunSpec +--- @param result neotest.StrategyResult +--- @param d table +--- @param gotest_output table +--- @return table +function M.to_neotest_results(spec, result, d, gotest_output) --- Neotest results. --- @type table local neotest_results = {} - for neotest_node_id in pairs(internal_results) do - local test_properties = internal_results[neotest_node_id] + + -- populate all test results onto the Neotest format. + for pos_id in pairs(d) do + local test_data = d[pos_id] local test_output_path = vim.fs.normalize(async.fn.tempname()) - async.fn.writefile(test_properties.output, test_output_path) - neotest_results[neotest_node_id] = { - status = test_properties.status, - errors = test_properties.errors, + async.fn.writefile(test_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 @@ -203,33 +257,23 @@ function M.results(spec, result, tree) test_command_status = "failed" end - -- write full test command output --- Full 'go test' output (parsed from JSON). --- @type table - local full_test_output = {} - local parsed_output_path = vim.fs.normalize(async.fn.tempname()) - for _, line in ipairs(jsonlines) do - -- vim.notify(vim.inspect(line)) + 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_test_output, line.Output) + table.insert(full_output, line.Output) end end - async.fn.writefile(full_test_output, parsed_output_path) + 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 = parsed_output_path, + output = test_command_output_path, } - -- FIXME: once output is parsed, 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 - vim.fn.writefile({ "" }, result.output) - - -- DEBUG: enable the following to see the collected data - -- vim.notify(vim.inspect(internal_results), vim.log.levels.DEBUG) - return neotest_results end diff --git a/lua/neotest-golang/runspec_dir.lua b/lua/neotest-golang/runspec_dir.lua index 4d82db17..40d06b4f 100644 --- a/lua/neotest-golang/runspec_dir.lua +++ b/lua/neotest-golang/runspec_dir.lua @@ -3,17 +3,19 @@ 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) - -- 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. local go_mod_filepath = M.find_file_upwards("go.mod", pos.path) if go_mod_filepath == nil then vim.notify( - "The selected folder is not a Go project, attempting different strategy.", + "The selected folder cannot be correlated to a Go project. " + .. "Will now attempt to run tests with a different strategy.", vim.log.levels.WARN ) return nil -- Deletgates away from the dir strategy @@ -29,9 +31,13 @@ function M.build(pos) 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() -- get the current working directory + local cwd = vim.fn.getcwd() local found_filepath = nil while start_path ~= cwd do local files = scan.scan_dir( diff --git a/lua/neotest-golang/utils.lua b/lua/neotest-golang/utils.lua index 3e762003..63322a72 100644 --- a/lua/neotest-golang/utils.lua +++ b/lua/neotest-golang/utils.lua @@ -7,7 +7,10 @@ function M.table_is_empty(t) return next(t) == nil end --- Find the common path of two folderpaths. +--- 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, "/")