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. The 'go list' command is an important key to couple
neotest positions with actual test data from 'go test' output.
  • Loading branch information
fredrikaverpil committed Jun 20, 2024
1 parent 2498842 commit 8acce7e
Show file tree
Hide file tree
Showing 14 changed files with 688 additions and 38 deletions.
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A Neotest adapter for running Go tests.
- Inline diagnostics.
- Works great with
[andythigpen/nvim-coverage](https://github.com/andythigpen/nvim-coverage) for
displaying coverage in the sign column (per-test basis).
displaying coverage in the sign column (per-Go package, or per-test basis).
- Monorepo support (detect, run and debug tests in sub-projects).
- Supports table tests (relies on treesitter AST detection).
- Supports nested test functions.
Expand All @@ -30,7 +30,9 @@ My next focus areas:
- [ ] Documentation around expanding new syntax support for table tests via AST
parsing.
- [ ] Add debug logging, set up bug report form.
- [ ] Investigate ways to speed up test execution when running dir/file.
- [ ] Investigate ways to speed up test execution when executing tests in...
- [x] dir
- [ ] file

## 🏓 Background

Expand Down Expand Up @@ -93,11 +95,12 @@ return {

## ⚙️ Configuration

| Argument | Default value | Description |
| ---------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `go_test_args` | `{ "-v", "-race", "-count=1", "-timeout=60s" }` | Arguments to pass into `go test`. |
| `dap_go_enabled` | `false` | Leverage [leoluz/nvim-dap-go](https://github.com/leoluz/nvim-dap-go) for debugging tests. |
| `dap_go_opts` | `{}` | Options to pass into `require("dap-go").setup()`. |
| Argument | Default value | Description |
| ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `go_test_args` | `{ "-v", "-race", "-count=1", "-timeout=60s" }` | Arguments to pass into `go test`. |
| `dap_go_enabled` | `false` | Leverage [leoluz/nvim-dap-go](https://github.com/leoluz/nvim-dap-go) for debugging tests. |
| `dap_go_opts` | `{}` | Options to pass into `require("dap-go").setup()`. |
| `warn_test_name_dupes` | `true` | Warn about duplicate test names within the same Go package. |

### Example configuration: custom `go test` arguments

Expand Down
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
58 changes: 41 additions & 17 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 @@ -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.
Expand All @@ -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
)
Expand All @@ -118,16 +137,21 @@ 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
--- @param result neotest.StrategyResult
--- @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.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)
Expand All @@ -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
Expand Down
33 changes: 30 additions & 3 deletions lua/neotest-golang/json.lua
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
local M = {}

--- Process JSON and return objects of interest.
--- Process output from 'go test -json' and return an iterable table.
--- @param raw_output table
--- @return table
function M.process_json(raw_output)
function M.process_gotest_output(raw_output)
local jsonlines = {}
for _, line in ipairs(raw_output) do
if string.match(line, "^%s*{") then -- must start with the `{` character
local status, json_data = pcall(vim.fn.json_decode, line)
if status then
table.insert(jsonlines, json_data)
else
-- NOTE: this is often hit because of "Vim:E474: Unidentified byte: ..."
-- NOTE: this can be hit because of "Vim:E474: Unidentified byte: ..."
vim.notify("Failed to decode JSON line: " .. line, vim.log.levels.WARN)
end
else
Expand All @@ -21,4 +21,31 @@ function M.process_json(raw_output)
return jsonlines
end

--- Process output from 'go list -json' an iterable lua table.
--- @param raw_output string
--- @return table
function M.process_golist_output(raw_output)
-- Split the input into separate JSON objects
local json_objects = {}
local current_object = ""
for line in raw_output:gmatch("[^\r\n]+") do
if line:match("^%s*{") and current_object ~= "" then
table.insert(json_objects, current_object)
current_object = ""
end
current_object = current_object .. line
end
table.insert(json_objects, current_object)

-- Parse each JSON object
local objects = {}
for _, json_object in ipairs(json_objects) do
local obj = vim.fn.json_decode(json_object)
table.insert(objects, obj)
end

-- Return the table of objects
return objects
end

return M
2 changes: 2 additions & 0 deletions lua/neotest-golang/options.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function Opts:new(opts)
}
self.dap_go_enabled = opts.dap_go_enabled or false
self.dap_go_opts = opts.dap_go_opts or {}
self.warn_test_name_dupes = opts.warn_test_name_dupes or true
end

--- A convenience function to get the current options.
Expand All @@ -23,6 +24,7 @@ function Opts:get()
go_test_args = self.go_test_args,
dap_go_enabled = self.dap_go_enabled,
dap_go_opts = self.dap_go_opts,
warn_test_name_dupes = self.warn_test_name_dupes,
}
end

Expand Down
Loading

0 comments on commit 8acce7e

Please sign in to comment.