initialize with first MVP
This commit is contained in:
26
lua/test-samurai/config.lua
Normal file
26
lua/test-samurai/config.lua
Normal file
@@ -0,0 +1,26 @@
|
||||
local M = {}
|
||||
|
||||
M.defaults = {
|
||||
runner_modules = {
|
||||
"test-samurai.runners.go",
|
||||
"test-samurai.runners.js-jest",
|
||||
"test-samurai.runners.js-mocha",
|
||||
"test-samurai.runners.js-vitest",
|
||||
},
|
||||
}
|
||||
|
||||
M.options = vim.deepcopy(M.defaults)
|
||||
|
||||
function M.setup(opts)
|
||||
if not opts or next(opts) == nil then
|
||||
M.options = vim.deepcopy(M.defaults)
|
||||
return
|
||||
end
|
||||
M.options = vim.tbl_deep_extend("force", M.defaults, opts)
|
||||
end
|
||||
|
||||
function M.get()
|
||||
return M.options
|
||||
end
|
||||
|
||||
return M
|
||||
175
lua/test-samurai/core.lua
Normal file
175
lua/test-samurai/core.lua
Normal file
@@ -0,0 +1,175 @@
|
||||
local config = require("test-samurai.config")
|
||||
|
||||
local M = {}
|
||||
|
||||
local state = {
|
||||
runners = {},
|
||||
last_win = nil,
|
||||
last_buf = nil,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function M.setup()
|
||||
load_runners()
|
||||
end
|
||||
|
||||
function M.reload_runners()
|
||||
load_runners()
|
||||
end
|
||||
|
||||
function M.get_runner_for_buf(bufnr)
|
||||
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
|
||||
return runner
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function open_float(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)
|
||||
end
|
||||
if state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf) then
|
||||
pcall(vim.api.nvim_buf_delete, state.last_buf, { force = true })
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe")
|
||||
vim.api.nvim_buf_set_option(buf, "filetype", "test-samurai-output")
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
|
||||
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)
|
||||
end
|
||||
end, { buffer = buf, nowait = true, silent = true })
|
||||
|
||||
state.last_win = win
|
||||
state.last_buf = buf
|
||||
end
|
||||
|
||||
local function run_cmd(cmd, cwd, on_exit)
|
||||
if vim.system then
|
||||
vim.system(cmd, { cwd = cwd, text = true }, function(obj)
|
||||
local code = obj.code or -1
|
||||
local stdout = obj.stdout or ""
|
||||
local stderr = obj.stderr or ""
|
||||
vim.schedule(function()
|
||||
on_exit(code, stdout, stderr)
|
||||
end)
|
||||
end)
|
||||
else
|
||||
local stdout_chunks = {}
|
||||
local stderr_chunks = {}
|
||||
vim.fn.jobstart(cmd, {
|
||||
cwd = cwd,
|
||||
stdout_buffered = true,
|
||||
stderr_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if data then
|
||||
table.insert(stdout_chunks, table.concat(data, "\n"))
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data then
|
||||
table.insert(stderr_chunks, table.concat(data, "\n"))
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
local stdout = table.concat(stdout_chunks, "\n")
|
||||
local stderr = table.concat(stderr_chunks, "\n")
|
||||
on_exit(code, stdout, stderr)
|
||||
end,
|
||||
})
|
||||
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
|
||||
|
||||
local cmd = command.cmd
|
||||
local cwd = command.cwd or vim.loop.cwd()
|
||||
|
||||
run_cmd(cmd, cwd, function(code, stdout, stderr)
|
||||
local header = "$ " .. table.concat(cmd, " ")
|
||||
local lines = { header, "" }
|
||||
if stdout ~= "" then
|
||||
local out_lines = vim.split(stdout, "\n", { plain = true })
|
||||
vim.list_extend(lines, out_lines)
|
||||
end
|
||||
if stderr ~= "" then
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, "[stderr]")
|
||||
local err_lines = vim.split(stderr, "\n", { plain = true })
|
||||
vim.list_extend(lines, err_lines)
|
||||
end
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, "[exit code] " .. tostring(code))
|
||||
open_float(lines)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
15
lua/test-samurai/init.lua
Normal file
15
lua/test-samurai/init.lua
Normal file
@@ -0,0 +1,15 @@
|
||||
local config = require("test-samurai.config")
|
||||
local core = require("test-samurai.core")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.setup(opts)
|
||||
config.setup(opts or {})
|
||||
core.setup()
|
||||
end
|
||||
|
||||
function M.test_nearest()
|
||||
core.run_nearest()
|
||||
end
|
||||
|
||||
return M
|
||||
145
lua/test-samurai/runners/go.lua
Normal file
145
lua/test-samurai/runners/go.lua
Normal file
@@ -0,0 +1,145 @@
|
||||
local util = require("test-samurai.util")
|
||||
|
||||
local runner = {
|
||||
name = "go",
|
||||
}
|
||||
|
||||
local function find_block_end(lines, start_idx)
|
||||
local depth = 0
|
||||
local started = false
|
||||
for i = start_idx, #lines do
|
||||
local line = lines[i]
|
||||
for j = 1, #line do
|
||||
local ch = line:sub(j, j)
|
||||
if ch == "{" then
|
||||
depth = depth + 1
|
||||
started = true
|
||||
elseif ch == "}" then
|
||||
if started then
|
||||
depth = depth - 1
|
||||
if depth == 0 then
|
||||
return i - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return #lines - 1
|
||||
end
|
||||
|
||||
local function find_test_functions(lines)
|
||||
local funcs = {}
|
||||
for i, line in ipairs(lines) do
|
||||
local name = line:match("^%s*func%s+([%w_]+)%s*%(")
|
||||
if not name then
|
||||
name = line:match("^%s*func%s+%([^)]-%)%s+([%w_]+)%s*%(")
|
||||
end
|
||||
if name and line:find("%*testing%.T") then
|
||||
local start_0 = i - 1
|
||||
local end_0 = find_block_end(lines, i)
|
||||
table.insert(funcs, {
|
||||
name = name,
|
||||
start = start_0,
|
||||
["end"] = end_0,
|
||||
})
|
||||
end
|
||||
end
|
||||
return funcs
|
||||
end
|
||||
|
||||
local function find_t_runs(lines, func)
|
||||
local subtests = {}
|
||||
for i = func.start + 1, func["end"] do
|
||||
local line = lines[i + 1]
|
||||
if line then
|
||||
local name = line:match("t%.Run%(%s*['\"]([^'\"]+)['\"]")
|
||||
if name then
|
||||
local start_idx = i + 1
|
||||
local end_0 = find_block_end(lines, start_idx)
|
||||
table.insert(subtests, {
|
||||
name = name,
|
||||
start = start_idx - 1,
|
||||
["end"] = end_0,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
return subtests
|
||||
end
|
||||
|
||||
local function escape_pattern(str)
|
||||
local escaped = str:gsub("(%W)", "%%%1")
|
||||
return "^" .. escaped .. "$"
|
||||
end
|
||||
|
||||
function runner.is_test_file(bufnr)
|
||||
local path = util.get_buf_path(bufnr)
|
||||
if not path or path == "" then
|
||||
return false
|
||||
end
|
||||
return path:sub(-8) == "_test.go"
|
||||
end
|
||||
|
||||
function runner.find_nearest(bufnr, row, _col)
|
||||
if not runner.is_test_file(bufnr) then
|
||||
return nil, "not a Go test file"
|
||||
end
|
||||
|
||||
local lines = util.get_buf_lines(bufnr)
|
||||
local funcs = find_test_functions(lines)
|
||||
|
||||
local current
|
||||
for _, f in ipairs(funcs) do
|
||||
if row >= f.start and row <= f["end"] then
|
||||
current = f
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not current then
|
||||
return nil, "cursor not inside a test function"
|
||||
end
|
||||
|
||||
local subtests = find_t_runs(lines, current)
|
||||
local inside_sub
|
||||
for _, sub in ipairs(subtests) do
|
||||
if row >= sub.start and row <= sub["end"] then
|
||||
inside_sub = sub
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local path = util.get_buf_path(bufnr)
|
||||
local root = util.find_root(path, { "go.mod", ".git" })
|
||||
|
||||
if inside_sub then
|
||||
local full = current.name .. "/" .. inside_sub.name
|
||||
return {
|
||||
file = path,
|
||||
cwd = root,
|
||||
test_path = full,
|
||||
scope = "subtest",
|
||||
func = current.name,
|
||||
subtest = inside_sub.name,
|
||||
}
|
||||
else
|
||||
return {
|
||||
file = path,
|
||||
cwd = root,
|
||||
test_path = current.name,
|
||||
scope = "function",
|
||||
func = current.name,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
function runner.build_command(spec)
|
||||
local pattern = escape_pattern(spec.test_path)
|
||||
local cmd = { "go", "test", "./...", "-run", pattern }
|
||||
return {
|
||||
cmd = cmd,
|
||||
cwd = spec.cwd,
|
||||
}
|
||||
end
|
||||
|
||||
return runner
|
||||
7
lua/test-samurai/runners/js-jest.lua
Normal file
7
lua/test-samurai/runners/js-jest.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
local js = require("test-samurai.runners.js")
|
||||
|
||||
return js.new({
|
||||
name = "js-jest",
|
||||
framework = "jest",
|
||||
command = { "npx", "jest" },
|
||||
})
|
||||
7
lua/test-samurai/runners/js-mocha.lua
Normal file
7
lua/test-samurai/runners/js-mocha.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
local js = require("test-samurai.runners.js")
|
||||
|
||||
return js.new({
|
||||
name = "js-mocha",
|
||||
framework = "mocha",
|
||||
command = { "npx", "mocha" },
|
||||
})
|
||||
7
lua/test-samurai/runners/js-vitest.lua
Normal file
7
lua/test-samurai/runners/js-vitest.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
local js = require("test-samurai.runners.js")
|
||||
|
||||
return js.new({
|
||||
name = "js-vitest",
|
||||
framework = "vitest",
|
||||
command = { "npx", "vitest" },
|
||||
})
|
||||
148
lua/test-samurai/runners/js.lua
Normal file
148
lua/test-samurai/runners/js.lua
Normal file
@@ -0,0 +1,148 @@
|
||||
local util = require("test-samurai.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
local default_filetypes = {
|
||||
javascript = true,
|
||||
javascriptreact = true,
|
||||
typescript = true,
|
||||
typescriptreact = true,
|
||||
}
|
||||
|
||||
local default_patterns = { ".test.", ".spec." }
|
||||
|
||||
local function is_js_test_file(bufnr, filetypes, patterns)
|
||||
local ft = vim.bo[bufnr].filetype
|
||||
if not filetypes[ft] then
|
||||
return false
|
||||
end
|
||||
local path = util.get_buf_path(bufnr)
|
||||
if not path or path == "" then
|
||||
return false
|
||||
end
|
||||
for _, pat in ipairs(patterns) do
|
||||
if path:find(pat, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function find_nearest_test(bufnr, row)
|
||||
local lines = util.get_buf_lines(bufnr)
|
||||
local start = row + 1
|
||||
if start > #lines then
|
||||
start = #lines
|
||||
elseif start < 1 then
|
||||
start = 1
|
||||
end
|
||||
for i = start, 1, -1 do
|
||||
local line = lines[i]
|
||||
local call, name = line:match("^%s*(it|test|describe)%s*%(%s*['"`]([^'"`]+)['"`]")
|
||||
if call and name then
|
||||
return {
|
||||
kind = call,
|
||||
name = name,
|
||||
line = i - 1,
|
||||
}
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function M.new(opts)
|
||||
local cfg = opts or {}
|
||||
local runner = {}
|
||||
|
||||
runner.name = cfg.name or "js"
|
||||
runner.framework = cfg.framework or "jest"
|
||||
runner.command = cfg.command or { "npx", runner.framework }
|
||||
|
||||
runner.filetypes = {}
|
||||
if cfg.filetypes then
|
||||
for _, ft in ipairs(cfg.filetypes) do
|
||||
runner.filetypes[ft] = true
|
||||
end
|
||||
else
|
||||
runner.filetypes = vim.deepcopy(default_filetypes)
|
||||
end
|
||||
|
||||
runner.patterns = cfg.patterns or default_patterns
|
||||
|
||||
function runner.is_test_file(bufnr)
|
||||
return is_js_test_file(bufnr, runner.filetypes, runner.patterns)
|
||||
end
|
||||
|
||||
function runner.find_nearest(bufnr, row, _col)
|
||||
if not runner.is_test_file(bufnr) then
|
||||
return nil, "not a JS/TS test file"
|
||||
end
|
||||
local hit = find_nearest_test(bufnr, row)
|
||||
if not hit then
|
||||
return nil, "no test call found"
|
||||
end
|
||||
local path = util.get_buf_path(bufnr)
|
||||
local root = util.find_root(path, {
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.js",
|
||||
"package.json",
|
||||
"node_modules",
|
||||
})
|
||||
return {
|
||||
file = path,
|
||||
cwd = root,
|
||||
framework = runner.framework,
|
||||
test_name = hit.name,
|
||||
kind = hit.kind,
|
||||
}
|
||||
end
|
||||
|
||||
local function build_jest(spec)
|
||||
local cmd = vim.deepcopy(runner.command)
|
||||
table.insert(cmd, spec.file)
|
||||
table.insert(cmd, "-t")
|
||||
table.insert(cmd, spec.test_name)
|
||||
return cmd
|
||||
end
|
||||
|
||||
local function build_mocha(spec)
|
||||
local cmd = vim.deepcopy(runner.command)
|
||||
table.insert(cmd, spec.file)
|
||||
table.insert(cmd, "--grep")
|
||||
table.insert(cmd, spec.test_name)
|
||||
return cmd
|
||||
end
|
||||
|
||||
local function build_vitest(spec)
|
||||
local cmd = vim.deepcopy(runner.command)
|
||||
table.insert(cmd, spec.file)
|
||||
table.insert(cmd, "-t")
|
||||
table.insert(cmd, spec.test_name)
|
||||
return cmd
|
||||
end
|
||||
|
||||
function runner.build_command(spec)
|
||||
local fw = runner.framework
|
||||
local cmd
|
||||
if fw == "jest" then
|
||||
cmd = build_jest(spec)
|
||||
elseif fw == "mocha" then
|
||||
cmd = build_mocha(spec)
|
||||
elseif fw == "vitest" then
|
||||
cmd = build_vitest(spec)
|
||||
else
|
||||
cmd = vim.deepcopy(runner.command)
|
||||
table.insert(cmd, spec.file)
|
||||
end
|
||||
return {
|
||||
cmd = cmd,
|
||||
cwd = spec.cwd,
|
||||
}
|
||||
end
|
||||
|
||||
return runner
|
||||
end
|
||||
|
||||
return M
|
||||
27
lua/test-samurai/util.lua
Normal file
27
lua/test-samurai/util.lua
Normal file
@@ -0,0 +1,27 @@
|
||||
local M = {}
|
||||
|
||||
function M.get_buf_lines(bufnr)
|
||||
return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
end
|
||||
|
||||
function M.get_buf_path(bufnr)
|
||||
local name = vim.api.nvim_buf_get_name(bufnr)
|
||||
if name == "" then
|
||||
return nil
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
function M.find_root(file, markers)
|
||||
if not file or file == "" then
|
||||
return vim.loop.cwd()
|
||||
end
|
||||
local dir = vim.fs.dirname(file)
|
||||
local found = vim.fs.find(markers, { path = dir, upward = true })
|
||||
if found and #found > 0 then
|
||||
return vim.fs.dirname(found[1])
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user