From 375584629e61ee43617da874561db35a828209f5 Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Mon, 9 Feb 2026 13:42:17 -0500 Subject: [PATCH] feat(neotest): add notifications, smart test discovery, fix DAP integration, and improve keymaps - Add notify function for user feedback on all test operations - Make tn smart: runs nearest in test files, finds matching test file from source - Add same smart logic to td (debug) and tw (watch) - Fix ts to auto-focus summary window for Enter navigation - Add summary keymaps for to expand/jump to tests - Add nvim-dap as dependency to fix debug strategy - Fix DAP UI to not auto-close and not enter insert mode - Fix watch to load test file buffer for LSP attachment - Make python path detection use VIRTUAL_ENV env var first - Fix neotest-python adapter configuration --- lua/custom/plugins/dap.lua | 31 +++++- lua/custom/plugins/neotest.lua | 178 +++++++++++++++++++++++++++++---- 2 files changed, 188 insertions(+), 21 deletions(-) diff --git a/lua/custom/plugins/dap.lua b/lua/custom/plugins/dap.lua index da10649..cf41bbe 100755 --- a/lua/custom/plugins/dap.lua +++ b/lua/custom/plugins/dap.lua @@ -24,7 +24,30 @@ return { local dap = require('dap') local dapui = require('dapui') - dapui.setup() + dapui.setup({ + -- Prevent entering insert mode when UI opens + enter = false, + layouts = { + { + elements = { + { id = 'scopes', size = 0.25 }, + { id = 'breakpoints', size = 0.25 }, + { id = 'stacks', size = 0.25 }, + { id = 'watches', size = 0.25 }, + }, + size = 40, + position = 'left', + }, + { + elements = { + { id = 'repl', size = 0.5 }, + { id = 'console', size = 0.5 }, + }, + size = 10, + position = 'bottom', + }, + }, + }) -- Mason handles debugger installation and configuration require('mason-nvim-dap').setup({ @@ -118,10 +141,10 @@ return { }, } - -- Auto-open/close UI + -- Auto-open UI when debugging starts dap.listeners.after.event_initialized['dapui_config'] = dapui.open - dap.listeners.before.event_terminated['dapui_config'] = dapui.close - dap.listeners.before.event_exited['dapui_config'] = dapui.close + -- Don't auto-close UI - let user close it manually with du + -- This prevents UI disappearing when running multiple debug sessions -- Breakpoint signs vim.fn.sign_define('DapBreakpoint', { text = '🔴', texthl = '', linehl = '', numhl = '' }) diff --git a/lua/custom/plugins/neotest.lua b/lua/custom/plugins/neotest.lua index 3b31aef..ce38762 100644 --- a/lua/custom/plugins/neotest.lua +++ b/lua/custom/plugins/neotest.lua @@ -8,6 +8,7 @@ return { 'nvim-neotest/neotest-python', 'alfaix/neotest-gtest', 'andythigpen/nvim-coverage', + 'mfussenegger/nvim-dap', -- Required for debug strategy }, event = 'VeryLazy', config = function() @@ -17,17 +18,47 @@ return { adapters = { require('neotest-python')({ runner = 'pytest', - dap = { justMyCode = false }, args = { '-v', '--tb=short' }, - -- Discover tests in tests/ directory - pytest_discover_instances = true, - }), - require('neotest-gtest').setup({ - debug_adapter = 'codelldb', + python = function() + -- Check if VIRTUAL_ENV is set (activated venv) + local venv = os.getenv('VIRTUAL_ENV') + if venv then + return venv .. '/bin/python' + end + -- Try common venv names + local venv_paths = { + '.venv/bin/python', + 'venv/bin/python', + '.env/bin/python', + 'env/bin/python', + } + for _, path in ipairs(venv_paths) do + if vim.fn.filereadable(path) == 1 then + return path + end + end + -- Fallback to system python + return vim.fn.exepath('python3') or vim.fn.exepath('python') or 'python' + end, }), + require('neotest-gtest'), }, output = { open_on_run = false }, - summary = { open = 'botright vsplit | vertical resize 60' }, + summary = { + open = 'botright vsplit | vertical resize 60', + mappings = { + expand = { '', 'l' }, + expand_all = 'E', + output = 'o', + short = 'O', + run = 'r', + debug = 'd', + mark = 'm', + run_marked = 'R', + clear_marked = 'M', + target = 't', + }, + }, }) local ok_cov, coverage = pcall(require, 'coverage') @@ -38,44 +69,157 @@ return { end -- Keymaps (defined after setup to ensure modules are available) + local function notify(msg, level) + local ok, notify_mod = pcall(require, 'notify') + if ok then + notify_mod(msg, level or vim.log.levels.INFO) + else + vim.notify(msg, level or vim.log.levels.INFO) + end + end + vim.keymap.set('n', 'tn', function() - neotest.run.run() - end, { desc = 'Test: Run nearest' }) + local file = vim.fn.expand('%:p') + local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$') + + if is_test then + neotest.run.run() + notify('Running nearest test') + else + -- Try to find corresponding test file + local basename = vim.fn.expand('%:t:r') + local test_patterns = { + 'tests/test_' .. basename .. '.py', + 'tests/' .. basename .. '_test.py', + 'test_' .. basename .. '.py', + basename .. '_test.py', + } + + for _, pattern in ipairs(test_patterns) do + if vim.fn.filereadable(pattern) == 1 then + neotest.run.run(pattern) + notify('Running test file: ' .. pattern) + return + end + end + + notify('No test file found for ' .. basename, vim.log.levels.WARN) + end + end, { desc = 'Test: Run nearest or matching test file' }) vim.keymap.set('n', 'tf', function() neotest.run.run(vim.fn.expand('%')) + notify('Running file tests: ' .. vim.fn.expand('%:t'), vim.log.levels.INFO) end, { desc = 'Test: Run file' }) vim.keymap.set('n', 'ta', function() neotest.run.run(vim.fn.getcwd()) + notify('Running all tests in ' .. vim.fn.getcwd(), vim.log.levels.INFO) end, { desc = 'Test: Run all (cwd)' }) vim.keymap.set('n', 'td', function() - neotest.run.run({ strategy = 'dap' }) - end, { desc = 'Test: Debug nearest' }) + local file = vim.fn.expand('%:p') + local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$') + + if is_test then + neotest.run.run({ strategy = 'dap' }) + notify('Debugging nearest test') + else + -- Try to find corresponding test file + local basename = vim.fn.expand('%:t:r') + local test_patterns = { + 'tests/test_' .. basename .. '.py', + 'tests/' .. basename .. '_test.py', + 'test_' .. basename .. '.py', + basename .. '_test.py', + } + + for _, pattern in ipairs(test_patterns) do + if vim.fn.filereadable(pattern) == 1 then + neotest.run.run({ pattern, strategy = 'dap' }) + notify('Debugging test file: ' .. pattern) + return + end + end + + notify('No test file found for ' .. basename, vim.log.levels.WARN) + end + end, { desc = 'Test: Debug nearest or matching test file' }) vim.keymap.set('n', 'tx', function() neotest.run.stop() + notify('Stopped test run', vim.log.levels.WARN) end, { desc = 'Test: Stop' }) vim.keymap.set('n', 'ts', function() neotest.summary.toggle() - end, { desc = 'Test: Toggle summary' }) + -- Focus the summary window so Enter/click works + vim.defer_fn(function() + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + if name:match('Neotest Summary') then + vim.api.nvim_set_current_win(win) + break + end + end + end, 50) + end, { desc = 'Test: Toggle and focus summary' }) vim.keymap.set('n', 'to', function() - neotest.output.open({ enter = true, auto_close = false }) + neotest.output.open({ auto_close = false }) end, { desc = 'Test: Show output' }) vim.keymap.set('n', 'tw', function() - neotest.watch.toggle(vim.fn.expand('%')) - end, { desc = 'Test: Watch file' }) + local file = vim.fn.expand('%') + local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$') + + local target_file + if is_test then + target_file = file + else + -- Find the matching test file + local basename = vim.fn.expand('%:t:r') + local test_patterns = { + 'tests/test_' .. basename .. '.py', + 'tests/' .. basename .. '_test.py', + 'test_' .. basename .. '.py', + basename .. '_test.py', + } + for _, pattern in ipairs(test_patterns) do + if vim.fn.filereadable(pattern) == 1 then + target_file = pattern + break + end + end + end + + if not target_file then + notify('No test file found', vim.log.levels.WARN) + return + end + + -- Open the test file in background so LSP attaches, then start watching + local buf = vim.fn.bufadd(target_file) + vim.fn.bufload(buf) + vim.defer_fn(function() + neotest.watch.toggle(target_file) + notify('Watching: ' .. target_file) + end, 100) + end, { desc = 'Test: Watch file or matching test' }) vim.keymap.set('n', ']t', function() - neotest.jump.next({ status = 'failed' }) + local ok, _ = pcall(neotest.jump.next, { status = 'failed' }) + if not ok then + notify('No tests found - run ta to discover tests', vim.log.levels.WARN) + end end, { desc = 'Test: Next failed' }) vim.keymap.set('n', '[t', function() - neotest.jump.prev({ status = 'failed' }) + local ok, _ = pcall(neotest.jump.prev, { status = 'failed' }) + if not ok then + notify('No tests found - run ta to discover tests', vim.log.levels.WARN) + end end, { desc = 'Test: Prev failed' }) vim.keymap.set('n', 'tC', function()