From 7729b1467166f2e087721453484e1001d1eacbb4 Mon Sep 17 00:00:00 2001 From: Ruben Garcia Date: Tue, 17 Feb 2026 21:24:51 +0100 Subject: [PATCH 1/2] feat: Rely on treesitter only --- lua/pytrize/jump/fixture.lua | 81 +++-------- lua/pytrize/paths.lua | 27 ++++ lua/pytrize/rename.lua | 57 ++------ lua/pytrize/ts.lua | 123 ++++++++++++++++ tests/pytrize/paths_spec.lua | 79 +++++++++++ tests/pytrize/ts_spec.lua | 263 +++++++++++++++++++++++++++++++++++ 6 files changed, 522 insertions(+), 108 deletions(-) create mode 100644 lua/pytrize/ts.lua create mode 100644 tests/pytrize/paths_spec.lua create mode 100644 tests/pytrize/ts_spec.lua diff --git a/lua/pytrize/jump/fixture.lua b/lua/pytrize/jump/fixture.lua index e8c0051..febcf1d 100644 --- a/lua/pytrize/jump/fixture.lua +++ b/lua/pytrize/jump/fixture.lua @@ -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('') + 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 diff --git a/lua/pytrize/paths.lua b/lua/pytrize/paths.lua index ab0cac0..0802dd0 100644 --- a/lua/pytrize/paths.lua +++ b/lua/pytrize/paths.lua @@ -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 diff --git a/lua/pytrize/rename.lua b/lua/pytrize/rename.lua index 56ece57..daf5cd0 100644 --- a/lua/pytrize/rename.lua +++ b/lua/pytrize/rename.lua @@ -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('') @@ -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 @@ -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 diff --git a/lua/pytrize/ts.lua b/lua/pytrize/ts.lua new file mode 100644 index 0000000..cafdcfc --- /dev/null +++ b/lua/pytrize/ts.lua @@ -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 diff --git a/tests/pytrize/paths_spec.lua b/tests/pytrize/paths_spec.lua new file mode 100644 index 0000000..c55d544 --- /dev/null +++ b/tests/pytrize/paths_spec.lua @@ -0,0 +1,79 @@ +local paths = require("pytrize.paths") + +-- Helper to create a temp directory tree with conftest.py files +local tmp_root = "/tmp/pytrize_paths_test" + +local function setup_tree(conftests) + -- conftests: list of relative dirs that should have a conftest.py + vim.fn.mkdir(tmp_root, "p") + for _, rel in ipairs(conftests) do + local dir = tmp_root .. "/" .. rel + vim.fn.mkdir(dir, "p") + local f = io.open(dir .. "/conftest.py", "w") + f:write("# conftest\n") + f:close() + end +end + +local function teardown_tree() + vim.fn.delete(tmp_root, "rf") +end + +describe("get_conftest_chain", function() + after_each(function() + teardown_tree() + end) + + it("finds conftest.py at root and subdirectory", function() + setup_tree({ ".", "tests" }) + vim.fn.mkdir(tmp_root .. "/tests/unit", "p") + local test_file = tmp_root .. "/tests/unit/test_foo.py" + + local chain = paths.get_conftest_chain(test_file, tmp_root) + assert.are.same({ + tmp_root .. "/conftest.py", + tmp_root .. "/tests/conftest.py", + }, chain) + end) + + it("returns empty when no conftest.py files exist", function() + vim.fn.mkdir(tmp_root .. "/tests", "p") + local test_file = tmp_root .. "/tests/test_foo.py" + + local chain = paths.get_conftest_chain(test_file, tmp_root) + assert.are.same({}, chain) + end) + + it("finds conftest.py only at root", function() + setup_tree({ "." }) + vim.fn.mkdir(tmp_root .. "/tests", "p") + local test_file = tmp_root .. "/tests/test_foo.py" + + local chain = paths.get_conftest_chain(test_file, tmp_root) + assert.are.same({ + tmp_root .. "/conftest.py", + }, chain) + end) + + it("finds conftest.py at every level", function() + setup_tree({ ".", "tests", "tests/unit" }) + local test_file = tmp_root .. "/tests/unit/test_foo.py" + + local chain = paths.get_conftest_chain(test_file, tmp_root) + assert.are.same({ + tmp_root .. "/conftest.py", + tmp_root .. "/tests/conftest.py", + tmp_root .. "/tests/unit/conftest.py", + }, chain) + end) + + it("returns conftest chain in root-to-leaf order", function() + setup_tree({ ".", "a", "a/b", "a/b/c" }) + local test_file = tmp_root .. "/a/b/c/test_deep.py" + + local chain = paths.get_conftest_chain(test_file, tmp_root) + assert.are.equal(4, #chain) + assert.are.equal(tmp_root .. "/conftest.py", chain[1]) + assert.are.equal(tmp_root .. "/a/b/c/conftest.py", chain[4]) + end) +end) diff --git a/tests/pytrize/ts_spec.lua b/tests/pytrize/ts_spec.lua new file mode 100644 index 0000000..4948333 --- /dev/null +++ b/tests/pytrize/ts_spec.lua @@ -0,0 +1,263 @@ +local has_parser = pcall(function() + vim.treesitter.language.inspect("python") +end) + +if not has_parser then + describe("ts (skipped)", function() + it("SKIPPED: python treesitter parser not installed", function() + print("Skipping ts tests: python treesitter parser not available") + end) + end) + return +end + +local ts_utils = require("pytrize.ts") + +local function create_python_buf(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value("filetype", "python", { buf = bufnr }) + local parser = vim.treesitter.get_parser(bufnr, "python") + parser:parse() + return bufnr +end + +describe("get_fixture_defs", function() + it("finds a bare @pytest.fixture definition", function() + local bufnr = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + }) + local defs = ts_utils.get_fixture_defs(bufnr) + assert.are.equal(1, #defs) + assert.are.equal("my_fixture", defs[1].name) + assert.is_not_nil(defs[1].name_node) + assert.is_not_nil(defs[1].func_node) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("finds a @pytest.fixture() call-form definition", function() + local bufnr = create_python_buf({ + "import pytest", + "", + "@pytest.fixture(scope='module')", + "def my_fixture():", + " return 42", + }) + local defs = ts_utils.get_fixture_defs(bufnr) + assert.are.equal(1, #defs) + assert.are.equal("my_fixture", defs[1].name) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("ignores plain functions without @pytest.fixture", function() + local bufnr = create_python_buf({ + "def not_a_fixture():", + " return 42", + }) + local defs = ts_utils.get_fixture_defs(bufnr) + assert.are.equal(0, #defs) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("finds multiple fixtures in one file", function() + local bufnr = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def fixture_a():", + " return 1", + "", + "@pytest.fixture(scope='session')", + "def fixture_b():", + " return 2", + }) + local defs = ts_utils.get_fixture_defs(bufnr) + assert.are.equal(2, #defs) + assert.are.equal("fixture_a", defs[1].name) + assert.are.equal("fixture_b", defs[2].name) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("ignores @pytest.mark.parametrize and other decorators", function() + local bufnr = create_python_buf({ + "import pytest", + "", + '@pytest.mark.parametrize("x", [1, 2])', + "def test_foo(x):", + " pass", + }) + local defs = ts_utils.get_fixture_defs(bufnr) + assert.are.equal(0, #defs) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) +end) + +local function write_tmp_py(name, lines) + local path = "/tmp/pytrize_test_" .. name .. ".py" + local f = io.open(path, "w") + f:write(table.concat(lines, "\n") .. "\n") + f:close() + return path +end + +describe("scan_fixtures", function() + it("returns fixtures keyed by name with file and linenr", function() + local path = write_tmp_py("scan1", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'connection'", + }) + local fixtures = ts_utils.scan_fixtures(path) + assert.is_not_nil(fixtures.db) + assert.are.equal(path, fixtures.db.file) + assert.are.equal(4, fixtures.db.linenr) + os.remove(path) + end) + + it("returns multiple fixtures from one file", function() + local path = write_tmp_py("scan2", { + "import pytest", + "", + "@pytest.fixture", + "def alpha():", + " return 1", + "", + "@pytest.fixture", + "def beta():", + " return 2", + }) + local fixtures = ts_utils.scan_fixtures(path) + assert.is_not_nil(fixtures.alpha) + assert.is_not_nil(fixtures.beta) + assert.are.equal(4, fixtures.alpha.linenr) + assert.are.equal(8, fixtures.beta.linenr) + os.remove(path) + end) + + it("returns empty table for file with no fixtures", function() + local path = write_tmp_py("scan3", { + "def helper():", + " return 42", + }) + local fixtures = ts_utils.scan_fixtures(path) + assert.are.same({}, fixtures) + os.remove(path) + end) + + it("cleans up buffer for files that were not previously loaded", function() + local path = write_tmp_py("scan4", { + "import pytest", + "", + "@pytest.fixture", + "def temp():", + " pass", + }) + assert.are.equal(-1, vim.fn.bufnr(path)) + ts_utils.scan_fixtures(path) + assert.are.equal(-1, vim.fn.bufnr(path)) + os.remove(path) + end) +end) + +-- build_fixture_index tests +local tmp_root = "/tmp/pytrize_index_test" + +local function write_py(path, lines) + local dir = vim.fn.fnamemodify(path, ":h") + vim.fn.mkdir(dir, "p") + local f = io.open(path, "w") + f:write(table.concat(lines, "\n") .. "\n") + f:close() +end + +local function teardown() + vim.fn.delete(tmp_root, "rf") +end + +describe("build_fixture_index", function() + after_each(teardown) + + it("finds fixtures from conftest.py and the test file", function() + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'conn'", + }) + write_py(tmp_root .. "/tests/test_foo.py", { + "import pytest", + "", + "@pytest.fixture", + "def local_fix():", + " return 1", + "", + "def test_it(db, local_fix):", + " pass", + }) + + local fixtures = ts_utils.build_fixture_index(tmp_root .. "/tests/test_foo.py", tmp_root) + assert.is_not_nil(fixtures.db) + assert.are.equal(tmp_root .. "/conftest.py", fixtures.db.file) + assert.is_not_nil(fixtures.local_fix) + assert.are.equal(tmp_root .. "/tests/test_foo.py", fixtures.local_fix.file) + end) + + it("inner conftest overrides outer conftest for same fixture name", function() + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'outer'", + }) + write_py(tmp_root .. "/tests/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'inner'", + }) + vim.fn.mkdir(tmp_root .. "/tests", "p") + local test_file = tmp_root .. "/tests/test_foo.py" + write_py(test_file, { "def test_it(db): pass" }) + + local fixtures = ts_utils.build_fixture_index(test_file, tmp_root) + assert.is_not_nil(fixtures.db) + assert.are.equal(tmp_root .. "/tests/conftest.py", fixtures.db.file) + end) + + it("test file fixture overrides conftest fixture", function() + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'conftest'", + }) + write_py(tmp_root .. "/tests/test_foo.py", { + "import pytest", + "", + "@pytest.fixture", + "def db():", + " return 'local'", + }) + + local fixtures = ts_utils.build_fixture_index(tmp_root .. "/tests/test_foo.py", tmp_root) + assert.are.equal(tmp_root .. "/tests/test_foo.py", fixtures.db.file) + end) + + it("returns empty when no fixtures exist anywhere", function() + vim.fn.mkdir(tmp_root .. "/tests", "p") + write_py(tmp_root .. "/tests/test_foo.py", { "def test_it(): pass" }) + + local fixtures = ts_utils.build_fixture_index(tmp_root .. "/tests/test_foo.py", tmp_root) + assert.are.same({}, fixtures) + end) +end) From e766248debd82b9b0c899d419f20c9445781b65d Mon Sep 17 00:00:00 2001 From: Ruben Garcia Date: Tue, 17 Feb 2026 21:42:23 +0100 Subject: [PATCH 2/2] 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 --- lua/pytrize/rename.lua | 18 ++++- tests/pytrize/rename_spec.lua | 125 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/lua/pytrize/rename.lua b/lua/pytrize/rename.lua index daf5cd0..b7296e4 100644 --- a/lua/pytrize/rename.lua +++ b/lua/pytrize/rename.lua @@ -228,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 @@ -293,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 diff --git a/tests/pytrize/rename_spec.lua b/tests/pytrize/rename_spec.lua index ddec74e..05b79bf 100644 --- a/tests/pytrize/rename_spec.lua +++ b/tests/pytrize/rename_spec.lua @@ -229,6 +229,131 @@ describe("find_rename_positions - combined", function() end) end) +describe("find_rename_positions - cross-file isolation", function() + it("only renames the fixture in the current buffer, not in another file with the same fixture", function() + local bufnr_a = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 1", + "", + "def test_a(my_fixture):", + " assert my_fixture", + }) + local bufnr_b = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 2", + "", + "def test_b(my_fixture):", + " assert my_fixture", + }) + + -- Rename only in buffer A + local positions = rename._find_rename_positions(bufnr_a, "my_fixture") + assert.are.equal(3, #positions) -- def name, param, body ref + rename._apply_renames(bufnr_a, positions, "renamed_fix") + + -- Buffer A is renamed + local lines_a = vim.api.nvim_buf_get_lines(bufnr_a, 0, -1, false) + assert.are.equal("def renamed_fix():", lines_a[4]) + assert.are.equal("def test_a(renamed_fix):", lines_a[7]) + assert.are.equal(" assert renamed_fix", lines_a[8]) + + -- Buffer B is untouched + local lines_b = vim.api.nvim_buf_get_lines(bufnr_b, 0, -1, false) + assert.are.equal("def my_fixture():", lines_b[4]) + assert.are.equal("def test_b(my_fixture):", lines_b[7]) + assert.are.equal(" assert my_fixture", lines_b[8]) + + vim.api.nvim_buf_delete(bufnr_a, { force = true }) + vim.api.nvim_buf_delete(bufnr_b, { force = true }) + end) +end) + +local tmp_root = "/tmp/pytrize_rename_scope_test" + +local function write_py(path, lines) + local dir = vim.fn.fnamemodify(path, ":h") + vim.fn.mkdir(dir, "p") + local f = io.open(path, "w") + f:write(table.concat(lines, "\n") .. "\n") + f:close() +end + +local function read_file_lines(path) + local f = io.open(path, "r") + local content = f:read("*a") + f:close() + local lines = vim.split(content, "\n") + if lines[#lines] == "" then + table.remove(lines) + end + return lines +end + +describe("rename - cross-file fixture scoping", function() + after_each(function() + -- Close all buffers from the tmp dir + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(bufnr) + if name:find(tmp_root, 1, true) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + vim.fn.delete(tmp_root, "rf") + end) + + it("renaming fixture in conftest only renames usage in file_a, not in file_b which has its own definition", function() + vim.fn.mkdir(tmp_root .. "/.pytest_cache", "p") + + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 'from conftest'", + }) + write_py(tmp_root .. "/test_a.py", { + "def test_uses_conftest(my_fixture):", + " assert my_fixture", + }) + write_py(tmp_root .. "/test_b.py", { + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 'local to b'", + "", + "def test_uses_local(my_fixture):", + " assert my_fixture", + }) + + -- Open conftest.py as the current buffer (where rename is initiated) + vim.cmd("edit " .. tmp_root .. "/conftest.py") + + rename._rename("my_fixture", "renamed_fix") + + -- conftest.py: definition is renamed + local conftest_lines = read_file_lines(tmp_root .. "/conftest.py") + assert.are.equal("def renamed_fix():", conftest_lines[4]) + + -- test_a.py: consumer is renamed (uses conftest fixture) + local a_lines = read_file_lines(tmp_root .. "/test_a.py") + assert.are.equal("def test_uses_conftest(renamed_fix):", a_lines[1]) + assert.are.equal(" assert renamed_fix", a_lines[2]) + + -- test_b.py: untouched (has its own fixture with the same name) + local b_lines = read_file_lines(tmp_root .. "/test_b.py") + assert.are.equal("def my_fixture():", b_lines[4]) + assert.are.equal("def test_uses_local(my_fixture):", b_lines[7]) + assert.are.equal(" assert my_fixture", b_lines[8]) + end) +end) + describe("apply_renames", function() it("replaces identifiers in buffer", function() local bufnr = create_python_buf({