Skip to content

Commit 4c3a9da

Browse files
PYT-CHARLIE: Rely only on treesitter (#3)
* feat: Rely on treesitter only * feat: Scope fixture rename to avoid shadowed definitions Skip renaming in files where the fixture is shadowed by a closer definition. Uses build_fixture_index to resolve which file each consumer's fixture comes from before applying renames. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9263824 commit 4c3a9da

7 files changed

Lines changed: 664 additions & 109 deletions

File tree

lua/pytrize/jump/fixture.lua

Lines changed: 20 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,32 @@
11
local M = {}
22

3-
local Job = require('plenary.job')
4-
local Path = require('plenary.path')
5-
63
local warn = require('pytrize.warn').warn
74
local open_file = require('pytrize.jump.util').open_file
5+
local paths = require('pytrize.paths')
6+
local ts_utils = require('pytrize.ts')
87

9-
local function normal(cmd)
10-
vim.cmd(string.format('normal! %s', cmd))
11-
end
12-
13-
local function get_word_under_cursor()
14-
local savereg = vim.fn.getreginfo('"')
15-
normal('yiw')
16-
local word = vim.fn.getreg('"')
17-
vim.fn.setreg('"', savereg)
18-
return word
19-
end
20-
21-
local function parse_raw_fixture_output(cwd, lines)
22-
local fixtures = {}
23-
local pattern = '^([%w_]*) .*%-%- (%S*):(%d*)$'
24-
for _, line in ipairs(lines) do
25-
local i, _, fixture, file, linenr = string.find(line, pattern)
26-
if i ~= nil then
27-
fixtures[fixture] = {
28-
file = cwd / file,
29-
linenr = tonumber(linenr),
30-
}
31-
end
8+
M.to_declaration = function()
9+
local fixture = vim.fn.expand('<cword>')
10+
if fixture == '' then
11+
warn('no word under cursor')
12+
return
3213
end
33-
return fixtures
34-
end
3514

36-
local function get_cwd()
37-
return Path:new(vim.api.nvim_buf_get_name(0)):parent()
38-
end
15+
local filepath = vim.api.nvim_buf_get_name(0)
16+
local root_dir = paths.split_at_root(filepath)
17+
if root_dir == nil then
18+
return
19+
end
3920

40-
local function lookup_fixtures(callback)
41-
local cwd = get_cwd()
42-
Job:new({
43-
command = 'pytest',
44-
args = {'--fixtures', '-v'},
45-
cwd = tostring(cwd),
46-
on_exit = vim.schedule_wrap(function(j, return_val)
47-
if return_val == 0 then
48-
local fixtures = parse_raw_fixture_output(cwd, j:result())
49-
callback(fixtures)
50-
else
51-
warn(string.format('failed to query fixtures: %s', table.concat(j:result(), '\n')))
52-
end
53-
end),
54-
}):sync()
55-
end
21+
local fixtures = ts_utils.build_fixture_index(filepath, root_dir)
22+
local location = fixtures[fixture]
23+
if location == nil then
24+
warn(string.format('fixture "%s" not found', fixture))
25+
return
26+
end
5627

57-
M.to_declaration = function()
58-
local fixture = get_word_under_cursor()
59-
lookup_fixtures(function(fixtures)
60-
local fixture_location = fixtures[fixture]
61-
if fixture_location == nil then
62-
warn(string.format('fixture "%s" not found', fixture))
63-
else
64-
local file = fixture_location.file
65-
local linenr = fixture_location.linenr
66-
open_file(tostring(file))
67-
vim.api.nvim_win_set_cursor(0, {linenr, 0})
68-
vim.fn.search(fixture)
69-
end
70-
end)
28+
open_file(location.file)
29+
vim.api.nvim_win_set_cursor(0, {location.linenr, 0})
7130
end
7231

7332
return M

lua/pytrize/paths.lua

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,33 @@ M.split_at_root = function(file)
2828
warn("couldn't find the pytest root dir")
2929
end
3030

31+
M.get_conftest_chain = function(filepath, root_dir)
32+
local dir = vim.fn.fnamemodify(filepath, ':h')
33+
local chain = {}
34+
35+
-- Walk from root_dir down to the file's directory.
36+
-- Build the list of directories from root to file dir, then check each for conftest.py.
37+
local dirs = {}
38+
local current = dir
39+
while #current >= #root_dir do
40+
table.insert(dirs, 1, current)
41+
local parent = vim.fn.fnamemodify(current, ':h')
42+
if parent == current then
43+
break
44+
end
45+
current = parent
46+
end
47+
48+
for _, d in ipairs(dirs) do
49+
local conftest = d .. '/conftest.py'
50+
if vim.fn.filereadable(conftest) == 1 then
51+
table.insert(chain, conftest)
52+
end
53+
end
54+
55+
return chain
56+
end
57+
3158
M.get_nodeids_path = function(rootdir)
3259
return join_path{rootdir, '.pytest_cache', 'v', 'cache', 'nodeids'}
3360
end

lua/pytrize/rename.lua

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ local M = {}
33
local ts = vim.treesitter
44
local warn = require('pytrize.warn').warn
55
local paths = require('pytrize.paths')
6+
local ts_utils = require('pytrize.ts')
67

78
local function get_fixture_name()
89
return vim.fn.expand('<cword>')
@@ -17,26 +18,6 @@ local function find_python_files(root_dir, name)
1718
return result
1819
end
1920

20-
local function walk(node, callback)
21-
callback(node)
22-
for child in node:iter_children() do
23-
walk(child, callback)
24-
end
25-
end
26-
27-
local function is_fixture_decorator(node, bufnr)
28-
local node_type = node:type()
29-
if node_type == 'attribute' then
30-
return ts.get_node_text(node, bufnr) == 'pytest.fixture'
31-
elseif node_type == 'call' then
32-
local func = node:field('function')[1]
33-
if func and func:type() == 'attribute' then
34-
return ts.get_node_text(func, bufnr) == 'pytest.fixture'
35-
end
36-
end
37-
return false
38-
end
39-
4021
local function get_param_name_node(param_node)
4122
local t = param_node:type()
4223
if t == 'identifier' then
@@ -140,34 +121,16 @@ local find_rename_positions = function(bufnr, old_name)
140121

141122
local positions = {}
142123

143-
walk(root, function(node)
144-
local node_type = node:type()
145-
146-
-- Case A: Fixture definition
147-
if node_type == 'decorated_definition' then
148-
local has_fixture_decorator = false
149-
for child in node:iter_children() do
150-
if child:type() == 'decorator' then
151-
for dchild in child:iter_children() do
152-
if is_fixture_decorator(dchild, bufnr) then
153-
has_fixture_decorator = true
154-
break
155-
end
156-
end
157-
end
158-
end
159-
160-
if has_fixture_decorator then
161-
local func = node:field('definition')[1]
162-
if func and func:type() == 'function_definition' then
163-
local name_node = func:field('name')[1]
164-
if name_node and ts.get_node_text(name_node, bufnr) == old_name then
165-
local row, col_start, _, col_end = name_node:range()
166-
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
167-
end
168-
end
169-
end
124+
-- Case A: Fixture definitions
125+
for _, def in ipairs(ts_utils.get_fixture_defs(bufnr)) do
126+
if def.name == old_name then
127+
local row, col_start, _, col_end = def.name_node:range()
128+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
170129
end
130+
end
131+
132+
ts_utils.walk(root, function(node)
133+
local node_type = node:type()
171134

172135
-- Case B: Fixture consumer
173136
if node_type == 'function_definition' then
@@ -265,10 +228,25 @@ local function rename(old_name, new_name)
265228
return
266229
end
267230

231+
-- First pass: determine which files to process. Only rename in files where
232+
-- the fixture resolves to the current file (not shadowed by a closer definition).
233+
local files_to_process = {}
234+
for _, filepath in ipairs(py_files) do
235+
if filepath == current_file then
236+
table.insert(files_to_process, filepath)
237+
else
238+
local index = ts_utils.build_fixture_index(filepath, root_dir)
239+
local resolved = index[old_name]
240+
if resolved and resolved.file == current_file then
241+
table.insert(files_to_process, filepath)
242+
end
243+
end
244+
end
245+
268246
local total_replacements = 0
269247
local files_changed = 0
270248

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

@@ -330,5 +308,6 @@ end
330308
-- Internal exports for testing
331309
M._find_rename_positions = find_rename_positions
332310
M._apply_renames = apply_renames
311+
M._rename = rename
333312

334313
return M

lua/pytrize/ts.lua

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
local M = {}
2+
3+
local ts = vim.treesitter
4+
5+
M.walk = function(node, callback)
6+
callback(node)
7+
for child in node:iter_children() do
8+
M.walk(child, callback)
9+
end
10+
end
11+
12+
M.is_fixture_decorator = function(node, bufnr)
13+
local node_type = node:type()
14+
if node_type == 'attribute' then
15+
return ts.get_node_text(node, bufnr) == 'pytest.fixture'
16+
elseif node_type == 'call' then
17+
local func = node:field('function')[1]
18+
if func and func:type() == 'attribute' then
19+
return ts.get_node_text(func, bufnr) == 'pytest.fixture'
20+
end
21+
end
22+
return false
23+
end
24+
25+
M.get_fixture_defs = function(bufnr)
26+
local parser = ts.get_parser(bufnr, 'python')
27+
local tree = parser:parse()[1]
28+
local root = tree:root()
29+
30+
local defs = {}
31+
32+
M.walk(root, function(node)
33+
if node:type() ~= 'decorated_definition' then
34+
return
35+
end
36+
37+
local has_fixture_decorator = false
38+
for child in node:iter_children() do
39+
if child:type() == 'decorator' then
40+
for dchild in child:iter_children() do
41+
if M.is_fixture_decorator(dchild, bufnr) then
42+
has_fixture_decorator = true
43+
break
44+
end
45+
end
46+
end
47+
end
48+
49+
if has_fixture_decorator then
50+
local func = node:field('definition')[1]
51+
if func and func:type() == 'function_definition' then
52+
local name_node = func:field('name')[1]
53+
if name_node then
54+
table.insert(defs, {
55+
name = ts.get_node_text(name_node, bufnr),
56+
name_node = name_node,
57+
func_node = func,
58+
})
59+
end
60+
end
61+
end
62+
end)
63+
64+
return defs
65+
end
66+
67+
M.scan_fixtures = function(filepath)
68+
local existing_bufnr = vim.fn.bufnr(filepath)
69+
local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1
70+
71+
local bufnr = vim.fn.bufadd(filepath)
72+
if not was_loaded then
73+
vim.fn.bufload(bufnr)
74+
end
75+
76+
vim.api.nvim_set_option_value('filetype', 'python', { buf = bufnr })
77+
78+
local ok, defs = pcall(M.get_fixture_defs, bufnr)
79+
80+
if not was_loaded then
81+
vim.api.nvim_buf_delete(bufnr, { force = true })
82+
end
83+
84+
if not ok then
85+
return {}
86+
end
87+
88+
local fixtures = {}
89+
for _, def in ipairs(defs) do
90+
local row = def.name_node:start()
91+
fixtures[def.name] = {
92+
file = filepath,
93+
linenr = row + 1,
94+
}
95+
end
96+
return fixtures
97+
end
98+
99+
M.build_fixture_index = function(filepath, root_dir)
100+
local paths = require('pytrize.paths')
101+
local fixtures = {}
102+
103+
-- Scan conftest.py chain (root to leaf); later entries override earlier ones
104+
local chain = paths.get_conftest_chain(filepath, root_dir)
105+
for _, conftest in ipairs(chain) do
106+
local cf = M.scan_fixtures(conftest)
107+
for name, loc in pairs(cf) do
108+
fixtures[name] = loc
109+
end
110+
end
111+
112+
-- Scan the test file itself (fixtures defined here take priority)
113+
if vim.fn.filereadable(filepath) == 1 then
114+
local ff = M.scan_fixtures(filepath)
115+
for name, loc in pairs(ff) do
116+
fixtures[name] = loc
117+
end
118+
end
119+
120+
return fixtures
121+
end
122+
123+
return M

0 commit comments

Comments
 (0)