From 85ff3b1688ac872078055aa82393431e42b68f58 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 12 Apr 2026 19:30:38 -0600 Subject: [PATCH] refactor: extract helpers, dedup patterns, add regression tests Mechanical cleanup pass, behavior-preserving: - remove dead code: reposition_bars (unused), sbar click infrastructure (zones never populated, handler iterated empty list) - extract helpers in mode.lua: win_ok, buf_ok, is_dapui_pane_ft, for_each_dapui_pane (asymmetric filter preserved), safe_extmark, bar_set_lines, safe_set_height, if_still_active, invalidate_dead_bars, focus_source_win, unset_and_restore - inline install_cs_listener inside define_highlights to dedup the three ColorScheme augroup+autocmd triples (italic, key, sep) - data-drive sign_define in breakpoints.lua - data-drive python interpreter candidate probing - add tests/test_render_bars.lua pinning sbar/cbar output dimensions, zones, and overflow fallback behavior - add tests/test_highlights_cs.lua verifying ColorScheme augroups and highlight group existence / re-apply on event - add tests/test_helpers.lua unit-testing keyof and source_window - expose M._test (direct function refs) so render/highlight internals are testable in headless mode --- lua/turbo-debug/adapters/python.lua | 33 ++-- lua/turbo-debug/breakpoints.lua | 17 +- lua/turbo-debug/mode.lua | 280 ++++++++++++++-------------- tests/helpers.lua | 12 ++ tests/test_helpers.lua | 49 +++++ tests/test_highlights_cs.lua | 40 ++++ tests/test_render_bars.lua | 147 +++++++++++++++ 7 files changed, 414 insertions(+), 164 deletions(-) create mode 100644 tests/test_helpers.lua create mode 100644 tests/test_highlights_cs.lua create mode 100644 tests/test_render_bars.lua diff --git a/lua/turbo-debug/adapters/python.lua b/lua/turbo-debug/adapters/python.lua index 680d586..61f1531 100644 --- a/lua/turbo-debug/adapters/python.lua +++ b/lua/turbo-debug/adapters/python.lua @@ -2,31 +2,22 @@ local loader = require("turbo-debug.adapters.init") local M = {} +-- Try in order: active virtualenv, conda, common local venv dirs, system. +-- First executable match wins. Using a single ordered list keeps the probe +-- sequence explicit (ipairs preserves numeric order). local function find_python() - -- check active virtualenv - local venv = vim.env.VIRTUAL_ENV - if venv then - local path = venv .. "/bin/python" - if vim.fn.executable(path) == 1 then return path end - end - - -- check conda - local conda = vim.env.CONDA_PREFIX - if conda then - local path = conda .. "/bin/python" - if vim.fn.executable(path) == 1 then return path end - end - - -- probe common venv directories relative to cwd + local candidates = {} + if vim.env.VIRTUAL_ENV then candidates[#candidates + 1] = vim.env.VIRTUAL_ENV .. "/bin/python" end + if vim.env.CONDA_PREFIX then candidates[#candidates + 1] = vim.env.CONDA_PREFIX .. "/bin/python" end local cwd = vim.fn.getcwd() for _, dir in ipairs({ ".venv", "venv", "env", ".env" }) do - local path = cwd .. "/" .. dir .. "/bin/python" - if vim.fn.executable(path) == 1 then return path end + candidates[#candidates + 1] = cwd .. "/" .. dir .. "/bin/python" + end + candidates[#candidates + 1] = "python3" + candidates[#candidates + 1] = "python" + for _, p in ipairs(candidates) do + if vim.fn.executable(p) == 1 then return p end end - - -- fallback to system python - if vim.fn.executable("python3") == 1 then return "python3" end - if vim.fn.executable("python") == 1 then return "python" end return nil end diff --git a/lua/turbo-debug/breakpoints.lua b/lua/turbo-debug/breakpoints.lua index 17cc7fc..5d73776 100644 --- a/lua/turbo-debug/breakpoints.lua +++ b/lua/turbo-debug/breakpoints.lua @@ -42,11 +42,18 @@ function M.setup() -- ⭕ U+2B55 heavy large circle = rejected breakpoint -- 👉 U+1F449 backhand pointing right = current execution line (IP) -- 📝 U+1F4DD memo = log point - vim.fn.sign_define("DapBreakpoint", { text = "\xf0\x9f\x94\xb4", texthl = "DapBreakpoint", numhl = "DapBreakpoint" }) - vim.fn.sign_define("DapBreakpointCondition", { text = "\xf0\x9f\x9f\xa1", texthl = "DapBreakpointCondition", numhl = "DapBreakpointCondition" }) - vim.fn.sign_define("DapBreakpointRejected", { text = "\xe2\xad\x95", texthl = "DapBreakpointRejected", numhl = "DapBreakpointRejected" }) - vim.fn.sign_define("DapStopped", { text = "\xf0\x9f\x91\x89", texthl = "DapStopped", linehl = "DapStoppedLine", numhl = "DapStopped" }) - vim.fn.sign_define("DapLogPoint", { text = "\xf0\x9f\x93\x9d", texthl = "DapLogPoint", numhl = "DapLogPoint" }) + local signs = { + { "DapBreakpoint", "\xf0\x9f\x94\xb4" }, + { "DapBreakpointCondition", "\xf0\x9f\x9f\xa1" }, + { "DapBreakpointRejected", "\xe2\xad\x95" }, + { "DapStopped", "\xf0\x9f\x91\x89", { linehl = "DapStoppedLine" } }, + { "DapLogPoint", "\xf0\x9f\x93\x9d" }, + } + for _, s in ipairs(signs) do + local spec = { text = s[2], texthl = s[1], numhl = s[1] } + if s[3] then for k, v in pairs(s[3]) do spec[k] = v end end + vim.fn.sign_define(s[1], spec) + end -- unobtrusive inline virtual text: italic, comment-colored vim.api.nvim_set_hl(0, "NvimDapVirtualText", { default = true, link = "Comment", italic = true }) diff --git a/lua/turbo-debug/mode.lua b/lua/turbo-debug/mode.lua index 84dbb2f..efe8247 100644 --- a/lua/turbo-debug/mode.lua +++ b/lua/turbo-debug/mode.lua @@ -194,11 +194,14 @@ local function define_highlights() fg = src.fg, italic = true, default = true, }) end - apply_italic_hl() - vim.api.nvim_create_autocmd("ColorScheme", { - group = vim.api.nvim_create_augroup("TurboDebugBarItalicHL", { clear = true }), - callback = apply_italic_hl, - }) + local function install_cs_listener(name, fn) + fn() + vim.api.nvim_create_autocmd("ColorScheme", { + group = vim.api.nvim_create_augroup(name, { clear = true }), + callback = fn, + }) + end + install_cs_listener("TurboDebugBarItalicHL", apply_italic_hl) -- The key letter inside (c)ontinue etc. gets bold + Special's fg. -- No underline — the descender on lowercase q/g/p/y overlaps the @@ -215,11 +218,7 @@ local function define_highlights() fg = src.fg, bg = src.bg, bold = true, default = true, }) end - apply_key_hl() - vim.api.nvim_create_autocmd("ColorScheme", { - group = vim.api.nvim_create_augroup("TurboDebugBarKeyHL", { clear = true }), - callback = apply_key_hl, - }) + install_cs_listener("TurboDebugBarKeyHL", apply_key_hl) -- Separator color: derive from WinBar.fg at runtime. Every theme -- stylistically renders WinBar (pane-title chrome) in a color @@ -249,11 +248,7 @@ local function define_highlights() end vim.api.nvim_set_hl(0, "TurboDebugBarSeparator", { link = "WinSeparator" }) end - apply_sep_hl() - vim.api.nvim_create_autocmd("ColorScheme", { - group = vim.api.nvim_create_augroup("TurboDebugBarSepHL", { clear = true }), - callback = apply_sep_hl, - }) + install_cs_listener("TurboDebugBarSepHL", apply_sep_hl) end -- winhighlight strings applied to dapui panes. `WinBar:WinBar,WinBarNC:WinBar` @@ -296,8 +291,57 @@ end local bar_ns = vim.api.nvim_create_namespace("turbo-debug.bar") --- click zones, one table per bar. Each entry: { line_1based, dcol_start, dcol_end, fn } -local sbar_zones = {} +-- ─── small helpers ────────────────────────────────────────────────────────── +local function win_ok(w) return w and vim.api.nvim_win_is_valid(w) end +local function buf_ok(b) return b and vim.api.nvim_buf_is_valid(b) end + +-- Exact dapui pane filetypes. Different from the pattern `dapui_*` used in +-- setup_active_win_highlights / the FileType autocmd, and different from +-- schedule_console_redraw's `dapui_console` single-check — don't unify. +local DAPUI_PANE_FTS = { + dapui_stacks = true, dapui_scopes = true, dapui_watches = true, + dapui_breakpoints = true, dapui_console = true, ["dap-repl"] = true, +} +local function is_dapui_pane_ft(ft) return ft ~= nil and DAPUI_PANE_FTS[ft] == true end + +-- INTENTIONAL ASYMMETRY: callbacks do their own per-window filtering. +-- compute_dapui_total_height filters floats (relative==""); pin_dapui_sizes +-- does not. Don't hoist the filter into this iterator. +local function for_each_dapui_pane(cb) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(buf) then + local ft = vim.bo[buf].filetype + if is_dapui_pane_ft(ft) then cb(buf, ft) end + end + end +end + +-- Bar extmarks go through bar_ns + pcall. IP marker uses ip_ns separately. +local function safe_extmark(buf, row, col, opts) + pcall(vim.api.nvim_buf_set_extmark, buf, bar_ns, row, col, opts) +end + +local function bar_set_lines(buf, lines) + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false +end + +local function safe_set_height(w, h) pcall(vim.api.nvim_win_set_height, w, h) end + +-- Defer fn via vim.schedule; re-check `active` at fire time so the body +-- doesn't run after M.exit cleared state. +local function if_still_active(fn) + vim.schedule(function() if active then fn() end end) +end + +local function invalidate_dead_bars() + if not win_ok(sbar_win) then sbar_win, sbar_buf = nil, nil end + if not win_ok(cbar_win) then cbar_win, cbar_buf = nil, nil end +end + +-- click zones for the control bar. Each entry: { line_1based, dcol_start, dcol_end, fn } +-- Status bar has no clickable zones (control bar does), so no sbar_zones. local cbar_zones = {} local function dap_state() @@ -372,8 +416,8 @@ local function build_control_text(icon, actual_key, label, italic) end local function render_sbar() - if not (sbar_buf and vim.api.nvim_buf_is_valid(sbar_buf)) then return end - if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then return end + if not (buf_ok(sbar_buf)) then return end + if not (win_ok(sbar_win)) then return end local width = vim.api.nvim_win_get_width(sbar_win) if width < 1 then width = vim.o.columns end @@ -411,35 +455,31 @@ local function render_sbar() -- separator; the inward edge abuts dapui's top pane (whose winbar and -- nvim's own horiz separator serve as the visual boundary there). Keeps -- the bar's vertical footprint minimal. - vim.bo[sbar_buf].modifiable = true - vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { sep, content_line }) - vim.bo[sbar_buf].modifiable = false + bar_set_lines(sbar_buf, { sep, content_line }) vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1) - pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, { + safe_extmark(sbar_buf, 0, 0, { end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) -- content row - pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, { + safe_extmark(sbar_buf, 1, 0, { end_row = 1, end_col = #brand_text, hl_group = "TurboDebugBarBrand", }) if status_dw > 0 then local status_byte_start = #brand_text + pad_left - pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, status_byte_start, { + safe_extmark(sbar_buf, 1, status_byte_start, { end_row = 1, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus", }) end local chip_byte_start = #content_line - #state_chip - pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, chip_byte_start, { + safe_extmark(sbar_buf, 1, chip_byte_start, { end_row = 1, end_col = #content_line, hl_group = state_hl, }) - - sbar_zones = {} end local function render_cbar() - if not (cbar_buf and vim.api.nvim_buf_is_valid(cbar_buf)) then return end - if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then return end + if not (buf_ok(cbar_buf)) then return end + if not (win_ok(cbar_win)) then return end local width = vim.api.nvim_win_get_width(cbar_win) if width < 1 then width = vim.o.columns end @@ -526,35 +566,33 @@ local function render_cbar() -- 2 rows: content / bottom sep. Only the outward (bottom) edge gets a -- bright separator; the inward edge abuts dapui Console (whose winbar -- + nvim's own horiz separator serve as the visual boundary there). - vim.bo[cbar_buf].modifiable = true - vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { content_line, sep }) - vim.bo[cbar_buf].modifiable = false + bar_set_lines(cbar_buf, { content_line, sep }) vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1) - pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, 0, { + safe_extmark(cbar_buf, 1, 0, { end_row = 1, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) -- control labels on row 0 (content row) local ctrl_byte_offset = ctrl_pad_left for _, span in ipairs(ctrl_spans) do - pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], { + safe_extmark(cbar_buf, 0, ctrl_byte_offset + span[1], { end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = span[3], }) end for _, span in ipairs(ctrl_key_spans) do - pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], { + safe_extmark(cbar_buf, 0, ctrl_byte_offset + span[1], { end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarKey", }) end for _, span in ipairs(ctrl_italic_spans) do - pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], { + safe_extmark(cbar_buf, 0, ctrl_byte_offset + span[1], { end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarItalic", }) end local help_byte_start = #content_line - #help_text - pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, help_byte_start, { + safe_extmark(cbar_buf, 0, help_byte_start, { end_row = 0, end_col = #content_line, hl_group = "TurboDebugBarCtrl", }) @@ -581,17 +619,6 @@ end -- The bars are SPLITS, not floats. Splits consume real layout rows so -- dapui and source windows tuck underneath them naturally — no overlap. -local function handle_sbar_click() - local pos = vim.fn.getmousepos() - if not pos or pos.winid ~= sbar_win then return end - for _, zone in ipairs(sbar_zones) do - if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then - zone[4]() - return - end - end -end - local function handle_cbar_click() local pos = vim.fn.getmousepos() if not pos or pos.winid ~= cbar_win then return end @@ -661,7 +688,7 @@ local function open_bars() local caller_win = vim.api.nvim_get_current_win() -- status bar (top) - if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then + if not (win_ok(sbar_win)) then vim.cmd("noautocmd keepalt topleft split") sbar_win = vim.api.nvim_get_current_win() sbar_buf = vim.api.nvim_create_buf(false, true) @@ -670,8 +697,6 @@ local function open_bars() setup_bar_buf(sbar_buf) setup_bar_win(sbar_win) vim.wo[sbar_win].winfixheight = true - vim.keymap.set("n", "", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true }) - vim.keymap.set("n", "", "", { buffer = sbar_buf, silent = true, nowait = true }) bounce_out(sbar_buf) end @@ -680,7 +705,7 @@ local function open_bars() end -- control bar (bottom) - if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then + if not (win_ok(cbar_win)) then vim.cmd("noautocmd keepalt botright split") cbar_win = vim.api.nvim_get_current_win() cbar_buf = vim.api.nvim_create_buf(false, true) @@ -708,24 +733,18 @@ end -- fight subsequent WinResized events. Initial sizing only. local function pin_dapui_sizes() local pane_wins = {} - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) then - local ft = vim.bo[buf].filetype - if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches" - or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then - local wins = vim.fn.win_findbuf(buf) - if wins and wins[1] and vim.api.nvim_win_is_valid(wins[1]) then - pane_wins[ft] = wins[1] - end - end + for_each_dapui_pane(function(buf, ft) + local wins = vim.fn.win_findbuf(buf) + if wins and win_ok(wins[1]) then + pane_wins[ft] = wins[1] end - end + end) -- Console to configured height if pane_wins.dapui_console then local target = config.opts.console_height or math.max(8, math.floor(vim.o.lines * 0.2)) - pcall(vim.api.nvim_win_set_height, pane_wins.dapui_console, target) + safe_set_height(pane_wins.dapui_console, target) end -- Sidebar panes distributed equally (residual to Breakpoints) @@ -744,20 +763,16 @@ local function pin_dapui_sizes() local residual = total - (each * #present) for i, w in ipairs(present) do local h = each + (i == #present and residual or 0) - pcall(vim.api.nvim_win_set_height, w, h) + safe_set_height(w, h) end end end local function close_bars() - if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then - pcall(vim.api.nvim_win_close, sbar_win, true) - end - if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then - pcall(vim.api.nvim_win_close, cbar_win, true) - end + if win_ok(sbar_win) then pcall(vim.api.nvim_win_close, sbar_win, true) end + if win_ok(cbar_win) then pcall(vim.api.nvim_win_close, cbar_win, true) end sbar_win, sbar_buf, cbar_win, cbar_buf = nil, nil, nil, nil - sbar_zones, cbar_zones = {}, {} + cbar_zones = {} end -- `winfixheight` is a preference, not a guarantee. Under grid pressure @@ -775,15 +790,11 @@ end -- subsequent pin_dapui_sizes() call redistributes it across the dapui -- panes according to our initial proportions. local function ensure_bar_heights() - if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then - if vim.api.nvim_win_get_height(sbar_win) ~= 2 then - pcall(vim.api.nvim_win_set_height, sbar_win, 2) - end + if win_ok(sbar_win) and vim.api.nvim_win_get_height(sbar_win) ~= 2 then + safe_set_height(sbar_win, 2) end - if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then - if vim.api.nvim_win_get_height(cbar_win) ~= 2 then - pcall(vim.api.nvim_win_set_height, cbar_win, 2) - end + if win_ok(cbar_win) and vim.api.nvim_win_get_height(cbar_win) ~= 2 then + safe_set_height(cbar_win, 2) end end @@ -793,31 +804,18 @@ end -- without changing it). local function compute_dapui_total_height() local total = 0 - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) then - local ft = vim.bo[buf].filetype - if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches" - or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then - for _, win in ipairs(vim.fn.win_findbuf(buf)) do - if vim.api.nvim_win_is_valid(win) - and vim.api.nvim_win_get_config(win).relative == "" then - total = total + vim.api.nvim_win_get_height(win) - end - end + for_each_dapui_pane(function(buf, _) + for _, win in ipairs(vim.fn.win_findbuf(buf)) do + -- DELIBERATE: filter floats HERE, not in for_each_dapui_pane — + -- pin_dapui_sizes does NOT want this filter. + if win_ok(win) and vim.api.nvim_win_get_config(win).relative == "" then + total = total + vim.api.nvim_win_get_height(win) end end - end + end) return total end -local function reposition_bars() - -- Just re-render bar content. We intentionally DON'T re-pin dapui - -- sizes here — the user may have manually resized panes, and - -- fighting their resize on every event is a worse UX than occasional - -- layout drift. - render_bars() -end - -- ─── dap-ui setup ──────────────────────────────────────────────────────────── local function set_dapui_window_opts(win) @@ -969,12 +967,7 @@ local function ensure_dapui() -- focus to a real source window BEFORE dap processes the jump, we -- guarantee new source files (stepped-into library/stdlib files) open -- in the intended source window and the chrome stays intact. - dap.listeners.before.event_stopped["turbo-debug-focus-source"] = function() - local w = source_window() - if w and w ~= vim.api.nvim_get_current_win() then - pcall(vim.api.nvim_set_current_win, w) - end - end + dap.listeners.before.event_stopped["turbo-debug-focus-source"] = focus_source_win dap.listeners.after.event_stopped["turbo-debug-ip"] = function(session, body) vim.defer_fn(function() @@ -1026,7 +1019,7 @@ local function ensure_dapui() local ok, dapui = pcall(require, "dapui") if not (ok and dapui.elements and dapui.elements.console) then return end local bok, buf = pcall(dapui.elements.console.buffer) - if not bok or not buf or not vim.api.nvim_buf_is_valid(buf) then return end + if not bok or not buf_ok(buf) then return end pcall(function() local was_mod = vim.bo[buf].modifiable vim.bo[buf].modifiable = true @@ -1137,17 +1130,19 @@ source_window = function() return nil end -local function run_in_source(fn) - -- We can't use nvim_win_call here: dap.continue() may open a vim.ui.select - -- picker asynchronously, and by the time the picker opens, nvim_win_call - -- has already returned focus to the original window — the picker opens - -- but nothing has focus on it, forcing the user to click in to interact. - -- Actually shift focus to the source window so the picker takes focus - -- properly. +-- Shift focus to a source window if one exists and we're not already there. +-- We can't use nvim_win_call: dap.continue() may open a vim.ui.select picker +-- asynchronously, and by the time the picker opens, nvim_win_call has +-- already returned focus — the picker ends up unfocused. Actually shift. +local function focus_source_win() local w = source_window() if w and w ~= vim.api.nvim_get_current_win() then pcall(vim.api.nvim_set_current_win, w) end +end + +local function run_in_source(fn) + focus_source_win() return fn() end @@ -1291,16 +1286,23 @@ function M._install_for_buf(buf) end end +-- Ordering is load-bearing: del our-n → del our-v → restore prev-n → +-- restore prev-v. Restore MUST come after del or the user's mapping is +-- clobbered by our subsequent del. +local function unset_and_restore(buf, name, entry) + pcall(vim.keymap.del, "n", entry.key, { buffer = buf }) + if visual_actions[name] then + pcall(vim.keymap.del, "v", entry.key, { buffer = buf }) + end + if entry.prev_n then restore_mapping(buf, "n", entry.prev_n) end + if entry.prev_v then restore_mapping(buf, "v", entry.prev_v) end +end + local function clear_all_modal_keys() for buf, entries in pairs(installed) do if vim.api.nvim_buf_is_valid(buf) then for name, entry in pairs(entries) do - pcall(vim.keymap.del, "n", entry.key, { buffer = buf }) - if visual_actions[name] then - pcall(vim.keymap.del, "v", entry.key, { buffer = buf }) - end - if entry.prev_n then restore_mapping(buf, "n", entry.prev_n) end - if entry.prev_v then restore_mapping(buf, "v", entry.prev_v) end + unset_and_restore(buf, name, entry) end end end @@ -1319,7 +1321,7 @@ local function set_debug_chrome() end local function clear_debug_chrome() - if active_win and vim.api.nvim_win_is_valid(active_win) then + if win_ok(active_win) then vim.wo[active_win].winbar = saved_winbar or "" end active_win = nil @@ -1355,14 +1357,8 @@ function M.enter() vim.api.nvim_create_autocmd("VimResized", { group = group, callback = function() - vim.schedule(function() - if not active then return end - if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then - sbar_win, sbar_buf = nil, nil - end - if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then - cbar_win, cbar_buf = nil, nil - end + if_still_active(function() + invalidate_dead_bars() open_bars() ensure_bar_heights() pin_dapui_sizes() @@ -1387,12 +1383,7 @@ function M.enter() callback = function() if not active then return end vim.schedule(function() - if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then - sbar_win, sbar_buf = nil, nil - end - if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then - cbar_win, cbar_buf = nil, nil - end + invalidate_dead_bars() if not (sbar_win and cbar_win) then open_bars() end @@ -1426,8 +1417,7 @@ function M.enter() pin_dapui_sizes() -- second install pass after dapui has created its buffers - vim.schedule(function() - if not active then return end + if_still_active(function() for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end end @@ -1480,4 +1470,18 @@ end function M.is_active() return active end +-- Test-only exports (unstable API). Direct refs, not copies. +M._test = { + render_sbar = render_sbar, + render_cbar = render_cbar, + keyof = keyof, + build_control_text = build_control_text, + define_highlights = define_highlights, + source_window = function() return source_window() end, + set_bars = function(sw, sb, cw, cb) sbar_win, sbar_buf, cbar_win, cbar_buf = sw, sb, cw, cb end, + get_bars = function() return sbar_win, sbar_buf, cbar_win, cbar_buf end, + get_sbar_zones = function() return {} end, + get_cbar_zones = function() return cbar_zones end, +} + return M diff --git a/tests/helpers.lua b/tests/helpers.lua index c7cb60f..04c4317 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -6,4 +6,16 @@ function M.assert_eq(a, b, msg) end end +function M.assert_true(v, msg) + if not v then + error(string.format("FAIL: %s — expected truthy, got %s", msg, vim.inspect(v))) + end +end + +function M.assert_false(v, msg) + if v then + error(string.format("FAIL: %s — expected falsy, got %s", msg, vim.inspect(v))) + end +end + return M diff --git a/tests/test_helpers.lua b/tests/test_helpers.lua new file mode 100644 index 0000000..c743057 --- /dev/null +++ b/tests/test_helpers.lua @@ -0,0 +1,49 @@ +-- Unit tests for the helpers introduced during cleanup refactor. +-- Exercises helpers indirectly via observable side effects on bar/pane state. + +local h = require("helpers") +local assert_eq = h.assert_eq +local assert_true = h.assert_true +local assert_false = h.assert_false + +local mode = require("turbo-debug.mode") +local config = require("turbo-debug.config") +config.merge({}) + +-- keyof --------------------------------------------------------------------- +-- Reset, then exercise the three return paths. +config.merge({}) +assert_eq(mode._test.keyof("continue", "c"), "c", "default key") +config.merge({ keys = { continue = "n" } }) +assert_eq(mode._test.keyof("continue", "c"), "n", "user override") +config.merge({ keys = { continue = false } }) +assert_eq(mode._test.keyof("continue", "c"), nil, "disabled returns nil") +config.merge({}) + +-- source_window ------------------------------------------------------------- +-- Skips bars/dapui-panes; returns a source window when one exists. +do + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].filetype = "lua" + vim.api.nvim_set_current_buf(buf) + local w = mode._test.source_window() + assert_true(w ~= nil, "source_window finds lua buffer") +end + +do + -- Switch current to a dapui-pane filetype; source_window should still + -- find another source window if one exists. + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].filetype = "dapui_scopes" + vim.cmd("split") + vim.api.nvim_set_current_buf(buf) + local w = mode._test.source_window() + if w then + local ft = vim.bo[vim.api.nvim_win_get_buf(w)].filetype + assert_false(ft:match("^dapui_"), "source_window skips dapui_* filetypes") + assert_true(ft ~= "dap-repl", "source_window skips dap-repl") + assert_true(ft ~= "TurboDebugBar", "source_window skips TurboDebugBar") + end +end + +print("ALL HELPER TESTS PASSED") diff --git a/tests/test_highlights_cs.lua b/tests/test_highlights_cs.lua new file mode 100644 index 0000000..f678e6d --- /dev/null +++ b/tests/test_highlights_cs.lua @@ -0,0 +1,40 @@ +local h = require("helpers") +local assert_eq = h.assert_eq +local assert_true = h.assert_true + +local mode = require("turbo-debug.mode") +local config = require("turbo-debug.config") +config.merge({}) + +-- Invoke define_highlights via the _test hook. +mode._test.define_highlights() + +-- The three ColorScheme augroups must exist with at least one autocmd each. +local function group_has_colorscheme_autocmd(group_name) + local ok, autocmds = pcall(vim.api.nvim_get_autocmds, { group = group_name, event = "ColorScheme" }) + return ok and autocmds and #autocmds >= 1 +end + +assert_true(group_has_colorscheme_autocmd("TurboDebugBarItalicHL"), "italic augroup has ColorScheme autocmd") +assert_true(group_has_colorscheme_autocmd("TurboDebugBarKeyHL"), "key augroup has ColorScheme autocmd") +assert_true(group_has_colorscheme_autocmd("TurboDebugBarSepHL"), "sep augroup has ColorScheme autocmd") + +-- The highlight groups must exist after define_highlights. +local function hl_exists(name) + local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name }) + return ok and hl ~= nil and next(hl) ~= nil +end + +assert_true(hl_exists("TurboDebugBarItalic"), "TurboDebugBarItalic exists") +assert_true(hl_exists("TurboDebugBarKey"), "TurboDebugBarKey exists") +assert_true(hl_exists("TurboDebugBarSeparator"), "TurboDebugBarSeparator exists") +assert_true(hl_exists("TurboDebugBar"), "TurboDebugBar exists") +assert_true(hl_exists("TurboDebugBarBrand"), "TurboDebugBarBrand exists") + +-- Fire ColorScheme event; the callbacks should re-apply highlights (idempotent). +vim.cmd("doautocmd ColorScheme") +assert_true(hl_exists("TurboDebugBarItalic"), "TurboDebugBarItalic still exists after ColorScheme") +assert_true(hl_exists("TurboDebugBarKey"), "TurboDebugBarKey still exists after ColorScheme") +assert_true(hl_exists("TurboDebugBarSeparator"), "TurboDebugBarSeparator still exists after ColorScheme") + +print("ALL HIGHLIGHT CS TESTS PASSED") diff --git a/tests/test_render_bars.lua b/tests/test_render_bars.lua new file mode 100644 index 0000000..6ea4940 --- /dev/null +++ b/tests/test_render_bars.lua @@ -0,0 +1,147 @@ +local h = require("helpers") +local assert_eq = h.assert_eq +local assert_true = h.assert_true +local assert_false = h.assert_false + +local mode = require("turbo-debug.mode") +local config = require("turbo-debug.config") + +-- Ensure config is initialized with defaults so keyof/render_cbar can read it +config.merge({}) + +-- Helpers -------------------------------------------------------------------- + +local function make_bar_win() + vim.cmd("noautocmd keepalt topleft split") + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(win, buf) + vim.api.nvim_win_set_height(win, 2) + return win, buf +end + +local function cleanup(win) + if win and vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_close, win, true) + end +end + +local function line(buf, i) + return vim.api.nvim_buf_get_lines(buf, i, i + 1, false)[1] or "" +end + +-- build_control_text ------------------------------------------------------- + +-- Inline form when key matches first letter case-insensitively. +local text, kcol, kend, icol, iend = mode._test.build_control_text(">", "s", "step", "over") +assert_true(text:find("%(s%)tep") ~= nil, "inline form: (s)tep present") +assert_true(kcol < kend, "inline form: key_col < key_end_col") +assert_true(icol ~= nil and iend ~= nil, "inline form: italic span present") +assert_true(text:sub(icol + 1, iend) == "over", "inline form: italic text is 'over'") + +-- Prefix form when key does NOT match first letter. +local text2, _, _, icol2 = mode._test.build_control_text(">", "n", "step", "over") +assert_true(text2:find("%(n%) step") ~= nil, "prefix form: (n) step present") +assert_true(icol2 ~= nil, "prefix form: italic still rendered") + +-- No italic when italic param is nil. +local text3, _, _, icol3 = mode._test.build_control_text(">", "c", "continue", nil) +assert_eq(icol3, nil, "no italic span when italic=nil") +assert_true(text3:find("%(c%)ontinue") ~= nil, "no italic: (c)ontinue rendered") + +-- keyof ---------------------------------------------------------------------- + +-- Save any user override then reset to defaults +config.merge({}) +assert_eq(mode._test.keyof("continue", "c"), "c", "keyof returns default when unset") + +-- Disabled action returns nil +config.merge({ keys = { continue = false } }) +assert_eq(mode._test.keyof("continue", "c"), nil, "keyof returns nil when key=false") + +-- User override +config.merge({ keys = { continue = "g" } }) +assert_eq(mode._test.keyof("continue", "c"), "g", "keyof returns user override") + +-- Empty string falls back to default +config.merge({ keys = { continue = "" } }) +assert_eq(mode._test.keyof("continue", "c"), "c", "keyof falls back on empty string") + +-- Reset config to defaults for subsequent render tests +config.merge({}) + +-- render_sbar ---------------------------------------------------------------- + +do + local win, buf = make_bar_win() + mode._test.set_bars(win, buf, nil, nil) + mode._test.render_sbar() + local count = vim.api.nvim_buf_line_count(buf) + assert_eq(count, 2, "sbar has exactly 2 lines") + local sep = line(buf, 0) + local content = line(buf, 1) + assert_true(#sep > 0, "sbar separator non-empty") + assert_true(content:find(" turbo%-debug ") ~= nil, "sbar contains brand text") + assert_true(content:find("DEBUG") ~= nil, "sbar contains DEBUG chip") + -- sbar has no click zones + local zones = mode._test.get_sbar_zones() + assert_eq(#zones, 0, "sbar has zero click zones") + mode._test.set_bars(nil, nil, nil, nil) + cleanup(win) +end + +-- render_cbar ---------------------------------------------------------------- + +do + local win, buf = make_bar_win() + vim.api.nvim_win_set_width(win, 120) + mode._test.set_bars(nil, nil, win, buf) + mode._test.render_cbar() + local count = vim.api.nvim_buf_line_count(buf) + assert_eq(count, 2, "cbar has exactly 2 lines") + local content = line(buf, 0) + local sep = line(buf, 1) + assert_true(#sep > 0, "cbar separator non-empty") + assert_true(content:find(" help ") ~= nil, "cbar contains 'help' text") + -- Click zones: at least 5 controls (continue, step_over, step_into, step_out, terminate) + 1 help + -- When no session, restart is hidden so zone count = 5 + 1 = 6. + local zones = mode._test.get_cbar_zones() + assert_true(#zones >= 6, "cbar has at least 6 click zones: " .. #zones) + mode._test.set_bars(nil, nil, nil, nil) + cleanup(win) +end + +-- overflow fallback: at narrow widths, italic qualifiers are dropped +do + local win, buf = make_bar_win() + vim.api.nvim_win_set_width(win, 25) + mode._test.set_bars(nil, nil, win, buf) + mode._test.render_cbar() + local content = line(buf, 0) + -- italic qualifiers are "over", "into", "out" — on overflow they're omitted. + -- At width 25 with 5+ controls, there's no way they all fit with italics. + assert_false(content:find(" over ") or content:find(" into ") or content:find(" out "), + "overflow: italic qualifiers absent at narrow width") + mode._test.set_bars(nil, nil, nil, nil) + cleanup(win) +end + +-- disabling step_over reduces zone count +do + local win, buf = make_bar_win() + vim.api.nvim_win_set_width(win, 120) + mode._test.set_bars(nil, nil, win, buf) + mode._test.render_cbar() + local default_count = #mode._test.get_cbar_zones() + + config.merge({ keys = { step_over = false } }) + mode._test.render_cbar() + local disabled_count = #mode._test.get_cbar_zones() + assert_eq(disabled_count, default_count - 1, "disabling step_over drops one zone") + + config.merge({}) -- reset + mode._test.set_bars(nil, nil, nil, nil) + cleanup(win) +end + +print("ALL RENDER BAR TESTS PASSED")