Skip to content

Commit

Permalink
feat: support range-less "child" tests (#172)
Browse files Browse the repository at this point in the history
Not all tests have a unique range which applies to them.  For example,
in pytest, a test function can be parametrized to produce multiple
different test instances for the same range.  neotest currently assumes
that any cursor position in a file maps to a single test, and selects
the deepest-nested such test: this results in the last test instance for
a test function being focused, instead of the whole test: the only way
to run other (or all) instances is via the summary view.

This commit changes neotest's behaviour, by introducing support for
"child" test positions: these are simply test positions with `range` set
to `nil`.  This commit updates all[0] direct accesses of `range` on
positions with calls to `Tree:closest_value_for("range")`: this will
traverse up the parents of a node, returning the first non-`nil` `range`
value.  Child tests can be run via the summary view (directly, or via
marks), but any operations based on cursor position in a buffer will
operate on the parent test.

Fixes: #147

[0] With the exception of `positions.contains()`, which doesn't handle
    _test_ positions.
  • Loading branch information
OddBloke authored Jan 18, 2023
1 parent 3b41a1a commit 6676edc
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 17 deletions.
20 changes: 20 additions & 0 deletions doc/neotest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,26 @@ Return~
Return~
`(fun(): neotest.Tree)`

*neotest.Tree:closest_node_with()*
`Tree:closest_node_with`({data_attr})

Fetch the first node ascending the tree (including the current one) with the
given data attribute e.g. `range`
Parameters~
{data_attr} `(string)`
Return~
`(neotest.Tree)` | nil

*neotest.Tree:closest_value_for()*
`Tree:closest_value_for`({data_attr})

Fetch the first non-nil value for the given data attribute ascending the
tree (including the current node) with the given data attribute.
Parameters~
{data_attr} `(string)`
Return~
`(any)` | nil

*neotest.Tree:root()*
`Tree:root`()

Expand Down
9 changes: 5 additions & 4 deletions lua/neotest/client/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,11 @@ function neotest.Client:get_nearest(file_path, row, args)
return
end
local nearest
for _, pos in positions:iter_nodes() do
local data = pos:data()
if data.range and data.range[1] <= row then
nearest = pos
for _, node in positions:iter_nodes() do
node = node:closest_node_with("range") or node
local range = node:data().range
if range and range[1] <= row then
nearest = node
else
return nearest, adapter_id
end
Expand Down
7 changes: 6 additions & 1 deletion lua/neotest/client/runner.lua
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,15 @@ function TestRunner:_missing_results(tree, results, partial)
local root = tree:data()
local missing_tests = {}

local all_position_ids = {}
for _, pos in tree:iter() do
all_position_ids[pos.id] = true
end

local function propagate_result_upwards(node)
for parent in node:iter_parents() do
local parent_pos = parent:data()
if not lib.positions.contains(root, parent_pos) then
if not all_position_ids[parent_pos.id] then
return
end

Expand Down
6 changes: 5 additions & 1 deletion lua/neotest/consumers/diagnostic.lua
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ local function init(client)
local result = results[pos_id]
if position.type == "test" and result and result.errors and #result.errors > 0 then
local placed = self.tracking_marks[pos_id]
or self:init_mark(pos_id, result.errors, positions:get_key(pos_id):data().range[1])
or self:init_mark(
pos_id,
result.errors,
positions:get_key(pos_id):closest_value_for("range")[1]
)
if placed then
for error_i, error in pairs(result.errors or {}) do
local mark = api.nvim_buf_get_extmark_by_id(
Expand Down
4 changes: 2 additions & 2 deletions lua/neotest/consumers/jump.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ local get_nearest = function()
end

local function jump_to(node)
local range = node:data().range
local range = node:closest_value_for("range")
async.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] })
end

Expand All @@ -48,7 +48,7 @@ local jump_to_prev = function(pos, predicate)
if pos:data().type == "file" then
return false
end
if async.api.nvim_win_get_cursor(0)[1] - 1 > pos:data().range[1] then
if async.api.nvim_win_get_cursor(0)[1] - 1 > pos:closest_value_for("range")[1] then
jump_to(pos)
return true
end
Expand Down
8 changes: 5 additions & 3 deletions lua/neotest/consumers/output.lua
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,15 @@ local init = function()
if not positions then
return
end
for _, pos in positions:iter() do
for _, node in positions:iter_nodes() do
local pos = node:data()
local range = node:closest_value_for("range")
if
pos.type == "test"
and results[pos.id]
and results[pos.id].status == "failed"
and pos.range[1] <= line
and pos.range[3] >= line
and range[1] <= line
and range[3] >= line
then
open_output(
results[pos.id],
Expand Down
12 changes: 7 additions & 5 deletions lua/neotest/consumers/status.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ local function init(client)

local namespace = async.api.nvim_create_namespace(sign_group)

local function place_sign(buf, pos, adapter_id, results)
local function place_sign(buf, pos, range, adapter_id, results)
local status
if results[pos.id] then
local result = results[pos.id]
Expand All @@ -28,12 +28,12 @@ local function init(client)
end
if config.status.signs then
async.fn.sign_place(0, sign_group, "neotest_" .. status, pos.path, {
lnum = pos.range[1] + 1,
lnum = range[1] + 1,
priority = 1000,
})
end
if config.status.virtual_text then
async.api.nvim_buf_set_extmark(buf, namespace, pos.range[1], 0, {
async.api.nvim_buf_set_extmark(buf, namespace, range[1], 0, {
virt_text = {
{ statuses[status].text .. " ", statuses[status].texthl },
},
Expand All @@ -51,9 +51,11 @@ local function init(client)
if not tree then
return
end
for _, pos in tree:iter() do
for _, node in tree:iter_nodes() do
local pos = node:data()
local range = node:closest_value_for("range")
if pos.type ~= "file" then
place_sign(async.fn.bufnr(file_path), pos, adapter_id, results)
place_sign(async.fn.bufnr(file_path), pos, range, adapter_id, results)
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion lua/neotest/consumers/summary/component.lua
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ function SummaryComponent:_render(canvas, tree, expanded, focused, indent)
if position.type == "file" then
lib.ui.open_buf(buf)
else
lib.ui.open_buf(buf, position.range[1], position.range[2])
local range = node:closest_value_for("range")
lib.ui.open_buf(buf, range[1], range[2])
end
end)
end
Expand Down
23 changes: 23 additions & 0 deletions lua/neotest/types/tree.lua
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ function neotest.Tree:iter_parents()
end
end

--- Fetch the first node ascending the tree (including the current one) with the
--- given data attribute e.g. `range`
---@param data_attr string
---@return neotest.Tree | nil
function neotest.Tree:closest_node_with(data_attr)
if self:data()[data_attr] ~= nil then
return self
end
for parent in self:iter_parents() do
if parent:data()[data_attr] ~= nil then
return parent
end
end
end

--- Fetch the first non-nil value for the given data attribute ascending the
--- tree (including the current node) with the given data attribute.
---@param data_attr string
---@return any | nil
function neotest.Tree:closest_value_for(data_attr)
return self:closest_node_with(data_attr):data()[data_attr]
end

---@return neotest.Tree
function neotest.Tree:root()
local node = self
Expand Down

0 comments on commit 6676edc

Please sign in to comment.