extend simple test listing to a list-detail-view window

This commit is contained in:
2025-12-27 22:33:46 +01:00
parent 519fc38769
commit 60960322ac
4 changed files with 1108 additions and 20 deletions

View File

@@ -15,11 +15,22 @@ local state = {
last_scope_exit_code = nil,
last_scope_failures = nil,
last_border_kind = "default",
last_test_outputs = {},
last_result_line_map = {},
last_raw_output = nil,
last_float = nil,
detail_buf = nil,
detail_win = nil,
detail_opening = false,
autocmds_set = false,
}
local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary")
local result_ns = vim.api.nvim_create_namespace("TestSamuraiResult")
local detail_ns = vim.api.nvim_create_namespace("TestSamuraiDetailAnsi")
local apply_border_kind
local close_container
local restore_listing_full
local function get_hl_fg(name)
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = true })
@@ -79,12 +90,33 @@ local function ensure_output_autocmds()
vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter" }, {
group = group,
callback = function()
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
local cur = vim.api.nvim_get_current_win()
if cur ~= state.last_win then
pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil
end
if state.detail_opening then
return
end
local cur = vim.api.nvim_get_current_win()
local keep = (state.last_win and vim.api.nvim_win_is_valid(state.last_win) and cur == state.last_win)
or (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) and cur == state.detail_win)
if not keep then
close_container()
end
end,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
callback = function(args)
local closed = tonumber(args.match)
if not closed then
return
end
if state.last_win and closed == state.last_win then
state.last_win = nil
close_container()
return
end
if state.detail_win and closed == state.detail_win then
state.detail_win = nil
restore_listing_full()
end
end,
})
@@ -111,6 +143,12 @@ function M.setup()
state.last_scope_exit_code = nil
state.last_scope_failures = nil
state.last_border_kind = "default"
state.last_test_outputs = {}
state.last_result_line_map = {}
state.last_raw_output = nil
state.last_float = nil
state.detail_opening = false
state.detail_opening = false
end
function M.reload_runners()
@@ -223,7 +261,7 @@ local function float_geometry()
return width, height, row, col
end
local function apply_border_kind(win, kind)
apply_border_kind = function(win, kind)
if not (win and vim.api.nvim_win_is_valid(win)) then
return
end
@@ -236,11 +274,46 @@ local function apply_border_kind(win, kind)
end
end
close_container = function()
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil
end
if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then
pcall(vim.api.nvim_win_close, state.detail_win, true)
state.detail_win = nil
end
end
restore_listing_full = function()
if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then
return
end
local width = state.last_float and state.last_float.width or math.floor(vim.o.columns * 0.8)
local height = state.last_float and state.last_float.height or math.floor(vim.o.lines * 0.8)
local row = state.last_float and state.last_float.row or math.floor((vim.o.lines - height) / 2)
local col = state.last_float and state.last_float.col or math.floor((vim.o.columns - width) / 2)
vim.api.nvim_win_set_config(state.last_win, {
relative = "editor",
width = width,
height = height,
row = row,
col = col,
style = "minimal",
border = "rounded",
})
apply_border_kind(state.last_win, state.last_border_kind)
end
local function create_output_win(initial_lines)
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil
end
if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then
pcall(vim.api.nvim_win_close, state.detail_win, true)
state.detail_win = nil
end
local buf = state.last_buf
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
@@ -253,6 +326,7 @@ local function create_output_win(initial_lines)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_lines or {})
local width, height, row, col = float_geometry()
state.last_float = { width = width, height = height, row = row, col = col }
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
@@ -265,12 +339,10 @@ local function create_output_win(initial_lines)
})
vim.keymap.set("n", "<esc><esc>", function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
if state.last_win == win then
state.last_win = nil
end
end
close_container()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<cr>", function()
M.open_test_output_at_cursor()
end, { buffer = buf, nowait = true, silent = true })
state.last_win = win
@@ -289,8 +361,13 @@ local function reopen_output_win()
vim.api.nvim_set_current_win(state.last_win)
return state.last_buf, state.last_win
end
if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then
pcall(vim.api.nvim_win_close, state.detail_win, true)
state.detail_win = nil
end
local width, height, row, col = float_geometry()
state.last_float = { width = width, height = height, row = row, col = col }
local win = vim.api.nvim_open_win(state.last_buf, true, {
relative = "editor",
@@ -303,12 +380,10 @@ local function reopen_output_win()
})
vim.keymap.set("n", "<esc><esc>", function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
if state.last_win == win then
state.last_win = nil
end
end
close_container()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<cr>", function()
M.open_test_output_at_cursor()
end, { buffer = state.last_buf, nowait = true, silent = true })
state.last_win = win
@@ -333,6 +408,398 @@ local function append_lines(buf, new_lines)
end
end
local function normalize_output_lines(lines)
if type(lines) ~= "table" then
return {}
end
local out = {}
for _, line in ipairs(lines) do
if line ~= nil then
table.insert(out, line)
end
end
return out
end
local function ansi_color_to_rgb(idx)
local basic = {
[0] = 0x000000,
[1] = 0x800000,
[2] = 0x008000,
[3] = 0x808000,
[4] = 0x000080,
[5] = 0x800080,
[6] = 0x008080,
[7] = 0xC0C0C0,
[8] = 0x808080,
[9] = 0xFF0000,
[10] = 0x00FF00,
[11] = 0xFFFF00,
[12] = 0x0000FF,
[13] = 0xFF00FF,
[14] = 0x00FFFF,
[15] = 0xFFFFFF,
}
if basic[idx] then
return basic[idx]
end
if idx >= 16 and idx <= 231 then
local v = idx - 16
local r = math.floor(v / 36)
local g = math.floor((v % 36) / 6)
local b = v % 6
local function comp(n)
if n == 0 then
return 0
end
return 55 + (n * 40)
end
return comp(r) * 0x10000 + comp(g) * 0x100 + comp(b)
end
if idx >= 232 and idx <= 255 then
local shade = 8 + (idx - 232) * 10
return shade * 0x10000 + shade * 0x100 + shade
end
return nil
end
local function build_ansi_hl(style, cache)
local fg = style.fg
local bg = style.bg
if style.reverse then
fg, bg = bg, fg
end
local key = table.concat({
tostring(fg or ""),
tostring(bg or ""),
style.bold and "b" or "",
style.italic and "i" or "",
style.underline and "u" or "",
}, "|")
if key == "||||" then
return nil
end
if cache[key] then
return cache[key]
end
local name = "TestSamuraiAnsi_" .. tostring(#cache + 1)
cache[key] = name
local opts = {}
if fg then
opts.fg = fg
end
if bg then
opts.bg = bg
end
if style.bold then
opts.bold = true
end
if style.italic then
opts.italic = true
end
if style.underline then
opts.underline = true
end
pcall(vim.api.nvim_set_hl, 0, name, opts)
return name
end
local function parse_ansi_lines(lines)
local clean_lines = {}
local highlights = {}
local style = {}
local cache = {}
local function apply_sgr(params)
if #params == 0 then
params = { 0 }
end
local i = 1
while i <= #params do
local p = params[i]
if p == 0 then
style = {}
elseif p == 1 then
style.bold = true
elseif p == 2 then
style.dim = true
elseif p == 3 then
style.italic = true
elseif p == 4 then
style.underline = true
elseif p == 7 then
style.reverse = true
elseif p == 22 then
style.bold = nil
style.dim = nil
elseif p == 23 then
style.italic = nil
elseif p == 24 then
style.underline = nil
elseif p == 27 then
style.reverse = nil
elseif p == 39 then
style.fg = nil
elseif p == 49 then
style.bg = nil
elseif p >= 30 and p <= 37 then
style.fg = ansi_color_to_rgb(p - 30)
elseif p >= 90 and p <= 97 then
style.fg = ansi_color_to_rgb(p - 90 + 8)
elseif p >= 40 and p <= 47 then
style.bg = ansi_color_to_rgb(p - 40)
elseif p >= 100 and p <= 107 then
style.bg = ansi_color_to_rgb(p - 100 + 8)
elseif p == 38 or p == 48 then
local is_fg = (p == 38)
local mode = params[i + 1]
if mode == 5 then
local idx = params[i + 2]
if idx then
local rgb = ansi_color_to_rgb(idx)
if is_fg then
style.fg = rgb
else
style.bg = rgb
end
end
i = i + 2
elseif mode == 2 then
local r = params[i + 2]
local g = params[i + 3]
local b = params[i + 4]
if r and g and b then
local rgb = r * 0x10000 + g * 0x100 + b
if is_fg then
style.fg = rgb
else
style.bg = rgb
end
end
i = i + 4
end
end
i = i + 1
end
end
for lnum, line in ipairs(lines or {}) do
local clean = {}
local hls = {}
local pos = 1
local col = 0
while true do
local s, e = line:find("\27%[[0-9;]*m", pos)
if not s then
local chunk = line:sub(pos)
if chunk ~= "" then
table.insert(clean, chunk)
local hl = build_ansi_hl(style, cache)
if hl then
table.insert(hls, { col, col + #chunk, hl })
end
col = col + #chunk
end
break
end
local chunk = line:sub(pos, s - 1)
if chunk ~= "" then
table.insert(clean, chunk)
local hl = build_ansi_hl(style, cache)
if hl then
table.insert(hls, { col, col + #chunk, hl })
end
col = col + #chunk
end
local params = {}
local seq = line:sub(s + 2, e - 1)
for part in seq:gmatch("[^;]+") do
local num = tonumber(part)
if num then
table.insert(params, num)
end
end
apply_sgr(params)
pos = e + 1
end
clean_lines[lnum] = table.concat(clean)
highlights[lnum] = hls
end
return clean_lines, highlights
end
local function apply_detail_highlights(buf, highlights)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
vim.api.nvim_buf_clear_namespace(buf, detail_ns, 0, -1)
for lnum, entries in ipairs(highlights or {}) do
for _, entry in ipairs(entries or {}) do
local start_col = entry[1]
local end_col = entry[2]
local hl = entry[3]
if hl then
vim.api.nvim_buf_add_highlight(buf, detail_ns, hl, lnum - 1, start_col, end_col)
end
end
end
end
local function parse_go_output_from_raw(output)
local out = {}
if not output or output == "" then
return out
end
for line in output:gmatch("[^\n]+") do
local ok, data = pcall(vim.json.decode, line)
if ok and type(data) == "table" and data.Action == "output" and data.Test and data.Output then
if not out[data.Test] then
out[data.Test] = {}
end
local lines = vim.split(data.Output, "\n", { plain = true })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines, #lines)
end
for _, item in ipairs(lines) do
table.insert(out[data.Test], item)
end
end
end
return out
end
local function ensure_detail_buf(lines)
local buf = state.detail_buf
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(buf, "bufhidden", "hide")
vim.api.nvim_buf_set_option(buf, "buftype", "nofile")
vim.api.nvim_buf_set_option(buf, "swapfile", false)
vim.api.nvim_buf_set_option(buf, "filetype", "test-samurai-output")
state.detail_buf = buf
vim.keymap.set("n", "<esc><esc>", function()
close_container()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<C-w>h", function()
M.focus_listing()
end, { buffer = buf, nowait = true, silent = true })
end
local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines))
vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines)
apply_detail_highlights(buf, highlights)
return buf
end
local function open_detail_split(lines)
local buf = ensure_detail_buf(lines)
local base = state.last_float or {}
local width = base.width or math.floor(vim.o.columns * 0.8)
local height = base.height or math.floor(vim.o.lines * 0.8)
local row = base.row or math.floor((vim.o.lines - height) / 2)
local col = base.col or math.floor((vim.o.columns - width) / 2)
local left_width = math.max(1, math.floor(width * 0.2))
local right_width = width - left_width
if right_width < 1 then
right_width = 1
left_width = math.max(1, width - right_width)
end
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
vim.api.nvim_win_set_config(state.last_win, {
relative = "editor",
width = left_width,
height = height,
row = row,
col = col,
style = "minimal",
border = "rounded",
})
end
local right_cfg = {
relative = "editor",
width = right_width,
height = height,
row = row,
col = col + left_width,
style = "minimal",
border = "rounded",
}
local right = state.detail_win
if right and vim.api.nvim_win_is_valid(right) then
vim.api.nvim_win_set_buf(right, buf)
vim.api.nvim_win_set_config(right, right_cfg)
else
state.detail_opening = true
right = vim.api.nvim_open_win(buf, true, right_cfg)
state.detail_win = right
state.detail_opening = false
end
vim.api.nvim_set_current_win(right)
end
function M.open_test_output_at_cursor()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local test_name = state.last_result_line_map[line]
if not test_name then
local text = vim.api.nvim_get_current_line()
test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$")
end
if not test_name then
vim.notify("[test-samurai] No test output for this line", vim.log.levels.WARN)
return
end
local function resolve_output(name)
local output = state.last_test_outputs[name]
if output then
return name, output
end
local matches = {}
for full, lines in pairs(state.last_test_outputs) do
if full == name or full:sub(-#name) == name then
table.insert(matches, { name = full, lines = lines })
end
end
if #matches == 1 then
return matches[1].name, matches[1].lines
end
return name, nil
end
local resolved_name, output = resolve_output(test_name)
test_name = resolved_name
if (type(output) ~= "table" or #output == 0) and state.last_raw_output and state.last_runner then
local parser = state.last_runner.parse_test_output
if type(parser) == "function" then
local ok_parse, parsed = pcall(parser, state.last_raw_output)
if ok_parse and type(parsed) == "table" then
state.last_test_outputs = parsed
test_name, output = resolve_output(test_name)
end
end
end
if (type(output) ~= "table" or #output == 0) and state.last_raw_output then
local parsed = parse_go_output_from_raw(state.last_raw_output)
if type(parsed) == "table" and next(parsed) ~= nil then
state.last_test_outputs = parsed
test_name, output = resolve_output(test_name)
end
end
if type(output) ~= "table" or #output == 0 then
vim.notify("[test-samurai] No output captured for " .. test_name, vim.log.levels.WARN)
return
end
open_detail_split(output)
end
function M.focus_listing()
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
vim.api.nvim_set_current_win(state.last_win)
end
end
local function run_cmd(cmd, cwd, handlers)
local h = handlers or {}
@@ -383,6 +850,38 @@ local function pick_display(results, key, scope_kind)
return results[key]
end
local function track_result_lines(start_line, results, scope_kind)
if not results then
return
end
local entries = {}
local function append_kind(kind)
local display = pick_display(results, kind, scope_kind)
if type(display) ~= "table" then
return
end
local full = results[kind]
for i, name in ipairs(display) do
local full_name = nil
if type(full) == "table" then
full_name = full[i]
end
if not full_name or full_name == "" then
full_name = name
end
table.insert(entries, full_name)
end
end
append_kind("passes")
append_kind("skips")
append_kind("failures")
for i, name in ipairs(entries) do
if name and name ~= "" then
state.last_result_line_map[start_line + i] = name
end
end
end
local function format_results(results, scope_kind)
local lines = {}
local passes = pick_display(results, "passes", scope_kind)
@@ -539,6 +1038,9 @@ end
local function run_command(command, opts)
local options = opts or {}
state.last_test_outputs = {}
state.last_result_line_map = {}
state.last_raw_output = nil
if command and type(command.cmd) == "table" and #command.cmd > 0 then
if options.save_last ~= false then
state.last_command = {
@@ -568,6 +1070,7 @@ local function run_command(command, opts)
if type(parser) == "function" then
parser = { on_complete = parser }
end
local runner = options.runner
local parser_state = {}
parser_state.scope_kind = options.scope_kind
local had_parsed_output = false
@@ -628,6 +1131,7 @@ local function run_command(command, opts)
local start_line = vim.api.nvim_buf_line_count(buf)
append_lines(buf, lines)
apply_result_highlights(buf, start_line, lines)
track_result_lines(start_line, results, options.scope_kind)
end
run_cmd(cmd, cwd, {
@@ -670,8 +1174,15 @@ local function run_command(command, opts)
if not buf then
buf = select(1, create_output_win({ header }))
end
local output = table.concat(output_lines, "\n")
state.last_raw_output = output
if runner and type(runner.parse_test_output) == "function" then
local ok_parse, parsed = pcall(runner.parse_test_output, output)
if ok_parse and type(parsed) == "table" then
state.last_test_outputs = parsed
end
end
if parser and parser.on_complete then
local output = table.concat(output_lines, "\n")
local ok_parse, results = pcall(parser.on_complete, output, parser_state)
if ok_parse then
handle_parsed(results)