Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 20 additions & 61 deletions lua/pytrize/jump/fixture.lua
Original file line number Diff line number Diff line change
@@ -1,73 +1,32 @@
local M = {}

local Job = require('plenary.job')
local Path = require('plenary.path')

local warn = require('pytrize.warn').warn
local open_file = require('pytrize.jump.util').open_file
local paths = require('pytrize.paths')
local ts_utils = require('pytrize.ts')

local function normal(cmd)
vim.cmd(string.format('normal! %s', cmd))
end

local function get_word_under_cursor()
local savereg = vim.fn.getreginfo('"')
normal('yiw')
local word = vim.fn.getreg('"')
vim.fn.setreg('"', savereg)
return word
end

local function parse_raw_fixture_output(cwd, lines)
local fixtures = {}
local pattern = '^([%w_]*) .*%-%- (%S*):(%d*)$'
for _, line in ipairs(lines) do
local i, _, fixture, file, linenr = string.find(line, pattern)
if i ~= nil then
fixtures[fixture] = {
file = cwd / file,
linenr = tonumber(linenr),
}
end
M.to_declaration = function()
local fixture = vim.fn.expand('<cword>')
if fixture == '' then
warn('no word under cursor')
return
end
return fixtures
end

local function get_cwd()
return Path:new(vim.api.nvim_buf_get_name(0)):parent()
end
local filepath = vim.api.nvim_buf_get_name(0)
local root_dir = paths.split_at_root(filepath)
if root_dir == nil then
return
end

local function lookup_fixtures(callback)
local cwd = get_cwd()
Job:new({
command = 'pytest',
args = {'--fixtures', '-v'},
cwd = tostring(cwd),
on_exit = vim.schedule_wrap(function(j, return_val)
if return_val == 0 then
local fixtures = parse_raw_fixture_output(cwd, j:result())
callback(fixtures)
else
warn(string.format('failed to query fixtures: %s', table.concat(j:result(), '\n')))
end
end),
}):sync()
end
local fixtures = ts_utils.build_fixture_index(filepath, root_dir)
local location = fixtures[fixture]
if location == nil then
warn(string.format('fixture "%s" not found', fixture))
return
end

M.to_declaration = function()
local fixture = get_word_under_cursor()
lookup_fixtures(function(fixtures)
local fixture_location = fixtures[fixture]
if fixture_location == nil then
warn(string.format('fixture "%s" not found', fixture))
else
local file = fixture_location.file
local linenr = fixture_location.linenr
open_file(tostring(file))
vim.api.nvim_win_set_cursor(0, {linenr, 0})
vim.fn.search(fixture)
end
end)
open_file(location.file)
vim.api.nvim_win_set_cursor(0, {location.linenr, 0})
end

return M
27 changes: 27 additions & 0 deletions lua/pytrize/paths.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ M.split_at_root = function(file)
warn("couldn't find the pytest root dir")
end

M.get_conftest_chain = function(filepath, root_dir)
local dir = vim.fn.fnamemodify(filepath, ':h')
local chain = {}

-- Walk from root_dir down to the file's directory.
-- Build the list of directories from root to file dir, then check each for conftest.py.
local dirs = {}
local current = dir
while #current >= #root_dir do
table.insert(dirs, 1, current)
local parent = vim.fn.fnamemodify(current, ':h')
if parent == current then
break
end
current = parent
end

for _, d in ipairs(dirs) do
local conftest = d .. '/conftest.py'
if vim.fn.filereadable(conftest) == 1 then
table.insert(chain, conftest)
end
end

return chain
end

M.get_nodeids_path = function(rootdir)
return join_path{rootdir, '.pytest_cache', 'v', 'cache', 'nodeids'}
end
Expand Down
75 changes: 27 additions & 48 deletions lua/pytrize/rename.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ local M = {}
local ts = vim.treesitter
local warn = require('pytrize.warn').warn
local paths = require('pytrize.paths')
local ts_utils = require('pytrize.ts')

local function get_fixture_name()
return vim.fn.expand('<cword>')
Expand All @@ -17,26 +18,6 @@ local function find_python_files(root_dir, name)
return result
end

local function walk(node, callback)
callback(node)
for child in node:iter_children() do
walk(child, callback)
end
end

local function is_fixture_decorator(node, bufnr)
local node_type = node:type()
if node_type == 'attribute' then
return ts.get_node_text(node, bufnr) == 'pytest.fixture'
elseif node_type == 'call' then
local func = node:field('function')[1]
if func and func:type() == 'attribute' then
return ts.get_node_text(func, bufnr) == 'pytest.fixture'
end
end
return false
end

local function get_param_name_node(param_node)
local t = param_node:type()
if t == 'identifier' then
Expand Down Expand Up @@ -140,34 +121,16 @@ local find_rename_positions = function(bufnr, old_name)

local positions = {}

walk(root, function(node)
local node_type = node:type()

-- Case A: Fixture definition
if node_type == 'decorated_definition' then
local has_fixture_decorator = false
for child in node:iter_children() do
if child:type() == 'decorator' then
for dchild in child:iter_children() do
if is_fixture_decorator(dchild, bufnr) then
has_fixture_decorator = true
break
end
end
end
end

if has_fixture_decorator then
local func = node:field('definition')[1]
if func and func:type() == 'function_definition' then
local name_node = func:field('name')[1]
if name_node and ts.get_node_text(name_node, bufnr) == old_name then
local row, col_start, _, col_end = name_node:range()
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
end
end
end
-- Case A: Fixture definitions
for _, def in ipairs(ts_utils.get_fixture_defs(bufnr)) do
if def.name == old_name then
local row, col_start, _, col_end = def.name_node:range()
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
end
end

ts_utils.walk(root, function(node)
local node_type = node:type()

-- Case B: Fixture consumer
if node_type == 'function_definition' then
Expand Down Expand Up @@ -265,10 +228,25 @@ local function rename(old_name, new_name)
return
end

-- First pass: determine which files to process. Only rename in files where
-- the fixture resolves to the current file (not shadowed by a closer definition).
local files_to_process = {}
for _, filepath in ipairs(py_files) do
if filepath == current_file then
table.insert(files_to_process, filepath)
else
local index = ts_utils.build_fixture_index(filepath, root_dir)
local resolved = index[old_name]
if resolved and resolved.file == current_file then
table.insert(files_to_process, filepath)
end
end
end

local total_replacements = 0
local files_changed = 0

for _, filepath in ipairs(py_files) do
for _, filepath in ipairs(files_to_process) do
local existing_bufnr = vim.fn.bufnr(filepath)
local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1

Expand Down Expand Up @@ -330,5 +308,6 @@ end
-- Internal exports for testing
M._find_rename_positions = find_rename_positions
M._apply_renames = apply_renames
M._rename = rename

return M
123 changes: 123 additions & 0 deletions lua/pytrize/ts.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
local M = {}

local ts = vim.treesitter

M.walk = function(node, callback)
callback(node)
for child in node:iter_children() do
M.walk(child, callback)
end
end

M.is_fixture_decorator = function(node, bufnr)
local node_type = node:type()
if node_type == 'attribute' then
return ts.get_node_text(node, bufnr) == 'pytest.fixture'
elseif node_type == 'call' then
local func = node:field('function')[1]
if func and func:type() == 'attribute' then
return ts.get_node_text(func, bufnr) == 'pytest.fixture'
end
end
return false
end

M.get_fixture_defs = function(bufnr)
local parser = ts.get_parser(bufnr, 'python')
local tree = parser:parse()[1]
local root = tree:root()

local defs = {}

M.walk(root, function(node)
if node:type() ~= 'decorated_definition' then
return
end

local has_fixture_decorator = false
for child in node:iter_children() do
if child:type() == 'decorator' then
for dchild in child:iter_children() do
if M.is_fixture_decorator(dchild, bufnr) then
has_fixture_decorator = true
break
end
end
end
end

if has_fixture_decorator then
local func = node:field('definition')[1]
if func and func:type() == 'function_definition' then
local name_node = func:field('name')[1]
if name_node then
table.insert(defs, {
name = ts.get_node_text(name_node, bufnr),
name_node = name_node,
func_node = func,
})
end
end
end
end)

return defs
end

M.scan_fixtures = function(filepath)
local existing_bufnr = vim.fn.bufnr(filepath)
local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1

local bufnr = vim.fn.bufadd(filepath)
if not was_loaded then
vim.fn.bufload(bufnr)
end

vim.api.nvim_set_option_value('filetype', 'python', { buf = bufnr })

local ok, defs = pcall(M.get_fixture_defs, bufnr)

if not was_loaded then
vim.api.nvim_buf_delete(bufnr, { force = true })
end

if not ok then
return {}
end

local fixtures = {}
for _, def in ipairs(defs) do
local row = def.name_node:start()
fixtures[def.name] = {
file = filepath,
linenr = row + 1,
}
end
return fixtures
end

M.build_fixture_index = function(filepath, root_dir)
local paths = require('pytrize.paths')
local fixtures = {}

-- Scan conftest.py chain (root to leaf); later entries override earlier ones
local chain = paths.get_conftest_chain(filepath, root_dir)
for _, conftest in ipairs(chain) do
local cf = M.scan_fixtures(conftest)
for name, loc in pairs(cf) do
fixtures[name] = loc
end
end

-- Scan the test file itself (fixtures defined here take priority)
if vim.fn.filereadable(filepath) == 1 then
local ff = M.scan_fixtures(filepath)
for name, loc in pairs(ff) do
fixtures[name] = loc
end
end

return fixtures
end

return M
Loading