4 files changed,
895 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-03-30 18:13:19 +0300
Change ID:
mzrtynzklosqurquuotpsvuxuxysslvr
jump to
| A | lua/utest.lua |
| A | lua/utest/golang.lua |
| A | readme |
| A | stylua.toml |
A
lua/utest.lua
··· 1 +---@class utest.Adapter 2 +---@field ft string 3 +---@field query string 4 +---@field get_package_dir fun(file:string):string 5 +---@field build_file_command fun(file:string):string[] 6 +---@field build_command fun(test:table, file:string) -- FIXME: `table` 7 +---@field parse_output fun(output:string[], file:string):utest.AdapterTestResult 8 +---@field extract_test_output fun(output:string[], test_name:string|nil):string[] 9 +---@field extract_error_message fun(output:string[]):string|nil 10 + 11 +---@alias utest.TestStatus "pass"|"fail"|"running" 12 + 13 +---@class utest.Test 14 +---@field name string 15 +---@field file string 16 +---@field line number 17 +---@field end_line number 18 +---@field col number 19 +---@field end_col number 20 +---@field is_subtest boolean 21 +---@field parent string|nil 22 + 23 +---@class utest.AdapterTestResult 24 +---@field name string 25 +---@field status "pass"|"fail" 26 +---@field output string[] 27 +---@field error_line number|nil 28 + 29 +local S = { jobs = {}, results = {} } 30 +local H = { 31 + ---@type table<string, utest.Adapter> 32 + adapters = {}, 33 + ---@type table<string, vim.treesitter.Query> 34 + queries = {}, 35 + 36 + sns = nil, 37 + dns = nil, 38 + extmarks = {}, 39 + diagnostics = {}, 40 +} 41 + 42 +local utest = {} 43 +utest.config = { 44 + icons = { success = "", failed = "", running = "" }, -- TODO: add skipped 45 + timeout = 30, 46 +} 47 + 48 +function utest.setup(opts) 49 + utest.config = vim.tbl_deep_extend("keep", utest.config, opts) 50 + 51 + H.sns = vim.api.nvim_create_namespace "utest_signs" 52 + H.dns = vim.api.nvim_create_namespace "utest_diagnostics" 53 + H.adapters.go = require "utest.golang" 54 +end 55 + 56 +function utest.run() 57 + local bufnr = vim.api.nvim_get_current_buf() 58 + local adapter = H.adapters[vim.bo[bufnr].filetype] 59 + if not adapter then 60 + vim.notify("[utest] no adapter for this filetype", vim.log.levels.WARN) 61 + return 62 + end 63 + 64 + local cursor = vim.api.nvim_win_get_cursor(0) 65 + local cursor_line = cursor[1] - 1 66 + 67 + local test = H.find_nearest_test(bufnr, cursor_line, adapter) 68 + if not test then 69 + vim.notify("[utest] no near test found", vim.log.levels.INFO) 70 + return 71 + end 72 + 73 + H.execute_test(test, adapter, bufnr) 74 +end 75 + 76 +function utest.run_file() 77 + local bufnr = vim.api.nvim_get_current_buf() 78 + local adapter = H.adapters[vim.bo[bufnr].filetype] 79 + if not adapter then 80 + vim.notify("[utest] no adapter for this filetype", vim.log.levels.WARN) 81 + return 82 + end 83 + 84 + local tests = H.find_tests(bufnr, adapter) 85 + if #tests == 0 then 86 + vim.notify("[utest] no tests found in file", vim.log.levels.INFO) 87 + return 88 + end 89 + 90 + for _, test in ipairs(tests) do 91 + H.execute_test(test, adapter, bufnr) 92 + end 93 +end 94 + 95 +function utest.cancel() 96 + local cancelled = 0 97 + for id, info in pairs(S.jobs) do 98 + pcall(vim.fn.jobstop, id) 99 + if info.test_id then 100 + S.results[info.test_id] = { 101 + status = "fail", 102 + output = "", 103 + error_message = "Test cancelled", 104 + timestamp = os.time(), 105 + file = info.file, 106 + line = info.line, 107 + name = info.name, 108 + } 109 + end 110 + cancelled = cancelled + 1 111 + end 112 + S.jobs = {} 113 + 114 + if cancelled > 0 then 115 + vim.notify("[utest] cancelled running test(s)", vim.log.levels.INFO) 116 + else 117 + vim.notify("[utest] no running tests to cancel", vim.log.levels.INFO) 118 + end 119 +end 120 + 121 +function utest.clear() 122 + local bufnr = vim.api.nvim_get_current_buf() 123 + S.clear_file(vim.api.nvim_buf_get_name(bufnr)) 124 + H.signs_clear_buffer(bufnr) 125 + H.diagnostics_clear_buffer(bufnr) 126 + H.qf_clear() 127 +end 128 + 129 +function utest.qf() 130 + local qfitems = {} 131 + for test_id, result in pairs(S.get_failed()) do 132 + local file, line, name = test_id:match "^(.+):(%d+):(.+)$" 133 + if file and line then 134 + local error_text = result.test_output or result.error_message or "[Test failed]" 135 + local lines = vim.split(error_text, "\n", { plain = true }) 136 + for i, lcontent in ipairs(lines) do 137 + lcontent = vim.trim(lcontent) 138 + if lcontent ~= "" then 139 + local text = (i == 1) and (name .. ": " .. lcontent) or (" " .. lcontent) 140 + table.insert(qfitems, { 141 + filename = file, 142 + lnum = tonumber(line) + 1, 143 + col = 1, 144 + text = text, 145 + type = "E", 146 + }) 147 + end 148 + end 149 + end 150 + end 151 + 152 + if #qfitems == 0 then 153 + vim.notify("[utest] No failed tests", vim.log.levels.INFO) 154 + return 155 + end 156 + 157 + vim.fn.setqflist({}, "r", { title = "utest: failed tests", items = qfitems }) 158 +end 159 + 160 +-- STATE ====================================================================== 161 + 162 +function S.make_test_id(file, line, name) 163 + return string.format("%s:%d:%s", file, line, name) 164 +end 165 + 166 +function S.clear_file(file) 167 + for id, _ in pairs(S.results) do 168 + if id:match("^" .. vim.pesc(file) .. ":") then S.results[id] = nil end 169 + end 170 +end 171 + 172 +function S.get_failed() 173 + local f = {} 174 + for id, r in pairs(S.results) do 175 + if r.status == "fail" then f[id] = r end 176 + end 177 + return f 178 +end 179 + 180 +-- HELPERS ==================================================================== 181 + 182 +function H.qf_clear() 183 + local qf = vim.fn.getqflist { title = 1 } 184 + if qf.title == "utest: failed tests" then vim.fn.setqflist({}, "r") end 185 +end 186 + 187 +-- SIGNS 188 + 189 +local sign_highlights = { 190 + pass = "DiagnosticOk", 191 + fail = "DiagnosticError", 192 + running = "DiagnosticInfo", 193 +} 194 + 195 +---@param bufnr number 196 +---@param line number 197 +---@param status utest.TestStatus 198 +---@param test_id string 199 +function H.sign_place(bufnr, line, status, test_id) 200 + local icon = utest.config.icons.success 201 + if status == "fail" then 202 + icon = utest.config.icons.failed 203 + elseif status == "running" then 204 + icon = utest.config.icons.running 205 + end 206 + 207 + if not H.extmarks[bufnr] then H.extmarks[bufnr] = {} end 208 + 209 + local existing_id = H.extmarks[bufnr][test_id] 210 + if existing_id then 211 + pcall(vim.api.nvim_buf_del_extmark, bufnr, H.sns, existing_id) 212 + H.extmarks[bufnr][test_id] = nil 213 + end 214 + 215 + local hl = sign_highlights[status] -- FIXME: might fail if status is invalid 216 + local ok, res = pcall(vim.api.nvim_buf_set_extmark, bufnr, H.sns, line, 0, { 217 + priority = 1000, 218 + sign_text = icon, 219 + sign_hl_group = hl, 220 + }) 221 + if ok and test_id then H.extmarks[bufnr][test_id] = res end 222 +end 223 + 224 +--- get current line of a sign y test_id 225 +---@param bufnr number 226 +---@param test_id string 227 +function H.sign_get_current_line(bufnr, test_id) 228 + if not H.extmarks[bufnr] or not H.extmarks[bufnr][test_id] then return nil end 229 + local ok, mark = 230 + pcall(vim.api.nvim_buf_get_extmark_by_id, bufnr, H.sns, H.extmarks[bufnr][test_id], {}) 231 + if ok and mark and #mark >= 1 then return mark[1] end 232 + return nil 233 +end 234 + 235 +---@param bufnr number 236 +function H.signs_clear_buffer(bufnr) 237 + pcall(vim.api.nvim_buf_clear_namespace, bufnr, H.sns, 0, -1) 238 + H.extmarks[bufnr] = nil 239 +end 240 + 241 +--- clears all utest diagnostics in a buffer 242 +---@param bufnr number 243 +function H.diagnostics_clear_buffer(bufnr) 244 + H.diagnostics[bufnr] = nil 245 + vim.diagnostic.reset(H.dns, bufnr) 246 +end 247 + 248 +--- clear diagnostic at a specific line 249 +---@param bufnr number 250 +---@param line number 0-indexed line number 251 +function H.diagnostics_clear(bufnr, line) 252 + if H.diagnostics[bufnr] then 253 + H.diagnostics[bufnr][line] = nil 254 + H.diagnostics_refresh(bufnr) 255 + end 256 +end 257 + 258 +--- set diagnostic at a line 259 +---@param bufnr number 260 +---@param line number 0-indexed line number 261 +---@param message string|nil 262 +---@param output string[]|nil Full output lines 263 +function H.diagnostics_set(bufnr, line, message, output) 264 + if not H.diagnostics[bufnr] then H.diagnostics[bufnr] = {} end 265 + local msg = message or "[Test failed]" 266 + if output and #output > 0 then 267 + local readable_lines = {} 268 + for _, out_line in ipairs(output) do 269 + -- TODO: this should be in utest.golang, only support plain text 270 + local trimmed = vim.trim(out_line) 271 + if trimmed ~= "" then table.insert(readable_lines, trimmed) end 272 + end 273 + 274 + if #readable_lines > 0 then 275 + local output_text = table.concat(readable_lines, "\n") 276 + if output_text ~= "" then msg = msg .. "\n" .. output_text end 277 + end 278 + end 279 + 280 + H.diagnostics[bufnr][line] = { 281 + lnum = line, 282 + col = 0, 283 + severity = vim.diagnostic.severity.ERROR, 284 + source = "utest", 285 + message = msg, 286 + } 287 + H.diagnostics_refresh(bufnr) 288 +end 289 + 290 +--- refresh diagnostics for a buffer 291 +---@param bufnr number 292 +function H.diagnostics_refresh(bufnr) 293 + local diags = {} 294 + if H.diagnostics[bufnr] then 295 + for _, diag in pairs(H.diagnostics[bufnr]) do 296 + table.insert(diags, diag) 297 + end 298 + end 299 + vim.diagnostic.set(H.dns, bufnr, diags) 300 +end 301 + 302 +-- RUNNER 303 + 304 +-- TODO: refactor 305 +---@param test unknown TODO: fix type 306 +---@param adapter utest.Adapter 307 +---@param bufnr number 308 +function H.execute_test(test, adapter, bufnr) 309 + local cmd = adapter.build_command(test, test.file) 310 + local test_id = S.make_test_id(test.file, test.line, test.name) 311 + local output_lines = {} 312 + local timed_out = false 313 + 314 + S.results[test_id] = { 315 + status = "running", 316 + output = "", 317 + timestamp = os.time(), 318 + } 319 + H.sign_place(bufnr, test.line, "running", test_id) 320 + 321 + local timeout_timer, job_id = nil, nil 322 + local function cleanup() 323 + if timeout_timer then 324 + timeout_timer:stop() 325 + timeout_timer:close() 326 + timeout_timer = nil 327 + end 328 + if job_id and S.jobs[job_id] then S.jobs[job_id] = nil end 329 + end 330 + 331 + local function on_output(_, data, _) 332 + if data then 333 + for _, line in ipairs(data) do 334 + if line ~= "" then table.insert(output_lines, line) end 335 + end 336 + end 337 + end 338 + 339 + local function on_exit(_, exit_code) 340 + if timed_out then return end 341 + cleanup() 342 + 343 + local full_output = table.concat(output_lines, "\n") 344 + local results = adapter.parse_output(output_lines, test.file) 345 + 346 + -- find result for ths specific test 347 + local test_result = nil 348 + local search_name = test.name 349 + if test.is_subtest and test.parent then 350 + search_name = test.parent .. "/" .. test.name:gsub(" ", "_") 351 + end 352 + for _, r in ipairs(results) do 353 + if r.name == search_name or r.name == test.name then 354 + test_result = r 355 + break 356 + end 357 + end 358 + 359 + -- fallback: use exit code if no specific result found 360 + if not test_result then 361 + test_result = { 362 + name = test.name, 363 + status = exit_code == 0 and "pass" or "fail", 364 + output = output_lines, 365 + error_line = nil, 366 + } 367 + end 368 + 369 + -- ensure status validity 370 + local final_status = test_result.status 371 + if final_status ~= "pass" and final_status ~= "fail" then 372 + final_status = exit_code == 0 and "pass" or "fail" 373 + end 374 + test_result.status = final_status 375 + 376 + -- get human redabble output 377 + local test_output = {} 378 + if adapter.extract_test_output then 379 + test_output = adapter.extract_test_output(output_lines, search_name) 380 + end 381 + 382 + S.results[test_id] = { 383 + status = test_result.status, 384 + output = full_output, 385 + test_output = table.concat(test_output, "\n"), 386 + error_message = test_result.status == "fail" and adapter.extract_error_message(output_lines) 387 + or nil, 388 + timestamp = os.time(), 389 + file = test.file, 390 + line = test.line, 391 + name = test.name, 392 + } 393 + 394 + -- update ui 395 + vim.schedule(function() 396 + if not vim.api.nvim_buf_is_valid(bufnr) then return end 397 + H.sign_place(bufnr, test.line, test_result.status, test_id) 398 + if test_result.status == "fail" then 399 + -- Only set diagnostic if there's no diagnostic already at this line 400 + -- This prevents multiple diagnostics when parent/child tests both fail 401 + local existing = vim.diagnostic.get(bufnr, { namespace = H.dns, lnum = test.line }) 402 + if #existing == 0 then 403 + H.diagnostics_set( 404 + bufnr, 405 + test.line, 406 + S.results[test_id].test_output or S.results[test_id].error_message or "[Test failed]", 407 + test_output 408 + ) 409 + end 410 + else 411 + H.diagnostics_clear(bufnr, test.line) 412 + end 413 + end) 414 + end 415 + 416 + job_id = vim.fn.jobstart(cmd, { 417 + cwd = adapter.get_package_dir(test.file), 418 + on_stdout = on_output, 419 + on_stderr = on_output, 420 + on_exit = on_exit, 421 + stdout_buffered = true, 422 + stderr_buffered = true, 423 + }) 424 + 425 + if job_id < 0 then 426 + vim.notify("[utest] failed to start test: " .. test.name, vim.log.levels.ERROR) 427 + cleanup() 428 + return 429 + end 430 + 431 + S.jobs[job_id] = { 432 + job_id = job_id, 433 + test_id = test_id, 434 + file = test.file, 435 + line = test.line, 436 + name = test.name, 437 + start_time = os.time(), 438 + output = output_lines, 439 + } 440 + 441 + local timeout = utest.config.timeout * 1000 442 + timeout_timer = vim.uv.new_timer() 443 + 444 + -- stylua: ignore 445 + timeout_timer:start(timeout, 0, vim.schedule_wrap(function() ---@diagnostic disable-line: need-check-nil 446 + if S.jobs[job_id] then 447 + timed_out = true 448 + vim.fn.jobstop(job_id) 449 + S.results[test_id] = { 450 + status = "fail", 451 + output = table.concat(output_lines, "\n"), 452 + error_message = "Test timed out after " .. utest.config.timeout .. "s", 453 + timestamp = os.time(), 454 + file = test.file, 455 + line = test.line, 456 + name = test.name, 457 + } 458 + H.sign_place(bufnr, test.line, "fail", test_id) 459 + H.diagnostics_set(bufnr, test.line, "Test timed out", output_lines) 460 + cleanup() 461 + end 462 + end)) 463 +end 464 + 465 +-- TREESITTER PARSER 466 + 467 +---@param lang string 468 +---@param query string 469 +---@return vim.treesitter.Query|nil 470 +function H.get_query(lang, query) 471 + if H.queries[lang] then return H.queries[lang] end 472 + 473 + local ok, q = pcall(vim.treesitter.query.parse, lang, query) 474 + if not ok then return nil end 475 + 476 + H.queries[lang] = q 477 + return q 478 +end 479 + 480 +-- TODO: refactor 481 +---@param bufnr number 482 +---@param adapter utest.Adapter 483 +---@return utest.Test[] 484 +function H.find_tests(bufnr, adapter) 485 + local query = H.get_query(adapter.ft, adapter.query) 486 + if not query then return {} end -- TODO: show error 487 + 488 + local pok, parser = pcall(vim.treesitter.get_parser, bufnr, adapter.ft) 489 + if not pok or not parser then return {} end -- TODO: show error 490 + 491 + local tree = parser:parse()[1] 492 + if not tree then return {} end 493 + 494 + local file = vim.api.nvim_buf_get_name(bufnr) 495 + local root = tree:root() 496 + local tests = {} 497 + 498 + -- TODO: this is probably overly complicated 499 + for _, match, _ in query:iter_matches(root, bufnr, 0, -1) do 500 + local test_name, test_def = nil, nil 501 + for id, nodes in pairs(match) do 502 + local name = query.captures[id] 503 + if not name then goto continue_match end 504 + 505 + local node = type(nodes) == "table" and nodes[1] or nodes 506 + if name == "test.name" then 507 + test_name = vim.treesitter.get_node_text(node, bufnr) 508 + test_name = test_name:gsub('^"', ""):gsub('"$', "") 509 + elseif name == "test.definition" then 510 + test_def = node 511 + end 512 + 513 + ::continue_match:: 514 + end 515 + 516 + if test_name and test_def then 517 + -- TODO: it's knows about go too much 518 + local start_row, start_col, end_row, end_col = test_def:range() 519 + local is_subtest = not test_name:match "^Test" and not test_name:match "^Example" 520 + table.insert(tests, { 521 + name = test_name, 522 + file = file, 523 + line = start_row, 524 + col = start_col, 525 + end_line = end_row, 526 + end_col = end_col, 527 + is_subtest = is_subtest, 528 + parent = nil, 529 + }) 530 + end 531 + end 532 + 533 + -- Resolve parent relationships for subtests (including nested subtests) 534 + -- Uses line ranges to determine proper parent hierarchy 535 + table.sort(tests, function(a, b) 536 + return a.line < b.line 537 + end) 538 + 539 + -- Build parent hierarchy by checking which tests contain others based on line ranges 540 + -- Treesitter uses half-open intervals [start, end), so we use <= for start and < for end 541 + for i, test in ipairs(tests) do 542 + if test.is_subtest then 543 + local innermost_parent = nil 544 + local innermost_parent_line = -1 545 + 546 + for j, potential_parent in ipairs(tests) do 547 + if i ~= j then 548 + -- Check if potential_parent contains this test using line ranges 549 + if potential_parent.line <= test.line and test.line < potential_parent.end_line then 550 + -- Found a containing test, check if it's the innermost one 551 + if potential_parent.line > innermost_parent_line then 552 + innermost_parent = potential_parent 553 + innermost_parent_line = potential_parent.line 554 + end 555 + end 556 + end 557 + end 558 + 559 + if innermost_parent then 560 + -- Build full parent path for nested subtests 561 + if innermost_parent.parent then 562 + test.parent = innermost_parent.parent .. "/" .. innermost_parent.name 563 + else 564 + test.parent = innermost_parent.name 565 + end 566 + end 567 + end 568 + end 569 + 570 + return tests 571 +end 572 + 573 +---@param bufnr number 574 +---@param cursor_line number 575 +---@param adapter utest.Adapter 576 +---@return utest.Test|nil 577 +function H.find_nearest_test(bufnr, cursor_line, adapter) 578 + local tests = H.find_tests(bufnr, adapter) 579 + if #tests == 0 then return nil end 580 + 581 + local nearest = nil 582 + local nearest_distance = math.huge 583 + for _, test in ipairs(tests) do 584 + if cursor_line >= test.line and cursor_line <= test.end_line then 585 + -- prefer the innermost test (subtest) 586 + if not nearest or test.line > nearest.line then nearest = test end 587 + elseif cursor_line >= test.line then 588 + local distance = cursor_line - test.line 589 + if distance < nearest_distance then 590 + nearest = test 591 + nearest_distance = distance 592 + end 593 + end 594 + end 595 + return nearest 596 +end 597 + 598 +return utest
A
lua/utest/golang.lua
··· 1 +local golang = {} 2 +golang.ft = "go" 3 +golang.query = [[ 4 +; func TestXxx(t *testing.T) and func ExampleXxx() 5 +((function_declaration 6 + name: (identifier) @test.name) 7 + (#match? @test.name "^(Test|Example)") 8 + (#not-match? @test.name "^TestMain$")) @test.definition 9 + 10 +; t.Run("subtest name", func(t *testing.T) {...}) 11 +(call_expression 12 + function: (selector_expression 13 + operand: (identifier) @_operand 14 + (#match? @_operand "^(t|s|suite)$") 15 + field: (field_identifier) @_method) 16 + (#match? @_method "^Run$") 17 + arguments: (argument_list 18 + . 19 + (interpreted_string_literal) @test.name)) @test.definition 20 + 21 +; ============================================================================ 22 +; Table-driven tests with named slice variable and keyed fields 23 +; Detects table tests with struct fields using keys (e.g., {name: "test1"}). 24 +; Pattern: 25 +; tt := []struct{ name string }{ 26 +; {name: "test1"}, // @test.name = "test1" 27 +; {name: "test2"}, // @test.name = "test2" 28 +; } 29 +; for _, tc := range tt { 30 +; t.Run(tc.name, func(t *testing.T) { ... }) 31 +; } 32 +(block 33 + (statement_list 34 + (short_var_declaration 35 + left: (expression_list 36 + (identifier) @test.cases) 37 + right: (expression_list 38 + (composite_literal 39 + (literal_value 40 + (literal_element 41 + (literal_value 42 + (keyed_element 43 + (literal_element 44 + (identifier) @test.field.name) 45 + (literal_element 46 + (interpreted_string_literal) @test.name)))) @test.definition)))) 47 + (for_statement 48 + (range_clause 49 + left: (expression_list 50 + (identifier) @test.case) 51 + right: (identifier) @test.cases1 52 + (#eq? @test.cases @test.cases1)) 53 + body: (block 54 + (statement_list 55 + (expression_statement 56 + (call_expression 57 + function: (selector_expression 58 + operand: (identifier) @test.operand 59 + (#match? @test.operand "^[t]$") 60 + field: (field_identifier) @test.method 61 + (#match? @test.method "^Run$")) 62 + arguments: (argument_list 63 + (selector_expression 64 + operand: (identifier) @test.case1 65 + (#eq? @test.case @test.case1) 66 + field: (field_identifier) @test.field.name1 67 + (#eq? @test.field.name @test.field.name1)))))))))) 68 + 69 +; ============================================================================ 70 +; Map-based table-driven tests 71 +; Detects table tests where test cases are defined in a map with string keys. 72 +; Pattern: 73 +; testCases := map[string]struct{ want int }{ 74 +; "test1": {want: 1}, // @test.name = "test1" 75 +; "test2": {want: 2}, // @test.name = "test2" 76 +; } 77 +; for name, tc := range testCases { 78 +; t.Run(name, func(t *testing.T) { ... }) 79 +; } 80 +(block 81 + (statement_list 82 + (short_var_declaration 83 + left: (expression_list 84 + (identifier) @test.cases) 85 + right: (expression_list 86 + (composite_literal 87 + (literal_value 88 + (keyed_element 89 + (literal_element 90 + (interpreted_string_literal) @test.name) 91 + (literal_element 92 + (literal_value) @test.definition)))))) 93 + (for_statement 94 + (range_clause 95 + left: (expression_list 96 + (identifier) @test.key.name 97 + (identifier) @test.case) 98 + right: (identifier) @test.cases1 99 + (#eq? @test.cases @test.cases1)) 100 + body: (block 101 + (statement_list 102 + (expression_statement 103 + (call_expression 104 + function: (selector_expression 105 + operand: (identifier) @test.operand 106 + (#match? @test.operand "^[t]$") 107 + field: (field_identifier) @test.method 108 + (#match? @test.method "^Run$")) 109 + arguments: (argument_list 110 + ((identifier) @test.key.name1 111 + (#eq? @test.key.name @test.key.name1)))))))))) 112 +]] 113 + 114 +---@param file string 115 +---@return string 116 +function golang.get_package_dir(file) 117 + return vim.fn.fnamemodify(file, ":h") 118 +end 119 + 120 +---@param file string 121 +---@return string[] 122 +function golang.build_file_command(file) 123 + local pkg_dir = golang.get_package_dir(file) 124 + return { "go", "test", "-vet=off", "-json", "-v", "-count=1", pkg_dir } 125 +end 126 + 127 +---@param test table Test info with name, parent, is_subtest fields 128 +---@param file string File path 129 +---@return string[] Command arguments 130 +function golang.build_command(test, file) 131 + local pkg_dir = golang.get_package_dir(file) 132 + local run_pattern 133 + if test.is_subtest and test.parent then 134 + run_pattern = "^" 135 + .. vim.fn.escape(test.parent, "[](){}.*+?^$\\") 136 + .. "/" 137 + .. vim.fn.escape(test.name:gsub(" ", "_"), "[](){}.*+?^$\\") 138 + .. "$" 139 + else 140 + run_pattern = "^" .. vim.fn.escape(test.name, "[](){}.*+?^$\\") .. "$" 141 + end 142 + 143 + return { 144 + "go", 145 + "test", 146 + "-vet=off", 147 + "-json", 148 + "-v", 149 + "-count=1", 150 + "-run", 151 + run_pattern, 152 + pkg_dir, 153 + } 154 +end 155 + 156 +function golang.parse_output(output, file) 157 + local results = {} 158 + local test_outputs = {} ---@type table<string, string[]> 159 + local test_status = {} ---@type table<string, "pass"|"fail"> 160 + local file_basename = vim.fn.fnamemodify(file, ":t") 161 + for _, line in ipairs(output) do 162 + -- Skip empty lines 163 + if line == "" then goto continue end 164 + 165 + -- Parse JSON 166 + local ok, event = pcall(vim.json.decode, line) 167 + if not ok or not event then goto continue end 168 + 169 + local test_name = event.Test 170 + if not test_name then goto continue end 171 + 172 + -- Handle different actions 173 + if event.Action == "run" then 174 + test_outputs[test_name] = test_outputs[test_name] or {} 175 + elseif event.Action == "output" then 176 + test_outputs[test_name] = test_outputs[test_name] or {} 177 + if event.Output then 178 + local output_line = (event.Output:gsub("\n$", "")) 179 + table.insert(test_outputs[test_name], output_line) 180 + end 181 + elseif event.Action == "pass" or event.Action == "skip" then 182 + test_status[test_name] = "pass" 183 + elseif event.Action == "fail" then 184 + test_status[test_name] = "fail" 185 + end 186 + 187 + ::continue:: 188 + end 189 + 190 + -- Build results from collected data 191 + for name, status in pairs(test_status) do 192 + local output_lines = test_outputs[name] or {} 193 + 194 + -- Extract error line if failed 195 + local error_line = nil 196 + if status == "fail" then 197 + for _, out_line in ipairs(output_lines) do 198 + local line_num = out_line:match(file_basename .. ":(%d+):") 199 + if line_num then 200 + error_line = tonumber(line_num) 201 + break 202 + end 203 + end 204 + end 205 + 206 + table.insert(results, { 207 + name = name, 208 + status = status, 209 + output = output_lines, 210 + error_line = error_line, 211 + }) 212 + end 213 + 214 + return results 215 +end 216 + 217 +--- TODO: fix me daddy 218 +---@param output string[] output lines, json format 219 +---@param test_name string|nil Specific test name to get output for 220 +---@return string[] 221 +function golang.extract_test_output(output, test_name) 222 + local result = {} 223 + for _, line in ipairs(output) do 224 + local ok, event = pcall(vim.json.decode, line) 225 + if ok and event and event.Action == "output" then 226 + local output_test = event.Test or "" 227 + if test_name then 228 + -- Only include output for the exact test name (not parents) 229 + if output_test == test_name then table.insert(result, event.Output) end 230 + else 231 + -- Include all output (for file-level runs) 232 + table.insert(result, event.Output) 233 + end 234 + end 235 + end 236 + return result 237 +end 238 + 239 +---@param output string[] Output lines 240 +---@return string|nil 241 +function golang.extract_error_message(output) 242 + for _, line in ipairs(output) do 243 + if line:match "%.go:%d+:" then return vim.trim(line) end 244 + end 245 + return nil 246 +end 247 + 248 +return golang
A
readme
··· 1 +μtest.nvim 2 +---------- 3 + 4 +Small(~1K LOC) test runner for neovim. 5 +I used neotest for a while, it often broke on me after updates, 6 +so I made this mess. 7 + 8 +INSTALL 9 + 10 + vim.pack.add({ "https://git.olexsmir.xyz/utest.nvim" }) 11 + require("utest").setup() 12 + 13 +USAGE 14 + 15 + Plugin only provides lua api to interact with the plugin. 16 + 17 + require("utest").run() -- runs nearest test to relativly to cursor 18 + require("utest").run_file() -- runs all tests in the current file 19 + require("utest").cancel() -- cancels running test(s) 20 + require("utest").clear() -- clears diagnostics messages/signs 21 + require("utest").quickfix() -- populates quickfix with test outputs 22 + 23 +CONFIGURATION 24 + 25 + -- this is default config 26 + require("utest").setup { 27 + timeout = 30, -- (seconds) timeout for tests 28 + icons = { 29 + success = "", 30 + failed = "", 31 + running = "", 32 + } 33 + } 34 + 35 + 36 +LICENSE 37 + 38 + "THE BEERWARE LICENSE" (Revision 42): 39 + Oleksandr Smirnov wrote this code. As long as you retain this 40 + notice, you can do whatever you want with this stuff. If we 41 + meet someday, and you think this stuff is worth it, you can 42 + buy me a beer in return.