Skip to content

Commit 6b051cf

Browse files
feat: Rename fixture
1 parent 250bef1 commit 6b051cf

13 files changed

Lines changed: 945 additions & 0 deletions

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git -C /home/rsgarxia/code/github/nvim-pytrize.lua ls-tree:*)"
5+
]
6+
}
7+
}

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
PLENARY_DIR := /tmp/plenary.nvim
2+
3+
$(PLENARY_DIR):
4+
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR)
5+
6+
.PHONY: test
7+
test: $(PLENARY_DIR)
8+
nvim --headless --noplugin \
9+
-u tests/minimal_init.lua \
10+
-c "PlenaryBustedDirectory tests/pytrize/ {minimal_init = 'tests/minimal_init.lua'}"

lua/pytrize/api.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,10 @@ M.jump_fixture = function()
5353
jump.to_fixture_declaration()
5454
end
5555

56+
M.rename_fixture = function()
57+
local rename = require('pytrize.rename')
58+
59+
rename.rename_fixture()
60+
end
61+
5662
return M

lua/pytrize/init.lua

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

1213
M.setup = function(opts)

lua/pytrize/rename.lua

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
local M = {}
2+
3+
local ts = vim.treesitter
4+
local warn = require('pytrize.warn').warn
5+
local paths = require('pytrize.paths')
6+
7+
local function get_fixture_name()
8+
return vim.fn.expand('<cword>')
9+
end
10+
11+
local function find_python_files(root_dir, name)
12+
local result = vim.fn.systemlist(string.format(
13+
'grep -rl --include="*.py" "%s" "%s"',
14+
vim.fn.escape(name, '"\\'),
15+
root_dir
16+
))
17+
return result
18+
end
19+
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+
40+
local function get_param_name_node(param_node)
41+
local t = param_node:type()
42+
if t == 'identifier' then
43+
return param_node
44+
elseif t == 'default_parameter' or t == 'typed_default_parameter' then
45+
return param_node:field('name')[1]
46+
elseif t == 'typed_parameter' then
47+
-- typed_parameter has no 'name' field; the identifier is the first named child
48+
local first = param_node:named_child(0)
49+
if first and first:type() == 'identifier' then
50+
return first
51+
end
52+
end
53+
return nil
54+
end
55+
56+
local function get_param_names(parameters_node, bufnr)
57+
local names = {}
58+
for child in parameters_node:iter_children() do
59+
local name_node = get_param_name_node(child)
60+
if name_node then
61+
table.insert(names, ts.get_node_text(name_node, bufnr))
62+
end
63+
end
64+
return names
65+
end
66+
67+
local function find_body_references(body_node, old_name, bufnr)
68+
local positions = {}
69+
70+
local function walk_body(node, shadowed)
71+
if shadowed then
72+
return
73+
end
74+
75+
local node_type = node:type()
76+
77+
if node_type == 'function_definition' then
78+
local params_node = node:field('parameters')[1]
79+
if params_node then
80+
local param_names = get_param_names(params_node, bufnr)
81+
local re_declares = false
82+
for _, p in ipairs(param_names) do
83+
if p == old_name then
84+
re_declares = true
85+
break
86+
end
87+
end
88+
for child in node:iter_children() do
89+
walk_body(child, re_declares)
90+
end
91+
return
92+
end
93+
end
94+
95+
if node_type == 'identifier' then
96+
if ts.get_node_text(node, bufnr) == old_name then
97+
local parent = node:parent()
98+
if parent then
99+
local parent_type = parent:type()
100+
if parent_type == 'attribute' then
101+
local attr_field = parent:field('attribute')[1]
102+
if attr_field and attr_field:id() == node:id() then
103+
goto continue
104+
end
105+
end
106+
if parent_type == 'keyword_argument' then
107+
local name_field = parent:field('name')[1]
108+
if name_field and name_field:id() == node:id() then
109+
goto continue
110+
end
111+
end
112+
end
113+
local row, col_start, _, col_end = node:range()
114+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
115+
end
116+
::continue::
117+
end
118+
119+
for child in node:iter_children() do
120+
walk_body(child, false)
121+
end
122+
end
123+
124+
walk_body(body_node, false)
125+
return positions
126+
end
127+
128+
local find_rename_positions = function(bufnr, old_name)
129+
local ok = pcall(function()
130+
vim.treesitter.language.inspect('python')
131+
end)
132+
if not ok then
133+
warn('Python treesitter parser not installed - cannot rename fixture')
134+
return nil
135+
end
136+
137+
local parser = ts.get_parser(bufnr, 'python')
138+
local tree = parser:parse()[1]
139+
local root = tree:root()
140+
141+
local positions = {}
142+
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
170+
end
171+
172+
-- Case B: Fixture consumer
173+
if node_type == 'function_definition' then
174+
local params_node = node:field('parameters')[1]
175+
if params_node then
176+
local found_param_node = nil
177+
for child in params_node:iter_children() do
178+
local name_node = get_param_name_node(child)
179+
if name_node and ts.get_node_text(name_node, bufnr) == old_name then
180+
found_param_node = name_node
181+
break
182+
end
183+
end
184+
185+
if found_param_node then
186+
local row, col_start, _, col_end = found_param_node:range()
187+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
188+
189+
local body_node = node:field('body')[1]
190+
if body_node then
191+
local body_refs = find_body_references(body_node, old_name, bufnr)
192+
for _, pos in ipairs(body_refs) do
193+
table.insert(positions, pos)
194+
end
195+
end
196+
end
197+
end
198+
end
199+
200+
-- Case C: @pytest.mark.usefixtures("old_name") string arguments
201+
if node_type == 'call' then
202+
local func = node:field('function')[1]
203+
if func and func:type() == 'attribute' then
204+
local func_text = ts.get_node_text(func, bufnr)
205+
if func_text == 'pytest.mark.usefixtures' then
206+
local args = node:field('arguments')[1]
207+
if args then
208+
for child in args:iter_children() do
209+
if child:type() == 'string' then
210+
-- Find the string_content child which holds the text without quotes
211+
for schild in child:iter_children() do
212+
if schild:type() == 'string_content' then
213+
if ts.get_node_text(schild, bufnr) == old_name then
214+
local row, col_start, _, col_end = schild:range()
215+
table.insert(positions, { row = row, col_start = col_start, col_end = col_end })
216+
end
217+
end
218+
end
219+
end
220+
end
221+
end
222+
end
223+
end
224+
end
225+
end)
226+
227+
return positions
228+
end
229+
230+
local apply_renames = function(bufnr, positions, new_name)
231+
table.sort(positions, function(a, b)
232+
if a.row ~= b.row then
233+
return a.row > b.row
234+
end
235+
return a.col_start > b.col_start
236+
end)
237+
238+
for _, pos in ipairs(positions) do
239+
vim.api.nvim_buf_set_text(
240+
bufnr,
241+
pos.row, pos.col_start,
242+
pos.row, pos.col_end,
243+
{ new_name }
244+
)
245+
end
246+
247+
return #positions
248+
end
249+
250+
local function rename(old_name, new_name)
251+
if old_name == new_name then
252+
warn(string.format('New name is the same as old name: "%s"', old_name))
253+
return
254+
end
255+
256+
local current_file = vim.api.nvim_buf_get_name(0)
257+
local root_dir = paths.split_at_root(current_file)
258+
if root_dir == nil then
259+
return
260+
end
261+
262+
local py_files = find_python_files(root_dir, old_name)
263+
if #py_files == 0 then
264+
warn(string.format('No Python files contain "%s"', old_name))
265+
return
266+
end
267+
268+
local total_replacements = 0
269+
local files_changed = 0
270+
271+
for _, filepath in ipairs(py_files) do
272+
local existing_bufnr = vim.fn.bufnr(filepath)
273+
local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1
274+
275+
local bufnr = vim.fn.bufadd(filepath)
276+
if not was_loaded then
277+
vim.fn.bufload(bufnr)
278+
end
279+
280+
vim.api.nvim_set_option_value('filetype', 'python', { buf = bufnr })
281+
282+
local positions = find_rename_positions(bufnr, old_name)
283+
if positions == nil then
284+
return
285+
end
286+
287+
if #positions > 0 then
288+
local count = apply_renames(bufnr, positions, new_name)
289+
total_replacements = total_replacements + count
290+
files_changed = files_changed + 1
291+
292+
vim.api.nvim_buf_call(bufnr, function()
293+
vim.cmd('write')
294+
end)
295+
end
296+
297+
if not was_loaded then
298+
vim.api.nvim_buf_delete(bufnr, { force = false })
299+
end
300+
end
301+
302+
if total_replacements == 0 then
303+
warn(string.format('No fixture references found for "%s"', old_name))
304+
else
305+
vim.notify(string.format(
306+
'Pytrize: Renamed "%s" -> "%s" in %d file(s) (%d occurrence(s))',
307+
old_name, new_name, files_changed, total_replacements
308+
), vim.log.levels.INFO)
309+
end
310+
end
311+
312+
M.rename_fixture = function()
313+
local old_name = get_fixture_name()
314+
if old_name == '' then
315+
warn('No word under cursor')
316+
return
317+
end
318+
319+
vim.ui.input(
320+
{ prompt = string.format('Rename fixture "%s" to: ', old_name) },
321+
function(new_name)
322+
if new_name == nil or new_name == '' then
323+
return
324+
end
325+
rename(old_name, new_name)
326+
end
327+
)
328+
end
329+
330+
-- Internal exports for testing
331+
M._find_rename_positions = find_rename_positions
332+
M._apply_renames = apply_renames
333+
334+
return M

tests/minimal_init.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
local plenary_dir = "/tmp/plenary.nvim"
2+
local ts_dir = vim.fn.stdpath("data") .. "/lazy/nvim-treesitter"
3+
4+
vim.opt.runtimepath:append(".")
5+
vim.opt.runtimepath:append(plenary_dir)
6+
if vim.fn.isdirectory(ts_dir) == 1 then
7+
vim.opt.runtimepath:append(ts_dir)
8+
end
9+
10+
vim.cmd("runtime plugin/plenary.vim")

0 commit comments

Comments
 (0)