2363 lines
67 KiB
Lua
2363 lines
67 KiB
Lua
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,
|
|
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 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", "<C-j>", "<Nop>", opts)
|
|
vim.keymap.set("n", "<C-k>", "<Nop>", opts)
|
|
end
|
|
|
|
local function help_lines()
|
|
return {
|
|
"Test-Samurai Help",
|
|
"",
|
|
"TSam Commands:",
|
|
" TSamNearest <leader>tn",
|
|
" TSamFile <leader>tf",
|
|
" TSamAll <leader>ta",
|
|
" TSamLast <leader>tl",
|
|
" TSamFailedOnly <leader>te",
|
|
" TSamShowOutput <leader>to",
|
|
"",
|
|
"Standard Keymaps:",
|
|
" <leader>qn Close floats + jump to first quickfix entry",
|
|
" <leader>nf Next [ FAIL ] in listing",
|
|
" <leader>pf Previous [ FAIL ] in listing",
|
|
" <leader>sf Filter listing to [ FAIL ] only",
|
|
" <leader>ss Filter listing to [ SKIP ] only",
|
|
" <leader>sa Show all listing entries (clear filter)",
|
|
" <leader>tt Run the test under the cursor",
|
|
"",
|
|
"Testing-Float (Listing):",
|
|
" <cr> Open Detail-Float for selected test",
|
|
" <esc><esc> Close Testing-Float",
|
|
" <C-l> Focus Detail-Float (press l again for full)",
|
|
" <C-h> Focus Test-Listing-Float",
|
|
" <leader>z Toggle Detail-Float full width",
|
|
" <leader>o Jump to test location",
|
|
" ? Show this help",
|
|
"",
|
|
"Testing-Float (Detail):",
|
|
" <esc><esc> Close Testing-Float",
|
|
" <C-h> Focus Test-Listing-Float",
|
|
" <C-w>h Focus Test-Listing-Float",
|
|
" <C-l> Focus Detail-Float",
|
|
" <leader>z Toggle Detail-Float full width",
|
|
" <C-c> Close Detail-Float",
|
|
" ? Show this help",
|
|
}
|
|
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_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 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 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
|
|
|
|
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)
|
|
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", "<esc><esc>", function()
|
|
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 })
|
|
vim.keymap.set("n", "<C-h>", function()
|
|
M.focus_listing()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<C-l>", function()
|
|
handle_ctrl_l_in_listing()
|
|
end, { buffer = buf, silent = true })
|
|
vim.keymap.set("n", "<leader>z", function()
|
|
M.toggle_detail_full()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>nf", function()
|
|
jump_listing_fail("next")
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>pf", function()
|
|
jump_listing_fail("prev")
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>o", function()
|
|
jump_to_listing_test()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>qn", function()
|
|
jump_to_first_quickfix()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>sf", function()
|
|
M.filter_listing_failures()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>ss", function()
|
|
M.filter_listing_skips()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>sa", function()
|
|
M.filter_listing_all()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>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
|
|
|
|
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", "<esc><esc>", function()
|
|
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 })
|
|
vim.keymap.set("n", "<C-h>", function()
|
|
M.focus_listing()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<C-l>", function()
|
|
handle_ctrl_l_in_listing()
|
|
end, { buffer = state.last_buf, silent = true })
|
|
vim.keymap.set("n", "<leader>z", function()
|
|
M.toggle_detail_full()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>nf", function()
|
|
jump_listing_fail("next")
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>pf", function()
|
|
jump_listing_fail("prev")
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>o", function()
|
|
jump_to_listing_test()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>qn", function()
|
|
jump_to_first_quickfix()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>sf", function()
|
|
M.filter_listing_failures()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>ss", function()
|
|
M.filter_listing_skips()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>sa", function()
|
|
M.filter_listing_all()
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>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 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 })
|
|
vim.keymap.set("n", "<C-h>", function()
|
|
M.focus_listing()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<C-l>", function()
|
|
M.focus_detail()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>z", function()
|
|
M.toggle_detail_full()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<C-c>", function()
|
|
close_detail_float()
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
vim.keymap.set("n", "<leader>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)
|
|
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
|
|
vim.notify("[test-samurai] No output captured for " .. test_name, vim.log.levels.WARN)
|
|
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
|
|
open_detail_split(help_lines(), "default")
|
|
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.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 {}
|
|
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"
|
|
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,
|
|
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.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
|