Skip to content

Commit 1c6840f

Browse files
feat: Add PytrizeFixtureUsages command to find fixture usages in quickfix
New command places all usages of the fixture under the cursor into Neovim's quickfix list (parameters, body references, and usefixtures strings). Fixture definitions are excluded from results. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3968cde commit 1c6840f

4 files changed

Lines changed: 448 additions & 0 deletions

File tree

lua/pytrize/api.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,10 @@ M.rename_fixture = function()
5959
rename.rename_fixture()
6060
end
6161

62+
M.fixture_usages = function()
63+
local usages = require('pytrize.usages')
64+
65+
usages.show_usages()
66+
end
67+
6268
return M

lua/pytrize/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ local function setup_commands()
88
vim.cmd('command PytrizeJump lua require("pytrize.api").jump()')
99
vim.cmd('command PytrizeJumpFixture lua require("pytrize.api").jump_fixture()')
1010
vim.cmd('command PytrizeRenameFixture lua require("pytrize.api").rename_fixture()')
11+
vim.cmd('command PytrizeFixtureUsages lua require("pytrize.api").fixture_usages()')
1112
end
1213

1314
M.setup = function(opts)

lua/pytrize/usages.lua

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
local M = {}
2+
3+
local ts = vim.treesitter
4+
local warn = require('pytrize.warn').warn
5+
local paths = require('pytrize.paths')
6+
local ts_utils = require('pytrize.ts')
7+
8+
local function find_python_files(root_dir, name)
9+
return vim.fn.systemlist(string.format(
10+
'grep -rl --include="*.py" "%s" "%s"',
11+
vim.fn.escape(name, '"\\'),
12+
root_dir
13+
))
14+
end
15+
16+
local function get_param_name_node(param_node)
17+
local t = param_node:type()
18+
if t == 'identifier' then
19+
return param_node
20+
elseif t == 'default_parameter' or t == 'typed_default_parameter' then
21+
return param_node:field('name')[1]
22+
elseif t == 'typed_parameter' then
23+
local first = param_node:named_child(0)
24+
if first and first:type() == 'identifier' then
25+
return first
26+
end
27+
end
28+
return nil
29+
end
30+
31+
local function find_body_references(body_node, fixture_name, bufnr)
32+
local positions = {}
33+
34+
local function walk_body(node, shadowed)
35+
if shadowed then return end
36+
37+
local node_type = node:type()
38+
39+
if node_type == 'function_definition' then
40+
local params_node = node:field('parameters')[1]
41+
if params_node then
42+
local re_declares = false
43+
for child in params_node:iter_children() do
44+
local name_node = get_param_name_node(child)
45+
if name_node and ts.get_node_text(name_node, bufnr) == fixture_name then
46+
re_declares = true
47+
break
48+
end
49+
end
50+
for child in node:iter_children() do
51+
walk_body(child, re_declares)
52+
end
53+
return
54+
end
55+
end
56+
57+
if node_type == 'identifier' then
58+
if ts.get_node_text(node, bufnr) == fixture_name then
59+
local parent = node:parent()
60+
if parent then
61+
local parent_type = parent:type()
62+
if parent_type == 'attribute' then
63+
local attr_field = parent:field('attribute')[1]
64+
if attr_field and attr_field:id() == node:id() then
65+
goto continue
66+
end
67+
end
68+
if parent_type == 'keyword_argument' then
69+
local name_field = parent:field('name')[1]
70+
if name_field and name_field:id() == node:id() then
71+
goto continue
72+
end
73+
end
74+
end
75+
local row, col_start, _, col_end = node:range()
76+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
77+
end
78+
::continue::
79+
end
80+
81+
for child in node:iter_children() do
82+
walk_body(child, false)
83+
end
84+
end
85+
86+
walk_body(body_node, false)
87+
return positions
88+
end
89+
90+
-- Find usage positions (parameters, body references, usefixtures strings)
91+
-- Does NOT include fixture definitions.
92+
local find_usage_positions = function(bufnr, fixture_name)
93+
local ok = pcall(function() vim.treesitter.language.inspect('python') end)
94+
if not ok then return {} end
95+
96+
local parser = ts.get_parser(bufnr, 'python')
97+
local tree = parser:parse()[1]
98+
local root = tree:root()
99+
100+
local positions = {}
101+
102+
ts_utils.walk(root, function(node)
103+
local node_type = node:type()
104+
105+
-- Case A: fixture consumers (parameter + body refs)
106+
if node_type == 'function_definition' then
107+
local params_node = node:field('parameters')[1]
108+
if params_node then
109+
local found_param_node = nil
110+
for child in params_node:iter_children() do
111+
local name_node = get_param_name_node(child)
112+
if name_node and ts.get_node_text(name_node, bufnr) == fixture_name then
113+
found_param_node = name_node
114+
break
115+
end
116+
end
117+
118+
if found_param_node then
119+
local row, col_start, _, col_end = found_param_node:range()
120+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
121+
122+
local body_node = node:field('body')[1]
123+
if body_node then
124+
for _, pos in ipairs(find_body_references(body_node, fixture_name, bufnr)) do
125+
table.insert(positions, pos)
126+
end
127+
end
128+
end
129+
end
130+
end
131+
132+
-- Case B: @pytest.mark.usefixtures("fixture_name") strings
133+
if node_type == 'call' then
134+
local func = node:field('function')[1]
135+
if func and func:type() == 'attribute' then
136+
if ts.get_node_text(func, bufnr) == 'pytest.mark.usefixtures' then
137+
local args = node:field('arguments')[1]
138+
if args then
139+
for child in args:iter_children() do
140+
if child:type() == 'string' then
141+
for schild in child:iter_children() do
142+
if schild:type() == 'string_content' then
143+
if ts.get_node_text(schild, bufnr) == fixture_name then
144+
local row, col_start, _, col_end = schild:range()
145+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
146+
end
147+
end
148+
end
149+
end
150+
end
151+
end
152+
end
153+
end
154+
end
155+
end)
156+
157+
return positions
158+
end
159+
160+
local find_all_usages = function(fixture_name, root_dir)
161+
local py_files = find_python_files(root_dir, fixture_name)
162+
local items = {}
163+
164+
for _, filepath in ipairs(py_files) do
165+
local existing_bufnr = vim.fn.bufnr(filepath)
166+
local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1
167+
168+
local bufnr = vim.fn.bufadd(filepath)
169+
if not was_loaded then
170+
vim.fn.bufload(bufnr)
171+
end
172+
173+
vim.api.nvim_set_option_value('filetype', 'python', { buf = bufnr })
174+
175+
local positions = find_usage_positions(bufnr, fixture_name)
176+
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
177+
178+
for _, pos in ipairs(positions) do
179+
table.insert(items, {
180+
filename = filepath,
181+
lnum = pos.row + 1,
182+
col = pos.col_start + 1,
183+
text = lines[pos.row + 1] or '',
184+
})
185+
end
186+
187+
if not was_loaded then
188+
vim.api.nvim_buf_delete(bufnr, { force = false })
189+
end
190+
end
191+
192+
return items
193+
end
194+
195+
M.show_usages = function()
196+
local fixture_name = vim.fn.expand('<cword>')
197+
if fixture_name == '' then
198+
warn('no word under cursor')
199+
return
200+
end
201+
202+
local filepath = vim.api.nvim_buf_get_name(0)
203+
local root_dir = paths.split_at_root(filepath)
204+
if root_dir == nil then return end
205+
206+
local items = find_all_usages(fixture_name, root_dir)
207+
208+
if #items == 0 then
209+
warn(string.format('no usages found for fixture "%s"', fixture_name))
210+
return
211+
end
212+
213+
vim.fn.setqflist(items, 'r')
214+
vim.cmd('copen')
215+
end
216+
217+
-- Internal exports for testing
218+
M._find_usage_positions = find_usage_positions
219+
M._find_all_usages = find_all_usages
220+
221+
return M

0 commit comments

Comments
 (0)