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
This commit is contained in:
swaits committed 2026-04-12 19:42:54 -06:00
1 parent 7a21d372ff
commit 85ff3b1688
7 files changed
+414 -164

No files matched your search

+12 -21
View File
@@ -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
+12 -5
View File
@@ -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 })
+142 -138
View File
@@ -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", "<LeftMouse>", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true })
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { 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
+12
View File
@@ -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
+49
View File
@@ -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")
+40
View File
@@ -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")
+147
View File
@@ -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")