From 3285cd2383f0fec09c03c4c4bfdd9e5686077f33 Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Wed, 31 Dec 2025 16:11:05 +0100 Subject: [PATCH] create complete runner by AGENTS.md and ChatGPT-Codex --- AGENTS.md | 139 +++++++++++++++++++ README.md | 55 ++++++++ lua/test-samurai-go-runner/init.lua | 198 ++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 lua/test-samurai-go-runner/init.lua diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4edb07d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,139 @@ +# runner-agents.md — test-samurai Runner-API + +Ziel: Diese Datei beschreibt die öffentliche Runner-API, die ein neuer Runner +implementieren muss, damit alle Commands vollständig unterstützt werden. + +## Modulform (Pflicht) + +- Der Runner ist ein Lua-Modul, das eine Table mit Funktionen zurückgibt. +- Beispiel: + - `local runner = {}` + - `return runner` + +## Pflichtfunktionen (volle Command-Unterstützung) + +- `is_test_file(bufnr) -> boolean` + - Wird für die Runner-Auswahl genutzt. +- `find_nearest(bufnr, row, col) -> spec|nil, err?` + - Für `TSamNearest`. + - `spec.file` muss gesetzt sein. + - Bei Fehler/kein Treffer: `nil, "reason"` zurückgeben. +- `build_command(spec) -> command_spec` + - Für `TSamNearest`. +- `build_file_command(bufnr) -> command_spec` + - Für `TSamFile`. +- `build_all_command(bufnr) -> command_spec` + - Für `TSamAll`. +- `build_failed_command(last_command, failures, scope_kind) -> command_spec` + - Für `TSamFailedOnly`. + +## Output-Parsing (Listing + Summary) + +Genau eine der folgenden Varianten muss vorhanden sein: + +- `parse_results(output) -> results` + - `results` muss enthalten: + - `passes` (Array von Namen) + - `failures` (Array von Namen) + - `skips` (Array von Namen) + - Optional: + - `display = { passes = {}, failures = {}, skips = {} }` + - `failures_all` (für Streaming-Parser, um alle bisherigen Failures zu liefern) + - Wenn `display` fehlt, werden `passes/failures/skips` direkt im Listing angezeigt. +- oder `output_parser() -> { on_line, on_complete }` + - `on_line(line, state)` kann `results` liefern (siehe oben). + - `on_complete(output, state)` kann `results` liefern (siehe oben). + +## Detail-Output (TSamShowOutput / im Listing) + +- `parse_test_output(output) -> table` + - Rückgabeform: + - `{ [test_name] = { "line1", "line2", ... } }` + - `test_name` muss mit `results.*` korrespondieren (gleiches Namensschema). + +## Quickfix-Unterstützung (Failures) + +- `collect_failed_locations(failures, command, scope_kind) -> items` + - `items`: Array von Quickfix-Items + - `{ filename = "...", lnum = , col = , text = "..." }` + +## Erwartete Datenformen + +- `command_spec`: + - `{ cmd = { "binary", "arg1", ... }, cwd = "..." }` + - `cmd` darf nicht leer sein. + - `cwd` ist optional; wenn nicht gesetzt, nutzt der Core das aktuelle CWD. +- `spec` (von `find_nearest`): + - Muss mindestens `file` enthalten, z. B.: + - `{ file = "...", cwd = "...", test_name = "...", full_name = "...", kind = "..." }` + +## Optional empfohlene Metadaten + +- `name` (String) + - Wird in Fehlermeldungen und Logs angezeigt. +- `framework` (String) + - Wird zur Framework-Auswahl (z. B. JS) genutzt. + +## Prompt-Beispiel + +"Erstelle mir anhand der `runner-agents.md` einen neuen Runner für Rust." + +## Minimaler Runner-Skeleton (Template) + +```lua +local runner = { + name = "my-runner", + framework = "my-framework", +} + +function runner.is_test_file(bufnr) + return false +end + +function runner.find_nearest(bufnr, row, col) + return nil, "no test call found" +end + +function runner.build_command(spec) + return { cmd = { "echo", "not-implemented" }, cwd = spec.cwd } +end + +function runner.build_file_command(bufnr) + return { cmd = { "echo", "not-implemented" } } +end + +function runner.build_all_command(bufnr) + return { cmd = { "echo", "not-implemented" } } +end + +function runner.build_failed_command(last_command, failures, scope_kind) + return { cmd = { "echo", "not-implemented" }, cwd = last_command and last_command.cwd or nil } +end + +function runner.parse_results(output) + return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } } +end + +function runner.parse_test_output(output) + return {} +end + +function runner.collect_failed_locations(failures, command, scope_kind) + return {} +end + +return runner +``` + +## Checkliste für neue Runner + +- is_test_file implementiert +- find_nearest implementiert (setzt `spec.file`) +- build_command implementiert +- build_file_command implementiert +- build_all_command implementiert +- build_failed_command implementiert +- parse_results oder output_parser implementiert +- parse_test_output implementiert +- collect_failed_locations implementiert +- command_spec `{ cmd, cwd }` korrekt zurückgegeben diff --git a/README.md b/README.md new file mode 100644 index 0000000..39204de --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# test-samurai-go-runner.nvim + +Go runner for `test-samurai.nvim`. + +Main plugin: https://gitea.mschirmer.com/m13r/test-samurai.nvim + +## Features + +- Detects Go test files (`*_test.go`). +- Finds nearest `Test*`, `Example*`, and `Benchmark*` functions. +- Builds `go test` commands for nearest, file, all, and failed-only runs. +- Parses `go test` output to list passes, failures, and skips. + +## Installation (lazy.nvim) + +```lua +{ + "m13r/test-samurai.nvim", + dependencies = { + "m13r/test-samurai-go-runner.nvim", + }, + config = function() + require("test-samurai").setup({ + runners = { + require("test-samurai-go-runner"), + }, + }) + end, +} +``` + +## Local Development (lazy.nvim) + +```lua +{ + "m13r/test-samurai.nvim", + dependencies = { + { + "test-samurai-go-runner.nvim", + dir = "/absolute/path/to/test-samurai-go-runner.nvim", + }, + }, + config = function() + require("test-samurai").setup({ + runners = { + require("test-samurai-go-runner"), + }, + }) + end, +} +``` + +## Usage + +Use the standard `test-samurai.nvim` commands (e.g. `TSamNearest`, `TSamFile`, `TSamAll`, `TSamFailedOnly`). diff --git a/lua/test-samurai-go-runner/init.lua b/lua/test-samurai-go-runner/init.lua new file mode 100644 index 0000000..99d0d1b --- /dev/null +++ b/lua/test-samurai-go-runner/init.lua @@ -0,0 +1,198 @@ +local runner = { + name = "go", + framework = "go", +} + +local function is_test_name(name) + return name:match("^Test%w+") or name:match("^Example%w+") or name:match("^Benchmark%w+") +end + +local function escape_regex(text) + if vim and vim.pesc then + return vim.pesc(text) + end + return text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") +end + +local function find_go_mod_root(start_dir) + if not start_dir or start_dir == "" then + start_dir = vim.fn.getcwd() + end + local start_path = vim.fn.fnamemodify(start_dir, ":p") + local mod_path = vim.fn.findfile("go.mod", start_path .. ";") + if mod_path == "" then + return nil + end + return vim.fn.fnamemodify(mod_path, ":p:h") +end + +local function collect_results(output) + local passes = {} + local failures = {} + local skips = {} + local seen = { passes = {}, failures = {}, skips = {} } + + for line in output:gmatch("[^\n]+") do + local name = line:match("^%-%-%- PASS:%s+(%S+)") + if name and not seen.passes[name] then + seen.passes[name] = true + passes[#passes + 1] = name + end + + name = line:match("^%-%-%- FAIL:%s+(%S+)") + if name and not seen.failures[name] then + seen.failures[name] = true + failures[#failures + 1] = name + end + + name = line:match("^%-%-%- SKIP:%s+(%S+)") + if name and not seen.skips[name] then + seen.skips[name] = true + skips[#skips + 1] = name + end + end + + return passes, failures, skips +end + +function runner.is_test_file(bufnr) + local name = vim.api.nvim_buf_get_name(bufnr) + return name:sub(-8) == "_test.go" +end + +function runner.find_nearest(bufnr, row, _col) + local line_count = vim.api.nvim_buf_line_count(bufnr) + local idx = row or (line_count - 1) + if idx < 0 then + idx = 0 + elseif idx >= line_count then + idx = line_count - 1 + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, line_count, false) + for i = idx + 1, 1, -1 do + local line = lines[i] + local name = line:match("^%s*func%s+(Test%w+)%s*%(") + local kind = "test" + if not name then + name = line:match("^%s*func%s+(Example%w+)%s*%(") + kind = "example" + end + if not name then + name = line:match("^%s*func%s+(Benchmark%w+)%s*%(") + kind = "benchmark" + end + if name and is_test_name(name) then + local file = vim.api.nvim_buf_get_name(bufnr) + local cwd = vim.fn.fnamemodify(file, ":p:h") + return { + file = file, + cwd = cwd, + test_name = name, + full_name = name, + kind = kind, + } + end + end + + return nil, "no test function found" +end + +function runner.build_command(spec) + local name = spec.full_name or spec.test_name + if spec.kind == "benchmark" then + return { + cmd = { "go", "test", "-run", "^$", "-bench", "^" .. escape_regex(name) .. "$" }, + cwd = spec.cwd, + } + end + + return { + cmd = { "go", "test", "-run", "^" .. escape_regex(name) .. "$" }, + cwd = spec.cwd, + } +end + +function runner.build_file_command(bufnr) + local file = vim.api.nvim_buf_get_name(bufnr) + local cwd = vim.fn.fnamemodify(file, ":p:h") + return { cmd = { "go", "test" }, cwd = cwd } +end + +function runner.build_all_command(bufnr) + local file = vim.api.nvim_buf_get_name(bufnr) + local cwd = vim.fn.fnamemodify(file, ":p:h") + local root = find_go_mod_root(cwd) or cwd + return { cmd = { "go", "test", "./..." }, cwd = root } +end + +function runner.build_failed_command(last_command, failures, _scope_kind) + if not failures or #failures == 0 then + if last_command and last_command.cmd then + return { cmd = last_command.cmd, cwd = last_command.cwd } + end + return { cmd = { "go", "test" } } + end + + local escaped = {} + for _, name in ipairs(failures) do + escaped[#escaped + 1] = escape_regex(name) + end + + local pattern = "^(" .. table.concat(escaped, "|") .. ")$" + return { + cmd = { "go", "test", "-run", pattern }, + cwd = last_command and last_command.cwd or nil, + } +end + +function runner.parse_results(output) + local passes, failures, skips = collect_results(output) + return { passes = passes, failures = failures, skips = skips } +end + +function runner.parse_test_output(output) + local results = {} + local current = nil + + for line in output:gmatch("[^\n]+") do + local name = line:match("^=== RUN%s+(%S+)") + if name then + current = name + results[current] = results[current] or {} + elseif line:match("^%-%-%- %u+:%s+%S+") then + current = nil + elseif current then + results[current] = results[current] or {} + results[current][#results[current] + 1] = line + end + end + + return results +end + +function runner.collect_failed_locations(failures, _command, _scope_kind) + local items = {} + if not failures then + return items + end + + for _, failure in ipairs(failures) do + local filename, lnum, col = failure:match("([^:%s]+%.go):(%d+):(%d+)") + if not filename then + filename, lnum = failure:match("([^:%s]+%.go):(%d+)") + end + if filename and lnum then + items[#items + 1] = { + filename = filename, + lnum = tonumber(lnum), + col = tonumber(col) or 1, + text = failure, + } + end + end + + return items +end + +return runner