diff --git a/lua/pytrize/api.lua b/lua/pytrize/api.lua index 8307a59..0a5da72 100644 --- a/lua/pytrize/api.lua +++ b/lua/pytrize/api.lua @@ -1,21 +1,18 @@ local M = {} +--- Clear pytrize virtual text from buffer. +---@param bufnr? integer Buffer number (0 or nil for current buffer) M.clear = function(bufnr) local marks = require("pytrize.marks") - - if bufnr == nil then - bufnr = 0 - end - marks.clear(bufnr) + marks.clear(bufnr or 0) end +--- Set pytrize virtual text for parametrize entries in buffer. +---@param bufnr? integer Buffer number (0 or nil for current buffer) M.set = function(bufnr) local cs = require("pytrize.call_spec") local marks = require("pytrize.marks") - - if bufnr == nil then - bufnr = 0 - end + bufnr = bufnr or 0 marks.clear(bufnr) local call_specs_per_func = cs.get_calls(bufnr) if call_specs_per_func == nil then @@ -45,24 +42,28 @@ M.set = function(bufnr) end end +--- Jump to the parametrize entry declaration under cursor. M.jump = function() local jump = require("pytrize.jump") jump.to_param_declaration() end +--- Jump to fixture definition under cursor. M.jump_fixture = function() local jump = require("pytrize.jump") jump.to_fixture_declaration() end +--- Rename fixture under cursor across the project. M.rename_fixture = function() local rename = require("pytrize.rename") rename.rename_fixture() end +--- Show all usages of fixture under cursor in quickfix list. M.fixture_usages = function() local usages = require("pytrize.usages") diff --git a/lua/pytrize/call_spec.lua b/lua/pytrize/call_spec.lua index 35e73b8..fab2cf5 100644 --- a/lua/pytrize/call_spec.lua +++ b/lua/pytrize/call_spec.lua @@ -1,34 +1,44 @@ local M = {} local ts = vim.treesitter -local ts_query = ts.query -local parse_query = ts_query.parse or ts_query.parse_query +local parse_query = vim.treesitter.query.parse local warn = require("pytrize.warn").warn local tbls = require("pytrize.tables") local get_root = function(bufnr) - local parser = ts.get_parser(bufnr) - local tstree = parser:parse()[1] - return tstree:root() + local ok, parser = pcall(ts.get_parser, bufnr, "python") + if not ok then + warn("Python treesitter parser not available") + return nil + end + local trees = parser:parse() + if not trees or #trees == 0 then + warn("Failed to parse buffer") + return nil + end + return trees[1]:root() end +local PARAM_QUERY = parse_query( + "python", + [[ + (decorated_definition ( + decorator ( + call + function: ((attribute) @param) + ) + )) + ]] +) + local get_param_call_nodes = function(bufnr) local tsroot = get_root(bufnr) - local query = parse_query( - "python", - -- TODO not sure why eg (#eq? @param "pytest.mark.parametrize") does not work - [[ - (decorated_definition ( - decorator ( - call - function: ((attribute) @param) - ) - )) - ]] - ) + if tsroot == nil then + return {} + end local nodes = {} - for _, node, _ in query:iter_captures(tsroot) do + for _, node, _ in PARAM_QUERY:iter_captures(tsroot, bufnr) do if ts.get_node_text(node, bufnr) == "pytest.mark.parametrize" then table.insert(nodes, node:parent()) end @@ -38,7 +48,17 @@ end local get_second_arg_node = function(call_node) local arguments = call_node:field("arguments")[1] - return arguments:child(3) + if not arguments then + return nil + end + -- Use named children to avoid depending on raw child indices + local named = {} + for child in arguments:iter_children() do + if child:named() then + table.insert(named, child) + end + end + return named[2] end local get_named_children = function(node) @@ -53,7 +73,7 @@ end local list_entries = function(call_node) local list = get_second_arg_node(call_node) - if list:type() ~= "list" then + if not list or list:type() ~= "list" then return {} end return get_named_children(list) @@ -166,14 +186,21 @@ M.get_calls = function(bufnr) local func_name = ts.get_node_text(func:field("name")[1], bufnr) local arguments = call:field("arguments")[1] - local params_node = arguments:child(1) - if params_node:type() ~= "string" then + local first_named = nil + for child in arguments:iter_children() do + if child:named() then + first_named = child + break + end + end + local params_node = first_named + if not params_node or params_node:type() ~= "string" then local row = call:start() warn( string.format( "couldn't parse params (line %d)\n expected `string`\n got `%s`", row, - params_node:type() + params_node and params_node:type() or "nil" ) ) return diff --git a/lua/pytrize/health.lua b/lua/pytrize/health.lua new file mode 100644 index 0000000..591f7b0 --- /dev/null +++ b/lua/pytrize/health.lua @@ -0,0 +1,39 @@ +local M = {} + +M.check = function() + vim.health.start("pytrize") + + -- Check Neovim version + if vim.fn.has("nvim-0.9.0") == 1 then + vim.health.ok("Neovim >= 0.9.0") + else + vim.health.error("Neovim >= 0.9.0 required") + end + + -- Check Python treesitter parser + local ok = pcall(vim.treesitter.language.inspect, "python") + if ok then + vim.health.ok("Python treesitter parser installed") + else + vim.health.error("Python treesitter parser not found", { + "Install with :TSInstall python (nvim-treesitter) or compile manually", + }) + end + + -- Check for grep (used by fixture rename/usages) + if vim.fn.executable("grep") == 1 then + vim.health.ok("grep command available") + else + vim.health.warn("grep not found — fixture rename and usages features will not work") + end + + -- Check highlight group + local settings = require("pytrize.settings").settings + if vim.fn.hlexists(settings.highlight) == 1 then + vim.health.ok(string.format("Highlight group '%s' exists", settings.highlight)) + else + vim.health.warn(string.format("Highlight group '%s' not found — virtual text may be invisible", settings.highlight)) + end +end + +return M diff --git a/lua/pytrize/init.lua b/lua/pytrize/init.lua index 52d15ce..84b5d96 100644 --- a/lua/pytrize/init.lua +++ b/lua/pytrize/init.lua @@ -3,18 +3,30 @@ local M = {} local settings = require("pytrize.settings") local function setup_commands() - vim.cmd('command Pytrize lua require("pytrize.api").set()') - vim.cmd('command PytrizeClear lua require("pytrize.api").clear()') - 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()') + vim.api.nvim_create_user_command("Pytrize", function() + require("pytrize.api").set() + end, { desc = "Set pytrize virtual text for parametrize entries" }) + vim.api.nvim_create_user_command("PytrizeClear", function() + require("pytrize.api").clear() + end, { desc = "Clear pytrize virtual text" }) + vim.api.nvim_create_user_command("PytrizeJump", function() + require("pytrize.api").jump() + end, { desc = "Jump to parametrize entry under cursor" }) + vim.api.nvim_create_user_command("PytrizeJumpFixture", function() + require("pytrize.api").jump_fixture() + end, { desc = "Jump to fixture definition under cursor" }) + vim.api.nvim_create_user_command("PytrizeRenameFixture", function() + require("pytrize.api").rename_fixture() + end, { desc = "Rename fixture under cursor across project" }) + vim.api.nvim_create_user_command("PytrizeFixtureUsages", function() + require("pytrize.api").fixture_usages() + end, { desc = "Show fixture usages in quickfix list" }) end +--- Configure the pytrize plugin. +---@param opts? PytrizeSettings M.setup = function(opts) - if opts == nil then - opts = {} - end + opts = opts or {} settings.update(opts) if not settings.settings.no_commands then setup_commands() diff --git a/lua/pytrize/jump/param.lua b/lua/pytrize/jump/param.lua index f191661..709c3c5 100644 --- a/lua/pytrize/jump/param.lua +++ b/lua/pytrize/jump/param.lua @@ -7,8 +7,6 @@ local paths = require('pytrize.paths') local warn = require('pytrize.warn').warn local open_file = require('pytrize.jump.util').open_file local get_nodeids_path = require('pytrize.paths').get_nodeids_path -local min = require('pytrize.utils').min -local max = require('pytrize.utils').max local function query_file(func_name, callback) local rootdir, _ = paths.split_at_root(vim.api.nvim_buf_get_name(0)) @@ -55,7 +53,7 @@ local function jump_to_nodeid_at_cursor(callback) local nodeid = nids.parse_raw(line:sub(i)) local pattern_position = col_num + 1 - (i - 1) -- cursor relative to match local param_position = pattern_position - nodeid.param_start_idx + 1 -- cursor relative to params - param_position = min(max(1, param_position), nodeid.params:len()) -- restrict it to be inside + param_position = math.min(math.max(1, param_position), nodeid.params:len()) -- restrict it to be inside if nodeid == nil then warn("couldn't parse nodeid under cursor") return diff --git a/lua/pytrize/jump/util.lua b/lua/pytrize/jump/util.lua index b96780a..9d0a4c6 100644 --- a/lua/pytrize/jump/util.lua +++ b/lua/pytrize/jump/util.lua @@ -1,6 +1,9 @@ local M = {} M.open_file = function(file) + -- vim.fn.execute() wraps :redir internally, suppressing the C-level + -- file-info message ("path" NL, NB) that :edit emits. vim.cmd.edit() + -- and :silent do not reliably suppress it. vim.fn.execute("edit " .. vim.fn.fnameescape(file)) end diff --git a/lua/pytrize/marks.lua b/lua/pytrize/marks.lua index c6eb948..e6e9243 100644 --- a/lua/pytrize/marks.lua +++ b/lua/pytrize/marks.lua @@ -2,49 +2,27 @@ local M = {} local settings = require("pytrize.settings").settings -local next_ext_ids = {} -local ext_ids = {} - -local function get_ns_id() - return vim.api.nvim_create_namespace("pytrize") -end +local NS_ID = vim.api.nvim_create_namespace("pytrize") +--- Clear all pytrize extmarks from a buffer. +---@param bufnr integer Buffer number (0 for current) M.clear = function(bufnr) if bufnr == 0 then - bufnr = vim.fn.bufnr() - end - for _, ext_id in ipairs(ext_ids[bufnr] or {}) do - vim.api.nvim_buf_del_extmark(bufnr, get_ns_id(), ext_id) - end - ext_ids[bufnr] = nil - next_ext_ids[bufnr] = nil -end - -local get_ext_id = function(bufnr) - if ext_ids[bufnr] == nil then - ext_ids[bufnr] = {} - end - if next_ext_ids[bufnr] == nil then - next_ext_ids[bufnr] = 1 + bufnr = vim.api.nvim_get_current_buf() end - local next_ext_id = next_ext_ids[bufnr] - table.insert(ext_ids[bufnr], next_ext_id) - next_ext_ids[bufnr] = next_ext_id + 1 - return next_ext_id + vim.api.nvim_buf_clear_namespace(bufnr, NS_ID, 0, -1) end +--- Set a virtual text extmark. +---@param opts { bufnr: integer, row: integer, text: string } M.set = function(opts) local bufnr = opts.bufnr if bufnr == 0 then - bufnr = vim.fn.bufnr() + bufnr = vim.api.nvim_get_current_buf() end - vim.api.nvim_buf_set_extmark( - bufnr, - get_ns_id(), - opts.row, - 0, - { id = get_ext_id(bufnr), virt_text = { { opts.text, settings.highlight } } } - ) + vim.api.nvim_buf_set_extmark(bufnr, NS_ID, opts.row, 0, { + virt_text = { { opts.text, settings.highlight } }, + }) end return M diff --git a/lua/pytrize/nodeids.lua b/lua/pytrize/nodeids.lua index 2882fd4..23626a5 100644 --- a/lua/pytrize/nodeids.lua +++ b/lua/pytrize/nodeids.lua @@ -6,7 +6,25 @@ local get_nodeids_path = require("pytrize.paths").get_nodeids_path local function get_raw_nodeids(rootdir) local nodeids_path = get_nodeids_path(rootdir) - return vim.fn.json_decode(vim.fn.readfile(nodeids_path)) + + if vim.fn.filereadable(nodeids_path) ~= 1 then + warn(string.format("Nodeids file not found: %s\nHave you run pytest?", nodeids_path)) + return {} + end + + local ok_read, content = pcall(vim.fn.readfile, nodeids_path) + if not ok_read then + warn(string.format("Failed to read nodeids file: %s", nodeids_path)) + return {} + end + + local ok_json, result = pcall(vim.fn.json_decode, content) + if not ok_json then + warn(string.format("Failed to parse nodeids JSON: %s", nodeids_path)) + return {} + end + + return result end M.parse_raw = function(raw_nodeid) diff --git a/lua/pytrize/rename.lua b/lua/pytrize/rename.lua index 48ff212..aa3a57a 100644 --- a/lua/pytrize/rename.lua +++ b/lua/pytrize/rename.lua @@ -4,6 +4,7 @@ local ts = vim.treesitter local warn = require("pytrize.warn").warn local paths = require("pytrize.paths") local ts_utils = require("pytrize.ts") +local ts_helpers = require("pytrize.ts_helpers") local utils = require("pytrize.utils") local function hrtime() @@ -17,100 +18,6 @@ local function get_fixture_name() return vim.fn.expand("") end -local function find_python_files(root_dir, name) - local result = - vim.fn.systemlist(string.format('grep -rl --include="*.py" "%s" "%s"', vim.fn.escape(name, '"\\'), root_dir)) - return result -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 - -- typed_parameter has no 'name' field; the identifier is the first named child - local first = param_node:named_child(0) - if first and first:type() == "identifier" then - return first - end - end - return nil -end - -local function get_param_names(parameters_node, bufnr) - local names = {} - for child in parameters_node:iter_children() do - local name_node = get_param_name_node(child) - if name_node then - table.insert(names, ts.get_node_text(name_node, bufnr)) - end - end - return names -end - -local function find_body_references(body_node, old_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 param_names = get_param_names(params_node, bufnr) - local re_declares = false - for _, p in ipairs(param_names) do - if p == old_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) == old_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 - local find_rename_positions = function(bufnr, old_name) local ok = pcall(function() vim.treesitter.language.inspect("python") @@ -120,13 +27,9 @@ local find_rename_positions = function(bufnr, old_name) return nil end - local parser = ts.get_parser(bufnr, "python") - local tree = parser:parse()[1] - local root = tree:root() - local positions = {} - -- Case A: Fixture definitions + -- Case A: Fixture definitions (rename the def name itself) 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() @@ -134,66 +37,11 @@ local find_rename_positions = function(bufnr, old_name) end end - ts_utils.walk(root, function(node) - local node_type = node:type() - - -- Case B: Fixture consumer - 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) == old_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 - local body_refs = find_body_references(body_node, old_name, bufnr) - for _, pos in ipairs(body_refs) do - table.insert(positions, pos) - end - end - end - end - end - - -- Case C: @pytest.mark.usefixtures("old_name") string arguments - if node_type == "call" then - local func = node:field("function")[1] - if func and func:type() == "attribute" then - local func_text = ts.get_node_text(func, bufnr) - if func_text == "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 - -- Find the string_content child which holds the text without quotes - for schild in child:iter_children() do - if schild:type() == "string_content" then - if ts.get_node_text(schild, bufnr) == old_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) + -- Case B & C: Consumer parameters, body references, usefixtures strings + local refs = ts_helpers.find_fixture_references(bufnr, old_name) + for _, pos in ipairs(refs) do + table.insert(positions, pos) + end return positions end @@ -228,7 +76,7 @@ local function rename(old_name, new_name) ts_utils.clear_scan_cache() local t0 = hrtime() - local py_files = find_python_files(root_dir, old_name) + local py_files = ts_helpers.find_python_files(root_dir, old_name) if #py_files == 0 then warn(string.format('No Python files contain "%s"', old_name)) return diff --git a/lua/pytrize/settings.lua b/lua/pytrize/settings.lua index 74a83f9..ee35e73 100644 --- a/lua/pytrize/settings.lua +++ b/lua/pytrize/settings.lua @@ -2,20 +2,31 @@ local M = {} local warn = require("pytrize.warn").warn --- defaults +---@class PytrizeSettings +---@field no_commands boolean Whether to skip creating user commands (default: false) +---@field highlight string Highlight group for virtual text (default: "LineNr") +---@field metrics boolean Show performance metrics for operations (default: false) + +---@type PytrizeSettings M.settings = { no_commands = false, highlight = "LineNr", metrics = false, - -- preferred_input = 'telescope', } +---@param opts table M.update = function(opts) for k, v in pairs(opts) do if M.settings[k] == nil then - warn("unexpected setting " .. k) + warn(string.format("unknown setting '%s'", k)) else - M.settings[k] = v + local expected = type(M.settings[k]) + local actual = type(v) + if expected ~= actual then + warn(string.format("invalid type for setting '%s': expected %s, got %s", k, expected, actual)) + else + M.settings[k] = v + end end end end diff --git a/lua/pytrize/ts.lua b/lua/pytrize/ts.lua index 84a15fc..21f0154 100644 --- a/lua/pytrize/ts.lua +++ b/lua/pytrize/ts.lua @@ -80,8 +80,24 @@ end local scan_cache = {} -M.clear_scan_cache = function() - scan_cache = {} +-- Auto-invalidate cache when Python files are saved or deleted +local cache_group = vim.api.nvim_create_augroup("PytrizeCache", { clear = true }) +vim.api.nvim_create_autocmd({ "BufWritePost", "BufDelete" }, { + group = cache_group, + pattern = "*.py", + callback = function(args) + local filepath = vim.api.nvim_buf_get_name(args.buf) + scan_cache[filepath] = nil + end, +}) + +---@param filepath? string Clear a single entry, or all entries if nil. +M.clear_scan_cache = function(filepath) + if filepath then + scan_cache[filepath] = nil + else + scan_cache = {} + end end M.scan_fixtures = function(filepath) diff --git a/lua/pytrize/ts_helpers.lua b/lua/pytrize/ts_helpers.lua new file mode 100644 index 0000000..01ec8ba --- /dev/null +++ b/lua/pytrize/ts_helpers.lua @@ -0,0 +1,200 @@ +local M = {} + +local ts = vim.treesitter + +--- Get the identifier node from a function parameter node. +--- Handles plain identifiers, default parameters, typed parameters, etc. +---@param param_node TSNode +---@return TSNode|nil +M.get_param_name_node = function(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 + +--- Get parameter names from a parameters node. +---@param parameters_node TSNode +---@param bufnr integer +---@return string[] +M.get_param_names = function(parameters_node, bufnr) + local names = {} + for child in parameters_node:iter_children() do + local name_node = M.get_param_name_node(child) + if name_node then + table.insert(names, ts.get_node_text(name_node, bufnr)) + end + end + return names +end + +--- Find all references to `name` inside a function body, respecting shadowing +--- by nested function definitions that re-declare the same parameter name. +---@param body_node TSNode +---@param name string +---@param bufnr integer +---@return table[] positions Array of {row, col_start, col_end} +M.find_body_references = function(body_node, 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 param_names = M.get_param_names(params_node, bufnr) + local re_declares = false + for _, p in ipairs(param_names) do + if p == 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) == 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 positions of fixture consumer parameters, body references, and +--- @pytest.mark.usefixtures string arguments in a buffer. +---@param bufnr integer +---@param fixture_name string +---@return table[] positions Array of {row, col_start, col_end} +M.find_fixture_references = function(bufnr, fixture_name) + local ts_utils = require("pytrize.ts") + + 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 = M.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 + local body_refs = M.find_body_references(body_node, fixture_name, bufnr) + for _, pos in ipairs(body_refs) do + table.insert(positions, pos) + end + end + end + end + end + + -- Case B: @pytest.mark.usefixtures("fixture_name") string arguments + if node_type == "call" then + local func = node:field("function")[1] + if func and func:type() == "attribute" then + local func_text = ts.get_node_text(func, bufnr) + if func_text == "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 + +--- Grep for Python files containing `name` under `root_dir`. +---@param root_dir string +---@param name string +---@return string[] +M.find_python_files = function(root_dir, name) + return vim.fn.systemlist( + string.format('grep -rl --include="*.py" "%s" "%s"', vim.fn.escape(name, '"\\'), root_dir) + ) +end + +return M diff --git a/lua/pytrize/usages.lua b/lua/pytrize/usages.lua index af1ca69..8d44c02 100644 --- a/lua/pytrize/usages.lua +++ b/lua/pytrize/usages.lua @@ -1,175 +1,17 @@ local M = {} -local ts = vim.treesitter local warn = require("pytrize.warn").warn local paths = require("pytrize.paths") -local ts_utils = require("pytrize.ts") +local ts_helpers = require("pytrize.ts_helpers") local utils = require("pytrize.utils") -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 py_files = ts_helpers.find_python_files(root_dir, fixture_name) local items = {} for _, filepath in ipairs(py_files) do utils.with_buf(filepath, function(bufnr) - local positions = find_usage_positions(bufnr, fixture_name) + local positions = ts_helpers.find_fixture_references(bufnr, fixture_name) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) for _, pos in ipairs(positions) do @@ -211,7 +53,7 @@ M.show_usages = function() end -- Internal exports for testing -M._find_usage_positions = find_usage_positions +M._find_fixture_references = ts_helpers.find_fixture_references M._find_all_usages = find_all_usages return M diff --git a/lua/pytrize/utils.lua b/lua/pytrize/utils.lua index 42a572d..95c6a87 100644 --- a/lua/pytrize/utils.lua +++ b/lua/pytrize/utils.lua @@ -1,50 +1,40 @@ local M = {} -M.min = function(a, b) - if a <= b then - return a - else - return b - end -end - -M.max = function(a, b) - if a >= b then - return a - else - return b - end -end - --- Temporarily load a Python buffer for `filepath`, call `fn(bufnr)`, then --- clean up if the buffer was not already loaded. Autocommands are suppressed --- during the load so that LSP clients (and other plugins) do not attach to --- ephemeral buffers. --- ---- @param filepath string Absolute path to a Python file. ---- @param fn fun(bufnr: integer): any Callback that receives the buffer number. ---- @param opts? { force_delete?: boolean } Options for cleanup (default: force_delete = false). ---- @return any The return value of `fn`. +---@param filepath string Absolute path to a Python file. +---@param fn fun(bufnr: integer): any Callback that receives the buffer number. +---@param opts? { force_delete?: boolean } Options for cleanup. +---@return any The return value of `fn`. M.with_buf = function(filepath, fn, opts) opts = opts or {} local existing_bufnr = vim.fn.bufnr(filepath) - local was_loaded = existing_bufnr ~= -1 and vim.fn.bufloaded(existing_bufnr) == 1 + local was_loaded = existing_bufnr ~= -1 and vim.api.nvim_buf_is_loaded(existing_bufnr) local bufnr = vim.fn.bufadd(filepath) if not was_loaded then local saved_eventignore = vim.o.eventignore vim.o.eventignore = "all" + -- vim.fn.execute() wraps :redir internally, suppressing the + -- C-level file-info message ("path" NL, NB) that bufload emits. vim.fn.execute("call bufload(" .. bufnr .. ")") - vim.api.nvim_set_option_value("filetype", "python", { buf = bufnr }) + vim.bo[bufnr].filetype = "python" vim.o.eventignore = saved_eventignore end - local result = fn(bufnr) + local ok, result = pcall(fn, bufnr) - if not was_loaded then + if not was_loaded and vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_delete(bufnr, { force = opts.force_delete or false }) end + if not ok then + error(result) + end + return result end diff --git a/lua/pytrize/warn.lua b/lua/pytrize/warn.lua index 14b09b1..d481f13 100644 --- a/lua/pytrize/warn.lua +++ b/lua/pytrize/warn.lua @@ -1,9 +1,7 @@ local M = {} M.warn = function(msg) - msg = vim.fn.escape(msg, '"'):gsub("\\n", "\n") - -- vim.cmd(string.format('echohl WarningMsg | echo "Pytrize Warning: %s" | echohl None', msg)) - vim.notify(vim.split(string.format("Pytrize Warning: %s", msg), "\n"), vim.log.levels.WARN) + vim.notify(string.format("Pytrize: %s", msg), vim.log.levels.WARN) end return M diff --git a/tests/pytrize/settings_spec.lua b/tests/pytrize/settings_spec.lua index ae6a168..10c7212 100644 --- a/tests/pytrize/settings_spec.lua +++ b/tests/pytrize/settings_spec.lua @@ -25,7 +25,6 @@ describe("settings", function() end) it("does not crash on unknown key", function() - -- Stub vim.notify to avoid errors in headless mode local original_notify = vim.notify local notified = false vim.notify = function() notified = true end @@ -33,4 +32,19 @@ describe("settings", function() vim.notify = original_notify assert.is_true(notified) end) + + it("rejects wrong type for a setting", function() + local original_notify = vim.notify + local warned = false + vim.notify = function(msg) + if type(msg) == "string" and msg:find("invalid type") then + warned = true + end + end + settings_mod.update({ highlight = 123 }) -- should be string, not number + vim.notify = original_notify + assert.is_true(warned) + -- value should not have changed + assert.are.equal("LineNr", settings_mod.settings.highlight) + end) end) diff --git a/tests/pytrize/usages_spec.lua b/tests/pytrize/usages_spec.lua index 7764696..9cc36c5 100644 --- a/tests/pytrize/usages_spec.lua +++ b/tests/pytrize/usages_spec.lua @@ -28,7 +28,7 @@ describe("find_usage_positions", function() "def test_foo(my_fixture):", " assert my_fixture == 42", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") -- param (row 0) + body ref (row 1) assert.are.equal(2, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) @@ -39,7 +39,7 @@ describe("find_usage_positions", function() "def test_foo(my_fixture: MagicMock):", " my_fixture.assert_called()", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") -- typed param (row 0) + body ref (row 1) assert.are.equal(2, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) @@ -53,7 +53,7 @@ describe("find_usage_positions", function() "def test_foo(self):", " pass", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") assert.are.equal(1, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) end) @@ -66,7 +66,7 @@ describe("find_usage_positions", function() "def my_fixture():", " return 42", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") assert.are.equal(0, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) end) @@ -76,7 +76,7 @@ describe("find_usage_positions", function() "def test_foo(db):", " x = db.my_fixture", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") assert.are.equal(0, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) end) @@ -86,7 +86,7 @@ describe("find_usage_positions", function() "def test_foo(db):", " call(my_fixture=1)", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(bufnr, "my_fixture") assert.are.equal(0, #positions) vim.api.nvim_buf_delete(bufnr, { force = true }) end) @@ -102,7 +102,7 @@ describe("find_usage_positions", function() "def test_uses(my_fixture):", " assert my_fixture", }) - local positions = usages._find_usage_positions(bufnr, "my_fixture") + local positions = usages._find_fixture_references(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 }) diff --git a/tests/pytrize/utils_spec.lua b/tests/pytrize/utils_spec.lua index de09afa..49785c4 100644 --- a/tests/pytrize/utils_spec.lua +++ b/tests/pytrize/utils_spec.lua @@ -1,31 +1,71 @@ local utils = require("pytrize.utils") -describe("min", function() - it("returns the smaller value", function() - assert.are.equal(1, utils.min(1, 2)) - assert.are.equal(1, utils.min(2, 1)) - end) - - it("returns the value when equal", function() - assert.are.equal(5, utils.min(5, 5)) - end) - - it("works with negative numbers", function() - assert.are.equal(-3, utils.min(-3, -1)) - end) -end) +describe("with_buf", function() + it("loads a file into a buffer, calls fn, and cleans up", function() + local tmp = vim.fn.tempname() .. ".py" + local f = io.open(tmp, "w") + f:write("x = 1\n") + f:close() + + local result = utils.with_buf(tmp, function(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + return lines[1] + end, { force_delete = true }) + + assert.are.equal("x = 1", result) + + -- buffer should have been cleaned up + assert.are.equal(-1, vim.fn.bufnr(tmp)) + vim.fn.delete(tmp) + end) + + it("suppresses file-info messages by loading via vim.fn.execute", function() + local tmp = vim.fn.tempname() .. ".py" + local f = io.open(tmp, "w") + f:write("x = 1\n") + f:close() + + -- The C-level file-info message ("path" NL, NB) that bufload() + -- emits does not appear in :messages or headless output, so we + -- cannot assert on captured text. Instead we verify the + -- suppression mechanism: with_buf must route through + -- vim.fn.execute() which uses :redir to swallow all output. + local original_execute = vim.fn.execute + local execute_calls = {} + vim.fn.execute = function(cmd) + table.insert(execute_calls, cmd) + return original_execute(cmd) + end + + utils.with_buf(tmp, function(_) end, { force_delete = true }) + + vim.fn.execute = original_execute + + -- At least one call should be the bufload wrapper + local found = false + for _, cmd in ipairs(execute_calls) do + if type(cmd) == "string" and cmd:find("bufload") then + found = true + break + end + end + assert.is_true(found, "with_buf should call vim.fn.execute() with bufload to suppress file-info messages") + + vim.fn.delete(tmp) + end) -describe("max", function() - it("returns the larger value", function() - assert.are.equal(2, utils.max(1, 2)) - assert.are.equal(2, utils.max(2, 1)) - end) + it("propagates errors from fn", function() + local tmp = vim.fn.tempname() .. ".py" + local f = io.open(tmp, "w") + f:write("x = 1\n") + f:close() - it("returns the value when equal", function() - assert.are.equal(5, utils.max(5, 5)) - end) + assert.has_error(function() + utils.with_buf(tmp, function(_) + error("test error") + end, { force_delete = true }) + end) - it("works with negative numbers", function() - assert.are.equal(-1, utils.max(-3, -1)) - end) + vim.fn.delete(tmp) + end) end)