commit 4de8921a42e6c984aaf7a0cf9669018b7799581c Author: M.Schirmer Date: Tue Dec 23 14:10:06 2025 +0100 initialize with first MVP diff --git a/lua/test-samurai/config.lua b/lua/test-samurai/config.lua new file mode 100644 index 0000000..30f3c01 --- /dev/null +++ b/lua/test-samurai/config.lua @@ -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 diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua new file mode 100644 index 0000000..641cf57 --- /dev/null +++ b/lua/test-samurai/core.lua @@ -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", "", 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 diff --git a/lua/test-samurai/init.lua b/lua/test-samurai/init.lua new file mode 100644 index 0000000..609c585 --- /dev/null +++ b/lua/test-samurai/init.lua @@ -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 diff --git a/lua/test-samurai/runners/go.lua b/lua/test-samurai/runners/go.lua new file mode 100644 index 0000000..d714f0e --- /dev/null +++ b/lua/test-samurai/runners/go.lua @@ -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 diff --git a/lua/test-samurai/runners/js-jest.lua b/lua/test-samurai/runners/js-jest.lua new file mode 100644 index 0000000..4aa0cba --- /dev/null +++ b/lua/test-samurai/runners/js-jest.lua @@ -0,0 +1,7 @@ +local js = require("test-samurai.runners.js") + +return js.new({ + name = "js-jest", + framework = "jest", + command = { "npx", "jest" }, +}) diff --git a/lua/test-samurai/runners/js-mocha.lua b/lua/test-samurai/runners/js-mocha.lua new file mode 100644 index 0000000..c76f0dd --- /dev/null +++ b/lua/test-samurai/runners/js-mocha.lua @@ -0,0 +1,7 @@ +local js = require("test-samurai.runners.js") + +return js.new({ + name = "js-mocha", + framework = "mocha", + command = { "npx", "mocha" }, +}) diff --git a/lua/test-samurai/runners/js-vitest.lua b/lua/test-samurai/runners/js-vitest.lua new file mode 100644 index 0000000..d56b206 --- /dev/null +++ b/lua/test-samurai/runners/js-vitest.lua @@ -0,0 +1,7 @@ +local js = require("test-samurai.runners.js") + +return js.new({ + name = "js-vitest", + framework = "vitest", + command = { "npx", "vitest" }, +}) diff --git a/lua/test-samurai/runners/js.lua b/lua/test-samurai/runners/js.lua new file mode 100644 index 0000000..ad3f0fe --- /dev/null +++ b/lua/test-samurai/runners/js.lua @@ -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 diff --git a/lua/test-samurai/util.lua b/lua/test-samurai/util.lua new file mode 100644 index 0000000..953119d --- /dev/null +++ b/lua/test-samurai/util.lua @@ -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 diff --git a/plugin/test-samurai.lua b/plugin/test-samurai.lua new file mode 100644 index 0000000..b15c3f2 --- /dev/null +++ b/plugin/test-samurai.lua @@ -0,0 +1,10 @@ +if vim.g.loaded_test_samurai then + return +end +vim.g.loaded_test_samurai = true + +vim.api.nvim_create_user_command("TestNearest", function() + require("test-samurai").test_nearest() +end, { + desc = "Run nearest test with test-samurai", +}) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..90e05fd --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,2 @@ +vim.opt.runtimepath:append(vim.loop.cwd()) +require("plenary.busted") diff --git a/tests/test_samurai_core_spec.lua b/tests/test_samurai_core_spec.lua new file mode 100644 index 0000000..722f8ae --- /dev/null +++ b/tests/test_samurai_core_spec.lua @@ -0,0 +1,26 @@ +local test_samurai = require("test-samurai") +local core = require("test-samurai.core") + +describe("test-samurai core", function() + before_each(function() + test_samurai.setup() + end) + + it("selects Go runner for _test.go files", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/foo_test.go") + local runner = core.get_runner_for_buf(bufnr) + assert.is_not_nil(runner) + assert.equals("go", runner.name) + end) + + it("selects JS jest runner for *.test.ts files", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/foo.test.ts") + vim.bo[bufnr].filetype = "typescript" + + local runner = core.get_runner_for_buf(bufnr) + assert.is_not_nil(runner) + assert.equals("js-jest", runner.name) + end) +end) diff --git a/tests/test_samurai_go_spec.lua b/tests/test_samurai_go_spec.lua new file mode 100644 index 0000000..bc6498a --- /dev/null +++ b/tests/test_samurai_go_spec.lua @@ -0,0 +1,85 @@ +local go_runner = require("test-samurai.runners.go") + +describe("test-samurai go runner", function() + it("detects Go test files by suffix", function() + local bufnr1 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr1, "/tmp/foo_test.go") + assert.is_true(go_runner.is_test_file(bufnr1)) + + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, "/tmp/foo.go") + assert.is_false(go_runner.is_test_file(bufnr2)) + end) + + it("finds subtest when cursor is inside t.Run block", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/foo_test.go") + local lines = { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside first", + " })", + "", + " t.Run(\"second\", func(t *testing.T) {", + " -- inside second", + " })", + "}", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(markers, opts) + return { "/tmp/go.mod" } + end + + local row_inside_first = 5 + local spec, err = go_runner.find_nearest(bufnr, row_inside_first, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.is_not_nil(spec) + assert.equals("TestFoo/first", spec.test_path) + assert.equals("subtest", spec.scope) + assert.equals("/tmp/foo_test.go", spec.file) + assert.equals("/tmp", spec.cwd) + end) + + it("falls back to whole test function when between subtests", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/foo_test.go") + local lines = { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside first", + " })", + "", + " t.Run(\"second\", func(t *testing.T) {", + " -- inside second", + " })", + "}", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(markers, opts) + return { "/tmp/go.mod" } + end + + local row_between = 7 + local spec, err = go_runner.find_nearest(bufnr, row_between, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.is_not_nil(spec) + assert.equals("TestFoo", spec.test_path) + assert.equals("function", spec.scope) + end) +end) diff --git a/tests/test_samurai_js_spec.lua b/tests/test_samurai_js_spec.lua new file mode 100644 index 0000000..9df34ac --- /dev/null +++ b/tests/test_samurai_js_spec.lua @@ -0,0 +1,53 @@ +local jest = require("test-samurai.runners.js-jest") + +describe("test-samurai js runner (jest)", function() + it("detects JS/TS test files by name and filetype", function() + local bufnr1 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr1, "/tmp/foo.test.ts") + vim.bo[bufnr1].filetype = "typescript" + assert.is_true(jest.is_test_file(bufnr1)) + + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, "/tmp/foo.ts") + vim.bo[bufnr2].filetype = "typescript" + assert.is_false(jest.is_test_file(bufnr2)) + end) + + it("finds nearest it() call as test name", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/foo.test.ts") + vim.bo[bufnr].filetype = "typescript" + local lines = { + "describe(\"outer\", function() {", + " it(\"inner 1\", function() {", + " -- inside 1", + " })", + "", + " it(\"inner 2\", function() {", + " -- inside 2", + " })", + "})", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(markers, opts) + return { "/tmp/package.json" } + end + + local row_inside_second = 6 + local spec, err = jest.find_nearest(bufnr, row_inside_second, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.is_not_nil(spec) + assert.equals("inner 2", spec.test_name) + assert.equals("jest", spec.framework) + assert.equals("/tmp/foo.test.ts", spec.file) + assert.equals("/tmp", spec.cwd) + + local cmd_spec = jest.build_command(spec) + assert.are.same({ "npx", "jest", "/tmp/foo.test.ts", "-t", "inner 2" }, cmd_spec.cmd) + end) +end)