445 lines
11 KiB
Lua
445 lines
11 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,
|
|
autocmds_set = false,
|
|
}
|
|
|
|
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({ "BufEnter", "WinEnter" }, {
|
|
group = group,
|
|
callback = function()
|
|
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
|
|
local cur = vim.api.nvim_get_current_win()
|
|
if cur ~= state.last_win then
|
|
pcall(vim.api.nvim_win_close, state.last_win, true)
|
|
state.last_win = nil
|
|
end
|
|
end
|
|
end,
|
|
})
|
|
|
|
state.autocmds_set = true
|
|
end
|
|
|
|
function M.setup()
|
|
load_runners()
|
|
ensure_output_autocmds()
|
|
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
|
|
|
|
if path:sub(-8) == "_test.go" then
|
|
local ok, go = pcall(require, "test-samurai.runners.go")
|
|
if ok and type(go) == "table" then
|
|
return go
|
|
end
|
|
end
|
|
|
|
if path:find(".test.", 1, true) or path:find(".spec.", 1, true) then
|
|
local ok, jsjest = pcall(require, "test-samurai.runners.js-jest")
|
|
if ok and type(jsjest) == "table" then
|
|
return jsjest
|
|
end
|
|
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 create_output_win(initial_lines)
|
|
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then
|
|
pcall(vim.api.nvim_win_close, state.last_win, true)
|
|
state.last_win = nil
|
|
end
|
|
|
|
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()
|
|
|
|
local win = vim.api.nvim_open_win(buf, true, {
|
|
relative = "editor",
|
|
width = width,
|
|
height = height,
|
|
row = row,
|
|
col = col,
|
|
style = "minimal",
|
|
border = "rounded",
|
|
})
|
|
|
|
vim.keymap.set("n", "<esc><esc>", function()
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
vim.api.nvim_win_close(win, true)
|
|
if state.last_win == win then
|
|
state.last_win = nil
|
|
end
|
|
end
|
|
end, { buffer = buf, nowait = true, silent = true })
|
|
|
|
state.last_win = win
|
|
state.last_buf = buf
|
|
|
|
return buf, win
|
|
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
|
|
|
|
local width, height, row, col = float_geometry()
|
|
|
|
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",
|
|
})
|
|
|
|
vim.keymap.set("n", "<esc><esc>", function()
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
vim.api.nvim_win_close(win, true)
|
|
if state.last_win == win then
|
|
state.last_win = nil
|
|
end
|
|
end
|
|
end, { buffer = state.last_buf, nowait = true, silent = true })
|
|
|
|
state.last_win = win
|
|
|
|
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 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 run_command(command)
|
|
local cmd = command.cmd
|
|
local cwd = command.cwd or vim.loop.cwd()
|
|
|
|
local header = "$ " .. table.concat(cmd, " ")
|
|
local buf = nil
|
|
local has_output = false
|
|
|
|
run_cmd(cmd, cwd, {
|
|
on_start = function()
|
|
buf = select(1, create_output_win({ header, "", "[running...]" }))
|
|
end,
|
|
on_stdout = function(lines)
|
|
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
|
|
append_lines(buf, lines)
|
|
end,
|
|
on_stderr = function(lines)
|
|
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
|
|
append_lines(buf, lines)
|
|
end,
|
|
on_exit = function(code)
|
|
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
|
|
end
|
|
append_lines(buf, { "", "[exit code] " .. tostring(code) })
|
|
end,
|
|
})
|
|
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 for this file", 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
|
|
|
|
run_command(command)
|
|
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 for this file", 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
|
|
|
|
run_command(command)
|
|
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 for this file", 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
|
|
|
|
run_command(command)
|
|
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
|