From 795cd68b9ae877b5f80da9fd55a45bfd870eee58 Mon Sep 17 00:00:00 2001 From: Ruben Garcia Date: Wed, 18 Feb 2026 23:02:20 +0100 Subject: [PATCH 1/2] 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 --- lua/pytrize/api.lua | 6 + lua/pytrize/init.lua | 1 + lua/pytrize/usages.lua | 221 ++++++++++++++++++++++++++++++++++ tests/pytrize/usages_spec.lua | 220 +++++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 lua/pytrize/usages.lua create mode 100644 tests/pytrize/usages_spec.lua 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) From a5acce6d302107793e4f19f7869ece340c560295 Mon Sep 17 00:00:00 2001 From: Ruben Garcia Date: Wed, 18 Feb 2026 23:03:19 +0100 Subject: [PATCH 2/2] docs: Document PytrizeFixtureUsages command in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: