diff --git a/README.md b/README.md index d04f647..6f76fd6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Several things: - Provides a command to rename the fixture under the cursor (by name), see [fixture](#rename-fixture) below. Done by calling `PytrizeRenameFixture`. Alternatively `lua require('pytrize.api').rename_fixture()`. +- Provides a command to find all usages of the fixture under the cursor across the project, see [fixture usages](#fixture-usages) below. + Done by calling `PytrizeFixtureUsages`. + Alternatively `lua require('pytrize.api').fixture_usages()`. ## Installation @@ -117,6 +120,13 @@ To jump to the declaration of a fixture under the cursor, do `PytrizeJumpFixture To rename the fixture under the cursor, do `PytrizeRenameFixture`: +## Fixture usages + +To find all usages of the fixture under the cursor, do `PytrizeFixtureUsages`. + +Results are loaded into Neovim's quickfix list and the quickfix window is opened automatically. +Each entry shows the file, line, and the line content where the fixture is used — as a parameter, a body reference, or inside `@pytest.mark.usefixtures(...)`. The fixture definition itself is excluded from the results. + ## Input In some cases the file-path is not printed by pytest, for example when a test fails when it might look something like: diff --git a/lua/pytrize/api.lua b/lua/pytrize/api.lua index 3cc076d..4fd604f 100644 --- a/lua/pytrize/api.lua +++ b/lua/pytrize/api.lua @@ -59,4 +59,10 @@ M.rename_fixture = function() rename.rename_fixture() end +M.fixture_usages = function() + local usages = require('pytrize.usages') + + usages.show_usages() +end + return M diff --git a/lua/pytrize/init.lua b/lua/pytrize/init.lua index cb68732..25c96f6 100644 --- a/lua/pytrize/init.lua +++ b/lua/pytrize/init.lua @@ -8,6 +8,7 @@ local function setup_commands() vim.cmd('command PytrizeJump lua require("pytrize.api").jump()') vim.cmd('command PytrizeJumpFixture lua require("pytrize.api").jump_fixture()') vim.cmd('command PytrizeRenameFixture lua require("pytrize.api").rename_fixture()') + vim.cmd('command PytrizeFixtureUsages lua require("pytrize.api").fixture_usages()') end M.setup = function(opts) diff --git a/lua/pytrize/usages.lua b/lua/pytrize/usages.lua new file mode 100644 index 0000000..9929255 --- /dev/null +++ b/lua/pytrize/usages.lua @@ -0,0 +1,221 @@ +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 find_python_files(root_dir, name) + return vim.fn.systemlist(string.format( + 'grep -rl --include="*.py" "%s" "%s"', + vim.fn.escape(name, '"\\'), + root_dir + )) +end + +local function get_param_name_node(param_node) + local t = param_node:type() + if t == 'identifier' then + return param_node + elseif t == 'default_parameter' or t == 'typed_default_parameter' then + return param_node:field('name')[1] + elseif t == 'typed_parameter' then + local first = param_node:named_child(0) + if first and first:type() == 'identifier' then + return first + end + end + return nil +end + +local function find_body_references(body_node, fixture_name, bufnr) + local positions = {} + + local function walk_body(node, shadowed) + if shadowed then return end + + local node_type = node:type() + + if node_type == 'function_definition' then + local params_node = node:field('parameters')[1] + if params_node then + local re_declares = false + for child in params_node:iter_children() do + local name_node = get_param_name_node(child) + if name_node and ts.get_node_text(name_node, bufnr) == fixture_name then + re_declares = true + break + end + end + for child in node:iter_children() do + walk_body(child, re_declares) + end + return + end + end + + if node_type == 'identifier' then + if ts.get_node_text(node, bufnr) == fixture_name then + local parent = node:parent() + if parent then + local parent_type = parent:type() + if parent_type == 'attribute' then + local attr_field = parent:field('attribute')[1] + if attr_field and attr_field:id() == node:id() then + goto continue + end + end + if parent_type == 'keyword_argument' then + local name_field = parent:field('name')[1] + if name_field and name_field:id() == node:id() then + goto continue + end + end + end + local row, col_start, _, col_end = node:range() + table.insert(positions, { row = row, col_start = col_start, col_end = col_end }) + end + ::continue:: + end + + for child in node:iter_children() do + walk_body(child, false) + end + end + + walk_body(body_node, false) + return positions +end + +-- Find usage positions (parameters, body references, usefixtures strings) +-- Does NOT include fixture definitions. +local find_usage_positions = function(bufnr, fixture_name) + local ok = pcall(function() vim.treesitter.language.inspect('python') end) + if not ok then return {} end + + local parser = ts.get_parser(bufnr, 'python') + local tree = parser:parse()[1] + local root = tree:root() + + local positions = {} + + ts_utils.walk(root, function(node) + local node_type = node:type() + + -- Case A: fixture consumers (parameter + body refs) + if node_type == 'function_definition' then + local params_node = node:field('parameters')[1] + if params_node then + local found_param_node = nil + for child in params_node:iter_children() do + local name_node = get_param_name_node(child) + if name_node and ts.get_node_text(name_node, bufnr) == fixture_name then + found_param_node = name_node + break + end + end + + if found_param_node then + local row, col_start, _, col_end = found_param_node:range() + table.insert(positions, { row = row, col_start = col_start, col_end = col_end }) + + local body_node = node:field('body')[1] + if body_node then + for _, pos in ipairs(find_body_references(body_node, fixture_name, bufnr)) do + table.insert(positions, pos) + end + end + end + end + end + + -- Case B: @pytest.mark.usefixtures("fixture_name") strings + if node_type == 'call' then + local func = node:field('function')[1] + if func and func:type() == 'attribute' then + if ts.get_node_text(func, bufnr) == 'pytest.mark.usefixtures' then + local args = node:field('arguments')[1] + if args then + for child in args:iter_children() do + if child:type() == 'string' then + for schild in child:iter_children() do + if schild:type() == 'string_content' then + if ts.get_node_text(schild, bufnr) == fixture_name then + local row, col_start, _, col_end = schild:range() + table.insert(positions, { row = row, col_start = col_start, col_end = col_end }) + end + end + end + end + end + end + end + end + end + end) + + return positions +end + +local find_all_usages = function(fixture_name, root_dir) + local py_files = find_python_files(root_dir, fixture_name) + local items = {} + + for _, filepath in ipairs(py_files) do + 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 positions = find_usage_positions(bufnr, fixture_name) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + for _, pos in ipairs(positions) do + table.insert(items, { + filename = filepath, + lnum = pos.row + 1, + col = pos.col_start + 1, + text = lines[pos.row + 1] or '', + }) + end + + if not was_loaded then + vim.api.nvim_buf_delete(bufnr, { force = false }) + end + end + + return items +end + +M.show_usages = function() + local fixture_name = vim.fn.expand('') + if fixture_name == '' then + warn('no word under cursor') + return + 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 items = find_all_usages(fixture_name, root_dir) + + if #items == 0 then + warn(string.format('no usages found for fixture "%s"', fixture_name)) + return + end + + vim.fn.setqflist(items, 'r') + vim.cmd('copen') +end + +-- Internal exports for testing +M._find_usage_positions = find_usage_positions +M._find_all_usages = find_all_usages + +return M diff --git a/tests/pytrize/usages_spec.lua b/tests/pytrize/usages_spec.lua new file mode 100644 index 0000000..7764696 --- /dev/null +++ b/tests/pytrize/usages_spec.lua @@ -0,0 +1,220 @@ +local has_parser = pcall(function() + vim.treesitter.language.inspect("python") +end) + +if not has_parser then + describe("usages (skipped)", function() + it("SKIPPED: python treesitter parser not installed", function() + print("Skipping usages tests: python treesitter parser not available") + end) + end) + return +end + +local usages = require("pytrize.usages") + +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("find_usage_positions", function() + it("finds fixture used as a plain parameter", function() + local bufnr = create_python_buf({ + "def test_foo(my_fixture):", + " assert my_fixture == 42", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + -- param (row 0) + body ref (row 1) + assert.are.equal(2, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("finds fixture used as a typed parameter", function() + local bufnr = create_python_buf({ + "def test_foo(my_fixture: MagicMock):", + " my_fixture.assert_called()", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + -- typed param (row 0) + body ref (row 1) + assert.are.equal(2, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("finds fixture in @pytest.mark.usefixtures", function() + local bufnr = create_python_buf({ + "import pytest", + "", + '@pytest.mark.usefixtures("my_fixture")', + "def test_foo(self):", + " pass", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + assert.are.equal(1, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("does NOT include the fixture definition itself", function() + local bufnr = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + assert.are.equal(0, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("does NOT find attribute access (db.my_fixture)", function() + local bufnr = create_python_buf({ + "def test_foo(db):", + " x = db.my_fixture", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + assert.are.equal(0, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("does NOT find keyword argument names", function() + local bufnr = create_python_buf({ + "def test_foo(db):", + " call(my_fixture=1)", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + assert.are.equal(0, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("finds both definition and consumer when they coexist, but only the consumer positions", function() + local bufnr = create_python_buf({ + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + "", + "def test_uses(my_fixture):", + " assert my_fixture", + }) + local positions = usages._find_usage_positions(bufnr, "my_fixture") + -- param (row 6) + body ref (row 7); definition on row 3 is excluded + assert.are.equal(2, #positions) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) +end) + +local tmp_root = "/tmp/pytrize_usages_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 + +describe("find_all_usages", function() + after_each(function() + 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("returns quickfix items for usages across files", function() + vim.fn.mkdir(tmp_root .. "/.git", "p") + + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + }) + write_py(tmp_root .. "/test_a.py", { + "def test_uses(my_fixture):", + " assert my_fixture", + }) + write_py(tmp_root .. "/test_b.py", { + "import pytest", + "", + '@pytest.mark.usefixtures("my_fixture")', + "def test_indirect(self):", + " pass", + }) + + local items = usages._find_all_usages("my_fixture", tmp_root) + + -- Each item must have the quickfix fields + assert.is_true(#items > 0) + for _, item in ipairs(items) do + assert.is_not_nil(item.filename) + assert.is_not_nil(item.lnum) + assert.is_not_nil(item.col) + assert.is_not_nil(item.text) + end + end) + + it("does NOT include the fixture definition in results", function() + vim.fn.mkdir(tmp_root .. "/.git", "p") + + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + }) + write_py(tmp_root .. "/test_a.py", { + "def test_uses(my_fixture):", + " assert my_fixture", + }) + + local items = usages._find_all_usages("my_fixture", tmp_root) + + -- conftest.py line 4 is the definition — must not appear + for _, item in ipairs(items) do + local in_conftest = item.filename:find("conftest.py", 1, true) ~= nil + local is_def_line = item.lnum == 4 + assert.is_false(in_conftest and is_def_line, "definition should not appear in usages") + end + end) + + it("finds the right files and line numbers", function() + vim.fn.mkdir(tmp_root .. "/.git", "p") + + write_py(tmp_root .. "/conftest.py", { + "import pytest", + "", + "@pytest.fixture", + "def my_fixture():", + " return 42", + }) + write_py(tmp_root .. "/test_a.py", { + "def test_uses(my_fixture):", + " assert my_fixture", + }) + + local items = usages._find_all_usages("my_fixture", tmp_root) + + -- Find the parameter usage in test_a.py line 1 + local found_param = false + for _, item in ipairs(items) do + if item.filename:find("test_a.py", 1, true) and item.lnum == 1 then + found_param = true + assert.is_true(item.col > 0) + assert.is_not_nil(item.text:find("my_fixture")) + end + end + assert.is_true(found_param, "expected to find parameter usage in test_a.py line 1") + end) +end)