local config = require("test-samurai.config") local util = require("test-samurai.util") local M = {} local state = { runners = {}, last_win = nil, last_buf = nil, last_command = nil, last_runner = nil, last_scope_command = nil, last_scope_runner = nil, last_scope_kind = nil, 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, detail_full = false, trigger_win = nil, trigger_buf = nil, trigger_cursor = nil, listing_unfiltered_lines = nil, listing_filtered_kind = nil, hardtime_refcount = 0, hardtime_was_enabled = 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 help_ns = vim.api.nvim_create_namespace("TestSamuraiHelp") local apply_border_kind local close_container local restore_listing_full local close_detail_float local jump_to_first_quickfix local apply_summary_highlights local apply_result_highlights local run_command local function disable_container_maps(buf) local opts = { buffer = buf, nowait = true, silent = true } vim.keymap.set("n", "", "", opts) vim.keymap.set("n", "", "", opts) end local function help_lines() return { "Test-Samurai Help", "", "TSam commands:", " TSamNearest tn", " TSamFile tf", " TSamAll ta", " TSamLast tl", " TSamFailedOnly te", " TSamShowOutput to", "", "Listing navigation:", " fn [F]ind [N]ext failed test in listing", " fp [F]ind [P]revious failed test in listing", " ff [F]ind [F]irst list entry", " o Jump to test location", " qn Close floats + jump to first quickfix entry", "", "Listing filters:", " sf Filter listing to [ FAIL ] only", " ss Filter listing to [ SKIP ] only", " sa Show all listing entries (clear filter)", "", "Listing actions:", " tt Run the test under the cursor", " cb breaks test-command onto multiple lines (clears search highlight)", " cj joins test-command onto single line", "", "Testing-Float (Listing):", " Open Detail-Float for selected test", " Close Testing-Float and restore cursor", " Focus Detail-Float (press l again for full)", " Focus Test-Listing-Float", " z Toggle Detail-Float full width", " ? Show this help", "", "Testing-Float (Detail):", " Close Testing-Float and restore cursor", " Focus Test-Listing-Float", " h Focus Test-Listing-Float", " Focus Detail-Float", " z Toggle Detail-Float full width", " Close Detail-Float", " ? Show this help", "", "Notes:", " No output captured -> shows placeholder text", " Buffers are saved via :wall before every test run", } end local function split_listing_sections(lines) local summary_start = nil for i, line in ipairs(lines or {}) do if line:match("^TOTAL%s+%d+") then summary_start = i - 1 if summary_start < 2 then summary_start = 2 end break end end local header = {} local body = {} local summary = {} if lines and #lines > 0 then header = { lines[1] } end for i, line in ipairs(lines or {}) do if i == 1 then elseif summary_start and i >= summary_start then table.insert(summary, line) else table.insert(body, line) end end return header, body, summary end local function rebuild_result_line_map(lines) state.last_result_line_map = {} for idx, line in ipairs(lines or {}) do local status = line:match("^%[%s*(%u+)%s*%]%s*%-") if status == "PASS" or status == "FAIL" or status == "SKIP" then local name = line:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") if name and name ~= "" then state.last_result_line_map[idx] = name end end end end local function apply_listing_lines(buf, lines) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.api.nvim_buf_clear_namespace(buf, result_ns, 0, -1) vim.api.nvim_buf_clear_namespace(buf, summary_ns, 0, -1) apply_result_highlights(buf, 0, lines) apply_summary_highlights(buf, 0, lines) rebuild_result_line_map(lines) end local function apply_listing_substitution(command) local buf = vim.api.nvim_get_current_buf() if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end vim.api.nvim_buf_call(buf, function() vim.cmd(command) end) end local function apply_listing_filter(kind) if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then return end if kind == "all" then if not state.listing_filtered_kind or not state.listing_unfiltered_lines then return end apply_listing_lines(state.last_buf, state.listing_unfiltered_lines) state.listing_filtered_kind = nil return end if state.listing_filtered_kind == kind then return end local base = state.listing_unfiltered_lines if not base then base = vim.api.nvim_buf_get_lines(state.last_buf, 0, -1, false) state.listing_unfiltered_lines = vim.deepcopy(base) end local header, body, summary = split_listing_sections(base) local filtered = {} for _, line in ipairs(body) do if kind == "fail" and line:match("^%[ FAIL %] %-") then table.insert(filtered, line) elseif kind == "skip" and line:match("^%[ SKIP %] %-") then table.insert(filtered, line) end end if #filtered == 0 then return end local combined = {} if #header > 0 then table.insert(combined, header[1]) table.insert(combined, "") end for _, line in ipairs(filtered) do table.insert(combined, line) end for _, line in ipairs(summary) do table.insert(combined, line) end apply_listing_lines(state.last_buf, combined) state.listing_filtered_kind = kind end local function get_hardtime() local ok, hardtime = pcall(require, "hardtime") if not ok or type(hardtime) ~= "table" then return nil end if type(hardtime.enable) ~= "function" or type(hardtime.disable) ~= "function" then return nil end return hardtime end local function hardtime_disable() local hardtime = get_hardtime() if not hardtime then return end if state.hardtime_refcount == 0 then state.hardtime_was_enabled = hardtime.is_plugin_enabled == true end state.hardtime_refcount = state.hardtime_refcount + 1 if hardtime.is_plugin_enabled == true then pcall(hardtime.disable) end end local function hardtime_restore() if state.hardtime_refcount == 0 then return end state.hardtime_refcount = state.hardtime_refcount - 1 if state.hardtime_refcount > 0 then return end if not state.hardtime_was_enabled then return end local hardtime = get_hardtime() if hardtime then pcall(hardtime.enable) end state.hardtime_was_enabled = false end local function handle_ctrl_l_in_listing() local next_key = vim.fn.getchar(0) if next_key ~= 0 and next_key ~= -1 then local char = vim.fn.nr2char(next_key) if char == "l" then M.expand_detail_full() return end vim.api.nvim_feedkeys(char, "n", false) end M.focus_detail() end local function is_fail_listing_line(line) return line and line:match("^%[ FAIL %] %- ") ~= nil end local function jump_listing_fail(direction) local win = vim.api.nvim_get_current_win() local buf = vim.api.nvim_get_current_buf() if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local total = vim.api.nvim_buf_line_count(buf) if total == 0 then return end local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local cursor = vim.api.nvim_win_get_cursor(win) local start = cursor[1] local function scan_forward(from, to) if from < 1 then from = 1 end if to > total then to = total end for i = from, to do if is_fail_listing_line(lines[i]) then return i end end return nil end local function scan_backward(from, to) if from < 1 then from = 1 end if to > total then to = total end for i = from, to, -1 do if is_fail_listing_line(lines[i]) then return i end end return nil end local target = nil if direction == "prev" then target = scan_backward(start - 1, 1) if not target then target = scan_backward(total, start) end else target = scan_forward(start + 1, total) if not target then target = scan_forward(1, start) end end if target then vim.api.nvim_win_set_cursor(win, { target, 0 }) end end local function jump_to_first_listing_entry() local win = vim.api.nvim_get_current_win() local buf = vim.api.nvim_get_current_buf() if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local total = vim.api.nvim_buf_line_count(buf) if total == 0 then return end local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) for i = 1, total do if lines[i] and lines[i]:match("^%[ %u+ %] %- ") then vim.api.nvim_win_set_cursor(win, { i, 0 }) return end end end local function find_normal_window() for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local cfg = vim.api.nvim_win_get_config(win) if cfg.relative == "" then return win end end return nil end local function capture_trigger_location() local win = vim.api.nvim_get_current_win() local cfg = vim.api.nvim_win_get_config(win) if cfg.relative ~= "" then win = find_normal_window() end if not (win and vim.api.nvim_win_is_valid(win)) then return end local buf = vim.api.nvim_win_get_buf(win) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local cursor = vim.api.nvim_win_get_cursor(win) state.trigger_win = win state.trigger_buf = buf state.trigger_cursor = { cursor[1], cursor[2] } end local function restore_trigger_location() local buf = state.trigger_buf local cursor = state.trigger_cursor if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local target_win = nil if state.trigger_win and vim.api.nvim_win_is_valid(state.trigger_win) then local cfg = vim.api.nvim_win_get_config(state.trigger_win) if cfg.relative == "" then target_win = state.trigger_win end end if not target_win then target_win = find_normal_window() end if not (target_win and vim.api.nvim_win_is_valid(target_win)) then return end vim.api.nvim_set_current_win(target_win) vim.api.nvim_win_set_buf(target_win, buf) if cursor then pcall(vim.api.nvim_win_set_cursor, target_win, cursor) end state.trigger_win = nil state.trigger_buf = nil state.trigger_cursor = nil end local function jump_to_listing_test() local cursor = vim.api.nvim_win_get_cursor(0) local line = cursor[1] local text = vim.api.nvim_get_current_line() local status = text:match("^%[%s*(%u+)%s*%]%s*%-") if status ~= "PASS" and status ~= "FAIL" and status ~= "SKIP" then return end local test_name = state.last_result_line_map[line] if not test_name or test_name == "" then test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") end if not test_name or test_name == "" then return end local runner = state.last_scope_runner or state.last_runner if not runner or type(runner.collect_failed_locations) ~= "function" then return end local command = state.last_scope_command or state.last_command if not command then return end local ok, items = pcall(runner.collect_failed_locations, { test_name }, command, state.last_scope_kind) if not ok or type(items) ~= "table" or #items == 0 then return end local target = items[1] if not target or not target.filename or target.filename == "" then return end local target_win = find_normal_window() close_container() if not (target_win and vim.api.nvim_win_is_valid(target_win)) then target_win = find_normal_window() end local buf = vim.fn.bufadd(target.filename) vim.fn.bufload(buf) if target_win and vim.api.nvim_win_is_valid(target_win) then vim.api.nvim_win_set_buf(target_win, buf) vim.api.nvim_set_current_win(target_win) else vim.api.nvim_set_current_buf(buf) end local total = vim.api.nvim_buf_line_count(buf) if total < 1 then return end local lnum = target.lnum or 1 if lnum < 1 then lnum = 1 elseif lnum > total then lnum = total end local col = target.col or 1 if col < 1 then col = 1 end vim.api.nvim_win_set_cursor(0, { lnum, col - 1 }) end local function get_hl_fg(name) local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = true }) if ok and type(hl) == "table" and hl.fg then return hl.fg end return nil end local function resolve_hl_fg(names, fallback) for _, name in ipairs(names or {}) do local fg = get_hl_fg(name) if fg then return fg end end return fallback end local function setup_summary_highlights() local normal_fg = get_hl_fg("Normal") local pass_fg = resolve_hl_fg({ "DiffAdd", "DiagnosticOk" }, normal_fg) local fail_fg = resolve_hl_fg({ "DiffDelete", "DiagnosticError" }, normal_fg) local skip_fg = resolve_hl_fg({ "DiagnosticInfo", "DiagnosticHint" }, normal_fg) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiSummaryBold", { fg = normal_fg, bold = true }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiSummaryPass", { fg = pass_fg, bold = true }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiSummaryFail", { fg = fail_fg, bold = true }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiSummarySkip", { fg = skip_fg, bold = true }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiResultPass", { fg = pass_fg }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiResultFail", { fg = fail_fg }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiResultSkip", { fg = skip_fg }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiBorderPass", { fg = pass_fg, bold = true }) pcall(vim.api.nvim_set_hl, 0, "TestSamuraiBorderFail", { fg = fail_fg, bold = true }) end local function load_runners() state.runners = {} local opts = config.get() local mods = opts.runner_modules or {} for _, mod in ipairs(mods) do local ok, runner = pcall(require, mod) if ok and type(runner) == "table" then table.insert(state.runners, runner) else vim.notify("[test-samurai] Failed to load runner " .. mod, vim.log.levels.WARN) end end end local function ensure_output_autocmds() if state.autocmds_set then return end local group = vim.api.nvim_create_augroup("TestSamuraiOutput", { clear = true }) vim.api.nvim_create_autocmd("WinClosed", { group = group, callback = function(args) local closed = tonumber(args.match) if not closed then return end if state.detail_win and closed == state.detail_win then state.detail_win = nil restore_listing_full() hardtime_restore() return end if state.last_win and closed == state.last_win then state.last_win = nil hardtime_restore() 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 return end end, }) vim.api.nvim_create_autocmd("ColorScheme", { group = group, callback = function() setup_summary_highlights() end, }) state.autocmds_set = true end function M.setup() load_runners() ensure_output_autocmds() setup_summary_highlights() state.last_command = nil state.last_runner = nil state.last_scope_command = nil state.last_scope_runner = nil state.last_scope_kind = nil 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.last_win = nil state.last_buf = nil state.detail_opening = false state.detail_full = false state.hardtime_refcount = 0 state.hardtime_was_enabled = false end function M.reload_runners() load_runners() end local function detect_js_framework(file) local root = util.find_root(file, { "package.json", "node_modules" }) if not root or root == "" then return nil end local pkg_path = vim.fs.joinpath(root, "package.json") local stat = vim.loop.fs_stat(pkg_path) if not stat or stat.type ~= "file" then return nil end local ok_read, lines = pcall(vim.fn.readfile, pkg_path) if not ok_read or type(lines) ~= "table" then return nil end local json = table.concat(lines, "\n") local ok_json, pkg = pcall(vim.json.decode, json) if not ok_json or type(pkg) ~= "table" then return nil end local present = {} local function scan_section(section) if type(section) ~= "table" then return end for name, _ in pairs(section) do if name == "mocha" or name == "jest" or name == "vitest" then present[name] = true end end end scan_section(pkg.dependencies) scan_section(pkg.devDependencies) if next(present) == nil then return nil end return present end function M.get_runner_for_buf(bufnr) local path = util.get_buf_path(bufnr) local candidates = {} for _, runner in ipairs(state.runners) do if type(runner.is_test_file) == "function" then local ok, is_test = pcall(runner.is_test_file, bufnr) if ok and is_test then table.insert(candidates, runner) end end end if #candidates == 1 then return candidates[1] elseif #candidates > 1 then local frameworks = nil if path and path ~= "" then frameworks = detect_js_framework(path) end if frameworks then for _, runner in ipairs(candidates) do if runner.framework and frameworks[runner.framework] then return runner end end end return candidates[1] end if not path or path == "" then return nil end return nil end local function float_geometry() local width = math.floor(vim.o.columns * 0.8) local height = math.floor(vim.o.lines * 0.8) local row = math.floor((vim.o.lines - height) / 2) local col = math.floor((vim.o.columns - width) / 2) return width, height, row, col end local function base_geometry() local width, height, row, col = float_geometry() local base = state.last_float or {} return base.width or width, base.height or height, base.row or row, base.col or col end apply_border_kind = function(win, kind) local target = win if not target then target = state.last_win end if not (target and vim.api.nvim_win_is_valid(target)) then return end if kind == "pass" then vim.api.nvim_win_set_option(target, "winhighlight", "FloatBorder:TestSamuraiBorderPass") elseif kind == "fail" then vim.api.nvim_win_set_option(target, "winhighlight", "FloatBorder:TestSamuraiBorderFail") else vim.api.nvim_win_set_option(target, "winhighlight", "") end end close_container = function() 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 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 end local function close_container_and_restore() close_container() restore_trigger_location() end jump_to_first_quickfix = function() close_container() local info = vim.fn.getqflist({ size = 0 }) if type(info) == "table" and (info.size or 0) > 0 then vim.cmd("cfirst") end end close_detail_float = function() 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) 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, height, row, col = base_geometry() vim.api.nvim_win_set_config(state.last_win, { relative = "editor", width = width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) end local function apply_split_layout(left_ratio) if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then return end if not (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win)) then return end local width, height, row, col = base_geometry() local available = math.max(1, width - 2) local left_width = math.floor(available * left_ratio) if left_width < 1 then left_width = 1 end if left_width >= available then left_width = math.max(1, available - 1) end local right_width = available - left_width if right_width < 1 then right_width = 1 if available > 1 then left_width = available - right_width end end local listing_border = "rounded" local detail_col = col + left_width + 2 if left_ratio <= 0 then left_width = 1 right_width = width listing_border = "none" detail_col = col state.detail_full = true else state.detail_full = false end vim.api.nvim_win_set_config(state.last_win, { relative = "editor", width = left_width, height = height, row = row, col = col, style = "minimal", border = listing_border, }) vim.api.nvim_win_set_config(state.detail_win, { relative = "editor", width = right_width, height = height, row = row, col = detail_col, style = "minimal", border = "rounded", }) end local function create_output_win(initial_lines) capture_trigger_location() 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 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 local buf = state.last_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, "filetype", "test-samurai-output") state.last_buf = buf end 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 listing = vim.api.nvim_open_win(buf, true, { relative = "editor", width = width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) hardtime_disable() vim.keymap.set("n", "", function() close_container_and_restore() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.open_test_output_at_cursor() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.focus_listing() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() handle_ctrl_l_in_listing() end, { buffer = buf, silent = true }) vim.keymap.set("n", "z", function() M.toggle_detail_full() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "fn", function() jump_listing_fail("next") end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "fp", function() jump_listing_fail("prev") end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "ff", function() jump_to_first_listing_entry() end, { buffer = buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" }) vim.keymap.set("n", "cb", function() M.listing_break_on_dashes() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "cj", function() M.listing_join_backslashes() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "o", function() jump_to_listing_test() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "qn", function() jump_to_first_quickfix() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "sf", function() M.filter_listing_failures() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "ss", function() M.filter_listing_skips() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "sa", function() M.filter_listing_all() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "tt", function() M.run_test_at_cursor() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "?", function() M.show_help() end, { buffer = buf, nowait = true, silent = true }) disable_container_maps(buf) state.last_win = listing state.last_buf = buf apply_border_kind(listing, state.last_border_kind) return buf, listing end local function reopen_output_win() if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then return nil, nil end if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then 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 capture_trigger_location() 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", width = width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) hardtime_disable() vim.keymap.set("n", "", function() close_container_and_restore() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.open_test_output_at_cursor() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.focus_listing() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() handle_ctrl_l_in_listing() end, { buffer = state.last_buf, silent = true }) vim.keymap.set("n", "z", function() M.toggle_detail_full() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "fn", function() jump_listing_fail("next") end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "fp", function() jump_listing_fail("prev") end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "ff", function() jump_to_first_listing_entry() end, { buffer = state.last_buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" }) vim.keymap.set("n", "cb", function() M.listing_break_on_dashes() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "cj", function() M.listing_join_backslashes() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "o", function() jump_to_listing_test() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "qn", function() jump_to_first_quickfix() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "sf", function() M.filter_listing_failures() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "ss", function() M.filter_listing_skips() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "sa", function() M.filter_listing_all() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "tt", function() M.run_test_at_cursor() end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "?", function() M.show_help() end, { buffer = state.last_buf, nowait = true, silent = true }) disable_container_maps(state.last_buf) state.last_win = win apply_border_kind(win, state.last_border_kind) return state.last_buf, win end local function append_lines(buf, new_lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end if not new_lines or #new_lines == 0 then return end local existing = vim.api.nvim_buf_line_count(buf) vim.api.nvim_buf_set_lines(buf, existing, existing, false, new_lines) if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then local total = vim.api.nvim_buf_line_count(buf) vim.api.nvim_win_set_cursor(state.last_win, { total, 0 }) 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 apply_help_highlights(buf, lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1) for lnum, line in ipairs(lines or {}) do if line:match(":%s*$") then vim.api.nvim_buf_add_highlight(buf, help_ns, "TestSamuraiSummaryPass", lnum - 1, 0, -1) 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", "", function() close_container_and_restore() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "h", function() M.focus_listing() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.focus_listing() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() M.focus_detail() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "z", function() M.toggle_detail_full() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "", function() close_detail_float() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "qn", function() jump_to_first_quickfix() end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "?", function() M.show_help() end, { buffer = buf, nowait = true, silent = true }) disable_container_maps(buf) end local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines)) vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines) vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1) apply_detail_highlights(buf, highlights) return buf end local function open_detail_split(lines, border_kind) local buf = ensure_detail_buf(lines) if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then return end local width, height, row, col = base_geometry() local available = math.max(1, width - 2) local left_width = math.floor(available * 0.25) if left_width < 1 then left_width = 1 end if left_width >= available then left_width = math.max(1, available - 1) end local right_width = available - left_width if right_width < 1 then right_width = 1 if available > 1 then left_width = available - right_width end end vim.api.nvim_win_set_config(state.last_win, { relative = "editor", width = left_width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) local right_cfg = { relative = "editor", width = right_width, height = height, row = row, col = col + left_width + 2, style = "minimal", border = "rounded", } local right = state.detail_win local opening_detail = not (right and vim.api.nvim_win_is_valid(right)) 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 if opening_detail then hardtime_disable() end apply_border_kind(right, border_kind) state.detail_full = false 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 text = vim.api.nvim_get_current_line() local status = text:match("^%[%s*(%u+)%s*%]%s*%-") if status and status ~= "PASS" and status ~= "FAIL" then return end local test_name = state.last_result_line_map[line] if not test_name then 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 open_detail_split({ "", "No output captured" }, "default") return end local border_kind = status and status:lower() or nil open_detail_split(output, border_kind) end function M.show_help() if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then vim.notify("[test-samurai] No test output window", vim.log.levels.WARN) return end local lines = help_lines() open_detail_split(lines, "default") apply_help_highlights(state.detail_buf, lines) end function M.filter_listing_failures() apply_listing_filter("fail") end function M.filter_listing_skips() apply_listing_filter("skip") end function M.filter_listing_all() apply_listing_filter("all") end function M.listing_break_on_dashes() apply_listing_substitution([[%s/--/\\\r\t--/g]]) vim.cmd("noh") end function M.listing_join_backslashes() apply_listing_substitution([[%s/\\\n\t//g]]) end function M.run_test_at_cursor() local cursor = vim.api.nvim_win_get_cursor(0) local line = cursor[1] local text = vim.api.nvim_get_current_line() local status = text:match("^%[%s*(%u+)%s*%]%s*%-") if status ~= "PASS" and status ~= "FAIL" and status ~= "SKIP" then return end local test_name = state.last_result_line_map[line] if not test_name then test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") end if not test_name or test_name == "" then return end local runner = state.last_scope_runner or state.last_runner if not runner or type(runner.build_command) ~= "function" then vim.notify("[test-samurai] Runner missing methods", vim.log.levels.ERROR) return end local command_src = state.last_scope_command or state.last_command if not command_src then vim.notify("[test-samurai] No previous test command", vim.log.levels.WARN) return end local spec = { file = command_src.file, cwd = command_src.cwd, test_name = test_name, full_name = test_name, } if runner._last_mocha_titles and type(runner._last_mocha_titles) == "table" then spec.mocha_full_title = runner._last_mocha_titles[test_name] end if not spec.mocha_full_title and test_name:find("/", 1, true) then spec.mocha_full_title = test_name:gsub("/", " ") end if not spec.file or spec.file == "" then vim.notify("[test-samurai] Missing test file for rerun", vim.log.levels.WARN) return end local ok_cmd, command = pcall(runner.build_command, spec) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build command", vim.log.levels.ERROR) return end command.file = spec.file local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "nearest", output_parser = parser or runner.parse_results, }) end function M.focus_listing() if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then apply_split_layout(0.25) end vim.api.nvim_set_current_win(state.last_win) end end function M.focus_detail() if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then vim.api.nvim_set_current_win(state.detail_win) end end function M.expand_detail_full() if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then apply_split_layout(0) vim.api.nvim_set_current_win(state.detail_win) end end function M.toggle_detail_full() if not (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win)) then return end if state.detail_full then apply_split_layout(0.25) vim.api.nvim_set_current_win(state.last_win) else M.expand_detail_full() end end local function run_cmd(cmd, cwd, handlers) local h = handlers or {} if h.on_start then pcall(h.on_start) end local function handle_chunk(fn, data) if not fn or not data then return end local lines = {} for _, line in ipairs(data) do if line ~= nil and line ~= "" then table.insert(lines, line) end end if #lines > 0 then fn(lines) end end vim.fn.jobstart(cmd, { cwd = cwd, stdout_buffered = false, stderr_buffered = false, on_stdout = function(_, data, _) handle_chunk(h.on_stdout, data) end, on_stderr = function(_, data, _) handle_chunk(h.on_stderr, data) end, on_exit = function(_, code, _) if h.on_exit then pcall(h.on_exit, code or 0) end end, }) end local function pick_display(results, key, scope_kind) if scope_kind == "all" then return results[key] end if type(results.display) == "table" and type(results.display[key]) == "table" then return results.display[key] end return results[key] end local function entry_root(name) if not name or name == "" then return nil end local idx = name:find("/", 1, true) if not idx then return name end return name:sub(1, idx - 1) end local function parent_of(name) if not name or name == "" then return nil end local last = nil for i = #name, 1, -1 do if name:sub(i, i) == "/" then last = i break end end if not last then return nil end return name:sub(1, last - 1) end local function group_entries_by_parent(entries) local nodes = {} local ordered = {} local roots = {} for _, entry in ipairs(entries) do local full = entry.full if full and full ~= "" then if not nodes[full] then nodes[full] = { entry = entry, children = {} } table.insert(ordered, full) end else table.insert(roots, { entry = entry, children = {} }) end end for _, full in ipairs(ordered) do local node = nodes[full] local parent = parent_of(full) if parent and nodes[parent] then table.insert(nodes[parent].children, node) else table.insert(roots, node) end end local out = {} local function emit(node) if node.entry then table.insert(out, node.entry) end for _, child in ipairs(node.children) do emit(child) end end for _, node in ipairs(roots) do emit(node) end return out end local function build_listing_entries(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, { kind = kind, full = full_name, display = name, }) end end append_kind("passes") append_kind("skips") append_kind("failures") local parent_set = {} for _, entry in ipairs(entries) do if entry.full and entry.full ~= "" then parent_set[entry.full] = true end end for _, entry in ipairs(entries) do if entry.full and entry.full:find("/", 1, true) then local parent = parent_of(entry.full) if parent and parent_set[parent] then entry.display = entry.full end end end return group_entries_by_parent(entries) end local function track_result_lines(start_line, results, scope_kind) local entries = build_listing_entries(results, scope_kind) for i, entry in ipairs(entries) do if entry.full and entry.full ~= "" then state.last_result_line_map[start_line + i] = entry.full end end end local function format_results(results, scope_kind) local lines = {} local entries = build_listing_entries(results, scope_kind) for _, entry in ipairs(entries) do if entry.kind == "passes" then table.insert(lines, "[ PASS ] - " .. entry.display) elseif entry.kind == "skips" then table.insert(lines, "[ SKIP ] - " .. entry.display) elseif entry.kind == "failures" then table.insert(lines, "[ FAIL ] - " .. entry.display) end end return lines end local function make_summary_tracker(enabled) if not enabled then return nil end return { passes = {}, failures = {}, skips = {}, } end local function add_unique_items(target, items) if type(items) ~= "table" then return end for _, item in ipairs(items) do if item and item ~= "" then target[item] = true end end end local function init_aggregate_results() return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} }, } end local function merge_results(agg, results, seen) if not agg or not results or not seen then return end local function merge_kind(kind) local items = results[kind] if type(items) ~= "table" then return end local display_items = nil if type(results.display) == "table" and type(results.display[kind]) == "table" then display_items = results.display[kind] end for i, name in ipairs(items) do if name and name ~= "" and not seen[kind][name] then seen[kind][name] = true table.insert(agg[kind], name) if display_items and display_items[i] then table.insert(agg.display[kind], display_items[i]) else table.insert(agg.display[kind], name) end end end end merge_kind("passes") merge_kind("failures") merge_kind("skips") end local function should_group_results(results) if not results then return false end local parent_set = {} for _, kind in ipairs({ "passes", "failures", "skips" }) do local list = results[kind] if type(list) == "table" then for _, name in ipairs(list) do if name and name ~= "" and not name:find("/", 1, true) then parent_set[name] = true end end end end if not next(parent_set) then return false end for _, kind in ipairs({ "passes", "failures", "skips" }) do local list = results[kind] if type(list) == "table" then for _, name in ipairs(list) do if name and name:find("/", 1, true) then local root = entry_root(name) if root and parent_set[root] then return true end end end end end return false end local function update_summary(summary, results) if not summary or not results then return end add_unique_items(summary.passes, results.passes) add_unique_items(summary.failures, results.failures) add_unique_items(summary.skips, results.skips) end local function count_summary(summary) if not summary then return 0, 0, 0, 0 end local function count(set) local total = 0 for _ in pairs(set) do total = total + 1 end return total end local pass_count = count(summary.passes) local fail_count = count(summary.failures) local skip_count = count(summary.skips) return pass_count, fail_count, skip_count, pass_count + fail_count + skip_count end local function format_summary_lines(summary, elapsed_seconds) local pass_count, fail_count, skip_count, total = count_summary(summary) local minutes = 0 local seconds = 0 if type(elapsed_seconds) == "number" and elapsed_seconds >= 0 then local total_seconds = math.floor(elapsed_seconds + 0.0000001) minutes = math.floor(total_seconds / 60) seconds = total_seconds % 60 end local duration = string.format("%02dm %02ds", minutes, seconds) return { "", string.format("TOTAL %d", total), string.format("DURATION %s", duration), "", string.format(" PASS %d - SKIPPED %d - FAILED %d", pass_count, skip_count, fail_count), } end local function highlight_label_number(buf, ns, lnum, line, label, hl_group) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local label_start = line:find(label, 1, true) if not label_start then return end local num_start, num_end = line:find("%d+", label_start + #label) if not num_start then return end vim.api.nvim_buf_add_highlight(buf, ns, hl_group, lnum, num_start - 1, num_end) end local function highlight_label_word(buf, ns, lnum, line, label, hl_group) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end local label_start = line:find(label, 1, true) if not label_start then return end vim.api.nvim_buf_add_highlight(buf, ns, hl_group, lnum, label_start - 1, label_start - 1 + #label) end apply_summary_highlights = function(buf, start_line, lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end for i, line in ipairs(lines or {}) do local lnum = start_line + i - 1 if line:match("^%s*PASS%s+%d+") then highlight_label_word(buf, summary_ns, lnum, line, "PASS", "TestSamuraiSummaryPass") highlight_label_number(buf, summary_ns, lnum, line, "PASS", "TestSamuraiSummaryPass") highlight_label_word(buf, summary_ns, lnum, line, "SKIPPED", "TestSamuraiSummarySkip") highlight_label_number(buf, summary_ns, lnum, line, "SKIPPED", "TestSamuraiSummarySkip") highlight_label_word(buf, summary_ns, lnum, line, "FAILED", "TestSamuraiSummaryFail") highlight_label_number(buf, summary_ns, lnum, line, "FAILED", "TestSamuraiSummaryFail") elseif line:match("^TOTAL%s+%d+") then highlight_label_word(buf, summary_ns, lnum, line, "TOTAL", "TestSamuraiSummaryBold") highlight_label_number(buf, summary_ns, lnum, line, "TOTAL", "TestSamuraiSummaryBold") elseif line:match("^DURATION%s+") then highlight_label_word(buf, summary_ns, lnum, line, "DURATION", "TestSamuraiSummaryBold") end end end apply_result_highlights = function(buf, start_line, lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end for i, line in ipairs(lines or {}) do local lnum = start_line + i - 1 if line:match("^%[ PASS %]") then highlight_label_word(buf, result_ns, lnum, line, "PASS", "TestSamuraiResultPass") elseif line:match("^%[ FAIL %]") then highlight_label_word(buf, result_ns, lnum, line, "FAIL", "TestSamuraiResultFail") elseif line:match("^%[ SKIP %]") then highlight_label_word(buf, result_ns, lnum, line, "SKIP", "TestSamuraiResultSkip") end end end local function collect_failure_names_from_listing() if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then return {} end local lines = vim.api.nvim_buf_get_lines(state.last_buf, 0, -1, false) local out = {} local seen = {} for idx, line in ipairs(lines) do if line:match("^%[ FAIL %]") then local name = state.last_result_line_map[idx] if not name then name = line:match("^%[ FAIL %] %-%s*(.+)$") end if name and name ~= "" and not seen[name] then seen[name] = true table.insert(out, name) end end end return out end run_command = function(command, opts) local options = opts or {} vim.cmd("wall") state.last_test_outputs = {} state.last_result_line_map = {} state.last_raw_output = nil state.listing_unfiltered_lines = nil state.listing_filtered_kind = nil local failures = {} local failures_seen = {} if command and type(command.cmd) == "table" and #command.cmd > 0 then if options.save_last ~= false then state.last_command = { cmd = vim.deepcopy(command.cmd), cwd = command.cwd, file = command.file, } state.last_runner = options.runner end if options.track_scope then state.last_scope_command = { cmd = vim.deepcopy(command.cmd), cwd = command.cwd, file = command.file, } state.last_scope_runner = options.runner state.last_scope_kind = options.scope_kind state.last_scope_exit_code = nil state.last_scope_failures = nil end end local cmd = command.cmd local cwd = command.cwd or vim.loop.cwd() local header = "$ " .. table.concat(cmd, " ") local buf = nil local has_output = false local parser = options.output_parser if type(parser) == "function" then parser = { on_complete = parser } end local runner = options.runner local parser_state = {} parser_state.scope_kind = options.scope_kind parser_state.aggregate_results = nil parser_state.result_start_line = nil parser_state.result_end_line = nil parser_state.seen = { passes = {}, failures = {}, skips = {}, } local had_parsed_output = false local summary_enabled = options.scope_kind == "file" or options.scope_kind == "all" or options.scope_kind == "nearest" or options.scope_kind == "last" local summary = make_summary_tracker(summary_enabled) local result_counts = make_summary_tracker(true) state.last_border_kind = "default" local start_time = nil if vim.loop and vim.loop.hrtime then start_time = vim.loop.hrtime() end local output_lines = {} local function collect_output(lines) if not lines or #lines == 0 then return end for _, line in ipairs(lines) do if line ~= nil then table.insert(output_lines, line) end end end local function ensure_output_started() if not buf then buf = select(1, create_output_win({ header, "" })) end if not has_output then local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false) if #cur > 0 and cur[#cur] == "[running...]" then vim.api.nvim_buf_set_lines(buf, #cur - 1, #cur, false, {}) end has_output = true end end local function handle_parsed(results) if not results then return end had_parsed_output = true if not parser_state.aggregate_results then parser_state.aggregate_results = init_aggregate_results() end merge_results(parser_state.aggregate_results, results, parser_state.seen) if type(results.failures) == "table" then for _, name in ipairs(results.failures) do if name and name ~= "" and not failures_seen[name] then failures_seen[name] = true table.insert(failures, name) end end end update_summary(summary, results) update_summary(result_counts, results) if options.track_scope then if results.failures_all ~= nil then state.last_scope_failures = results.failures_all elseif results.failures ~= nil then state.last_scope_failures = results.failures end end local lines = format_results(results, options.scope_kind) if #lines == 0 then return end ensure_output_started() 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) if not parser_state.result_start_line then parser_state.result_start_line = start_line end parser_state.result_end_line = vim.api.nvim_buf_line_count(buf) end run_cmd(cmd, cwd, { on_start = function() buf = select(1, create_output_win({ header, "", "[running...]" })) end, on_stdout = function(lines) if parser then collect_output(lines) if parser.on_line then for _, line in ipairs(lines or {}) do local ok_parse, results = pcall(parser.on_line, line, parser_state) if ok_parse then handle_parsed(results) end end end return end ensure_output_started() append_lines(buf, lines) end, on_stderr = function(lines) if parser then collect_output(lines) if parser.on_line then for _, line in ipairs(lines or {}) do local ok_parse, results = pcall(parser.on_line, line, parser_state) if ok_parse then handle_parsed(results) end end end return end ensure_output_started() append_lines(buf, lines) end, on_exit = function(code) 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 ok_parse, results = pcall(parser.on_complete, output, parser_state) if ok_parse then handle_parsed(results) end end if parser_state.aggregate_results and parser_state.result_start_line and should_group_results(parser_state.aggregate_results) then local start_line = parser_state.result_start_line local end_line = parser_state.result_end_line or start_line local grouped = format_results(parser_state.aggregate_results, options.scope_kind) vim.api.nvim_buf_set_lines(buf, start_line, end_line, false, grouped) vim.api.nvim_buf_clear_namespace(buf, result_ns, start_line, end_line) apply_result_highlights(buf, start_line, grouped) state.last_result_line_map = {} track_result_lines(start_line, parser_state.aggregate_results, options.scope_kind) parser_state.result_end_line = start_line + #grouped end local pass_count, fail_count = count_summary(result_counts) if fail_count > 0 then state.last_border_kind = "fail" elseif pass_count > 0 then state.last_border_kind = "pass" else state.last_border_kind = "default" end apply_border_kind(state.last_win, state.last_border_kind) if parser then if not had_parsed_output and #output_lines > 0 then ensure_output_started() append_lines(buf, output_lines) elseif not has_output then ensure_output_started() end if summary_enabled then local elapsed = nil if start_time and vim.loop and vim.loop.hrtime then elapsed = (vim.loop.hrtime() - start_time) / 1000000000 end ensure_output_started() local summary_lines = format_summary_lines(summary, elapsed) local start_line = vim.api.nvim_buf_line_count(buf) append_lines(buf, summary_lines) apply_summary_highlights(buf, start_line, summary_lines) end append_lines(buf, { "" }) else if not has_output then local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false) if #cur > 0 and cur[#cur] == "[running...]" then vim.api.nvim_buf_set_lines(buf, #cur - 1, #cur, false, {}) end end if summary_enabled then local elapsed = nil if start_time and vim.loop and vim.loop.hrtime then elapsed = (vim.loop.hrtime() - start_time) / 1000000000 end ensure_output_started() local summary_lines = format_summary_lines(summary, elapsed) local start_line = vim.api.nvim_buf_line_count(buf) append_lines(buf, summary_lines) apply_summary_highlights(buf, start_line, summary_lines) end append_lines(buf, { "" }) end if options.track_scope then state.last_scope_exit_code = code end local items = {} local failures_for_qf = failures local fallback_failures = options.qf_fallback_failures if options.track_scope and type(state.last_scope_failures) == "table" then local merged = {} local seen = {} for _, name in ipairs(failures or {}) do if name and not seen[name] then seen[name] = true table.insert(merged, name) end end for _, name in ipairs(state.last_scope_failures or {}) do if name and not seen[name] then seen[name] = true table.insert(merged, name) end end failures_for_qf = merged end local listing_failures = collect_failure_names_from_listing() if #listing_failures > 0 then local merged = {} local seen = {} for _, name in ipairs(failures_for_qf or {}) do if name and not seen[name] then seen[name] = true table.insert(merged, name) end end for _, name in ipairs(listing_failures) do if name and not seen[name] then seen[name] = true table.insert(merged, name) end end failures_for_qf = merged end if (not failures_for_qf or #failures_for_qf == 0) and type(fallback_failures) == "table" then local merged = {} local seen = {} for _, name in ipairs(fallback_failures) do if name and not seen[name] then seen[name] = true table.insert(merged, name) end end failures_for_qf = merged end if #failures_for_qf > 0 and runner and type(runner.collect_failed_locations) == "function" then local scope_kind = options.qf_scope_kind or options.scope_kind local ok_collect, collected = pcall(runner.collect_failed_locations, failures_for_qf, command, scope_kind) if ok_collect and type(collected) == "table" then items = collected end end vim.fn.setqflist({}, "r", { title = "Test-Samurai Failures", items = items }) end, }) end function M.run_last() if not (state.last_command and type(state.last_command.cmd) == "table") then vim.notify("[test-samurai] No previous test command", vim.log.levels.WARN) return end local command = { cmd = vim.deepcopy(state.last_command.cmd), cwd = state.last_command.cwd, } local runner = state.last_runner local parser = nil if runner then parser = runner.output_parser if type(parser) == "function" then parser = parser() end end run_command(command, { runner = runner, scope_kind = state.last_scope_kind or "last", output_parser = parser or (runner and runner.parse_results), }) end function M.run_nearest() local bufnr = vim.api.nvim_get_current_buf() local pos = vim.api.nvim_win_get_cursor(0) local row = pos[1] - 1 local col = pos[2] local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] no runner installed for this kind of test", vim.log.levels.WARN) return end if type(runner.find_nearest) ~= "function" or type(runner.build_command) ~= "function" then vim.notify("[test-samurai] Runner missing methods", vim.log.levels.ERROR) return end local ok, spec_or_err = pcall(runner.find_nearest, bufnr, row, col) if not ok or not spec_or_err then local msg = "[test-samurai] No test found" if type(spec_or_err) == "string" then msg = "[test-samurai] " .. spec_or_err end vim.notify(msg, vim.log.levels.WARN) return end local spec = spec_or_err local ok_cmd, command = pcall(runner.build_command, spec) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build command", vim.log.levels.ERROR) return end command.file = spec.file local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "nearest", output_parser = parser or runner.parse_results, }) end function M.run_file() local bufnr = vim.api.nvim_get_current_buf() local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] no runner installed for this kind of test", vim.log.levels.WARN) return end if type(runner.build_file_command) ~= "function" then vim.notify("[test-samurai] Runner does not support file-level execution", vim.log.levels.WARN) return end local ok_cmd, command = pcall(runner.build_file_command, bufnr) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build file command", vim.log.levels.ERROR) return end command.file = util.get_buf_path(bufnr) local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "file", output_parser = parser or runner.parse_results, }) end function M.run_all() local bufnr = vim.api.nvim_get_current_buf() local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] no runner installed for this kind of test", vim.log.levels.WARN) return end if type(runner.build_all_command) ~= "function" then vim.notify("[test-samurai] Runner does not support project-level execution", vim.log.levels.WARN) return end local ok_cmd, command = pcall(runner.build_all_command, bufnr) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build all-tests command", vim.log.levels.ERROR) return end command.file = util.get_buf_path(bufnr) local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "all", output_parser = parser or runner.parse_results, }) end local function build_failed_only_command() if not (state.last_scope_command and type(state.last_scope_command.cmd) == "table") then return nil, "[test-samurai] No previous scoped test command" end local failures = state.last_scope_failures or {} if #failures == 0 then return nil, nil end local runner = state.last_scope_runner if not runner or type(runner.build_failed_command) ~= "function" then local name = runner and runner.name or "unknown" return nil, "[test-samurai] Runner does not support failed-only: " .. name end local ok_build, command = pcall(runner.build_failed_command, state.last_scope_command, failures, state.last_scope_kind) if not ok_build or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then return nil, "[test-samurai] Runner failed to build failed-only command" end return command, nil end function M.run_failed_only() if not (state.last_scope_command and type(state.last_scope_command.cmd) == "table") then vim.notify("[test-samurai] No previous scoped test command", vim.log.levels.WARN) return end local command, err = build_failed_only_command() if not command then if not err then M.run_last() return end vim.notify(err, vim.log.levels.WARN) return end local runner = state.last_scope_runner if state.last_scope_command and state.last_scope_command.file then command.file = state.last_scope_command.file end local parser = nil if runner then parser = runner.output_parser if type(parser) == "function" then parser = parser() end end run_command(command, { save_last = false, runner = runner, output_parser = parser or (runner and runner.parse_results), qf_fallback_failures = state.last_scope_failures, qf_scope_kind = state.last_scope_kind, }) end function M.close_output_and_restore() close_container_and_restore() end function M.show_output() if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then vim.notify("[test-samurai] No previous output", vim.log.levels.WARN) return end reopen_output_win() end return M