utest.nvim/lua/utest.lua (view raw)
| 1 | ---@class utest.Adapter |
| 2 | ---@field ft string |
| 3 | ---@field query string |
| 4 | ---@field is_subtest fun(name:string):boolean |
| 5 | ---@field get_cwd fun(file:string):string |
| 6 | ---@field test_file_command fun(file:string):string[] |
| 7 | ---@field test_command fun(test:utest.Test, file:string) |
| 8 | ---@field parse_output fun(output:string[], file:string):utest.AdapterTestResult |
| 9 | ---@field extract_test_output fun(output:string[], test_name:string|nil):string[] |
| 10 | ---@field extract_error_message fun(output:string[]):string|nil |
| 11 | |
| 12 | ---@alias utest.TestStatus "success"|"fail"|"running"|"skipped" |
| 13 | |
| 14 | ---@class utest.Test |
| 15 | ---@field name string |
| 16 | ---@field file string |
| 17 | ---@field line number |
| 18 | ---@field end_line number |
| 19 | ---@field col number |
| 20 | ---@field end_col number |
| 21 | ---@field is_subtest boolean |
| 22 | ---@field parent string|nil |
| 23 | |
| 24 | ---@class utest.AdapterTestResult |
| 25 | ---@field name string |
| 26 | ---@field status utest.TestStatus |
| 27 | ---@field output string[] |
| 28 | ---@field error_line number|nil |
| 29 | |
| 30 | ---@class utest.Job |
| 31 | ---@field job_id number |
| 32 | ---@field test_id string |
| 33 | ---@field file string |
| 34 | ---@field name string |
| 35 | ---@field line number |
| 36 | ---@field start_time number |
| 37 | |
| 38 | ---@class utest.JobResult |
| 39 | ---@field status utest.TestStatus |
| 40 | ---@field output string |
| 41 | ---@field raw_output? string |
| 42 | ---@field timestamp number |
| 43 | ---@field file string |
| 44 | ---@field error_message? string |
| 45 | ---@field name? string |
| 46 | ---@field line? number |
| 47 | |
| 48 | local test_failed_msg = "[Test failed]" |
| 49 | local H = { |
| 50 | ---@type table<string, utest.Adapter> |
| 51 | adapters = {}, |
| 52 | ---@type table<string, vim.treesitter.Query> |
| 53 | queries = {}, |
| 54 | |
| 55 | ---@type table<string, utest.Job> |
| 56 | jobs = {}, |
| 57 | |
| 58 | ---@type table<string, utest.JobResult> |
| 59 | results = {}, |
| 60 | |
| 61 | sns = nil, |
| 62 | dns = nil, |
| 63 | extmarks = {}, |
| 64 | diagnostics = {}, |
| 65 | } |
| 66 | |
| 67 | local utest = {} |
| 68 | utest.config = { |
| 69 | timeout = 30, |
| 70 | icons = { |
| 71 | failed = "", |
| 72 | running = "", |
| 73 | skipped = "", |
| 74 | success = "", |
| 75 | }, |
| 76 | } |
| 77 | |
| 78 | function utest.setup(opts) |
| 79 | utest.config = vim.tbl_deep_extend("keep", utest.config, opts) |
| 80 | H.sns = vim.api.nvim_create_namespace "utest_signs" |
| 81 | H.dns = vim.api.nvim_create_namespace "utest_diagnostics" |
| 82 | H.adapters.go = require "utest.golang" |
| 83 | end |
| 84 | |
| 85 | function utest.run() |
| 86 | local bufnr = vim.api.nvim_get_current_buf() |
| 87 | local adapter = H.adapters[vim.bo[bufnr].filetype] |
| 88 | if not adapter then |
| 89 | vim.notify("[utest] no adapter for this filetype", vim.log.levels.WARN) |
| 90 | return |
| 91 | end |
| 92 | |
| 93 | local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 |
| 94 | local test = H.find_nearest_test(bufnr, cursor_line, adapter) |
| 95 | if not test then |
| 96 | vim.notify("[utest] no near test found", vim.log.levels.INFO) |
| 97 | return |
| 98 | end |
| 99 | |
| 100 | H.execute_test(test, adapter, bufnr) |
| 101 | end |
| 102 | |
| 103 | function utest.run_file() |
| 104 | local bufnr = vim.api.nvim_get_current_buf() |
| 105 | local adapter = H.adapters[vim.bo[bufnr].filetype] |
| 106 | if not adapter then |
| 107 | vim.notify("[utest] no adapter for this filetype", vim.log.levels.WARN) |
| 108 | return |
| 109 | end |
| 110 | |
| 111 | local tests = H.find_tests(bufnr, adapter) |
| 112 | if #tests == 0 then |
| 113 | vim.notify("[utest] no tests found in file", vim.log.levels.INFO) |
| 114 | return |
| 115 | end |
| 116 | |
| 117 | for _, test in ipairs(tests) do |
| 118 | H.execute_test(test, adapter, bufnr) |
| 119 | end |
| 120 | end |
| 121 | |
| 122 | function utest.cancel() |
| 123 | local cancelled = 0 |
| 124 | for id, info in pairs(H.jobs) do |
| 125 | pcall(vim.fn.jobstop, id) |
| 126 | if info.test_id then |
| 127 | H.results[info.test_id] = { |
| 128 | status = "fail", |
| 129 | output = "", |
| 130 | error_message = "Test cancelled", |
| 131 | timestamp = os.time(), |
| 132 | file = info.file, |
| 133 | line = info.line, |
| 134 | name = info.name, |
| 135 | } |
| 136 | end |
| 137 | cancelled = cancelled + 1 |
| 138 | end |
| 139 | H.jobs = {} |
| 140 | |
| 141 | if cancelled > 0 then |
| 142 | vim.notify("[utest] cancelled running test(s)", vim.log.levels.INFO) |
| 143 | else |
| 144 | vim.notify("[utest] no running tests to cancel", vim.log.levels.INFO) |
| 145 | end |
| 146 | end |
| 147 | |
| 148 | function utest.clear() |
| 149 | local bufnr = vim.api.nvim_get_current_buf() |
| 150 | H.clear_file(vim.api.nvim_buf_get_name(bufnr)) |
| 151 | H.signs_clear_buffer(bufnr) |
| 152 | H.diagnostics_clear_buffer(bufnr) |
| 153 | H.qf_clear() |
| 154 | end |
| 155 | |
| 156 | function utest.qf() |
| 157 | local qfitems = {} |
| 158 | for test_id, result in pairs(H.get_failed()) do |
| 159 | local file, line, name = test_id:match "^(.+):(%d+):(.+)$" |
| 160 | if file and line then |
| 161 | local error_text = result.output or result.error_message or test_failed_msg |
| 162 | local lines = vim.split(error_text, "\n", { plain = true }) |
| 163 | for i, lcontent in ipairs(lines) do |
| 164 | lcontent = vim.trim(lcontent) |
| 165 | if lcontent ~= "" then |
| 166 | local text = (i == 1) and (name .. ": " .. lcontent) or (" " .. lcontent) |
| 167 | table.insert(qfitems, { |
| 168 | filename = file, |
| 169 | lnum = tonumber(line) + 1, |
| 170 | col = 1, |
| 171 | text = text, |
| 172 | type = "E", |
| 173 | }) |
| 174 | end |
| 175 | end |
| 176 | end |
| 177 | end |
| 178 | |
| 179 | if #qfitems == 0 then |
| 180 | vim.notify("[utest] No failed tests", vim.log.levels.INFO) |
| 181 | return |
| 182 | end |
| 183 | |
| 184 | vim.fn.setqflist({}, "r", { title = "utest: failed tests", items = qfitems }) |
| 185 | end |
| 186 | |
| 187 | -- HELPERS ==================================================================== |
| 188 | |
| 189 | function H.make_test_id(file, line, name) |
| 190 | return string.format("%s:%d:%s", file, line, name) |
| 191 | end |
| 192 | |
| 193 | function H.clear_file(file) |
| 194 | for id, _ in pairs(H.results) do |
| 195 | if id:match("^" .. vim.pesc(file) .. ":") then H.results[id] = nil end |
| 196 | end |
| 197 | end |
| 198 | |
| 199 | function H.get_failed() |
| 200 | local f = {} |
| 201 | for id, r in pairs(H.results) do |
| 202 | if r.status == "fail" then f[id] = r end |
| 203 | end |
| 204 | return f |
| 205 | end |
| 206 | |
| 207 | function H.qf_clear() |
| 208 | local qf = vim.fn.getqflist { title = 1 } |
| 209 | if qf.title == "utest: failed tests" then vim.fn.setqflist({}, "r") end |
| 210 | end |
| 211 | |
| 212 | -- SIGNS |
| 213 | |
| 214 | ---@type table<utest.TestStatus, string> |
| 215 | local sign_highlights = { |
| 216 | success = "DiagnosticOk", |
| 217 | skipped = "DiagnosticInfo", |
| 218 | fail = "DiagnosticError", |
| 219 | running = "DiagnosticInfo", |
| 220 | } |
| 221 | |
| 222 | ---@param bufnr number |
| 223 | ---@param line number |
| 224 | ---@param status utest.TestStatus |
| 225 | ---@param test_id string |
| 226 | function H.sign_place(bufnr, line, status, test_id) |
| 227 | if not H.extmarks[bufnr] then H.extmarks[bufnr] = {} end |
| 228 | |
| 229 | local existing_id = H.extmarks[bufnr][test_id] |
| 230 | if existing_id then |
| 231 | pcall(vim.api.nvim_buf_del_extmark, bufnr, H.sns, existing_id) |
| 232 | H.extmarks[bufnr][test_id] = nil |
| 233 | end |
| 234 | |
| 235 | -- FIXME: might fail if status is invalid |
| 236 | local icon = utest.config.icons[status] |
| 237 | local hl = sign_highlights[status] |
| 238 | |
| 239 | local ok, res = pcall(vim.api.nvim_buf_set_extmark, bufnr, H.sns, line, 0, { |
| 240 | priority = 1000, |
| 241 | sign_text = icon, |
| 242 | sign_hl_group = hl, |
| 243 | }) |
| 244 | if ok and test_id then H.extmarks[bufnr][test_id] = res end |
| 245 | end |
| 246 | |
| 247 | --- get current line of a sign y test_id |
| 248 | ---@param bufnr number |
| 249 | ---@param test_id string |
| 250 | function H.sign_get_current_line(bufnr, test_id) |
| 251 | if not H.extmarks[bufnr] or not H.extmarks[bufnr][test_id] then return nil end |
| 252 | local ok, mark = |
| 253 | pcall(vim.api.nvim_buf_get_extmark_by_id, bufnr, H.sns, H.extmarks[bufnr][test_id], {}) |
| 254 | if ok and mark and #mark >= 1 then return mark[1] end |
| 255 | return nil |
| 256 | end |
| 257 | |
| 258 | ---@param bufnr number |
| 259 | function H.signs_clear_buffer(bufnr) |
| 260 | pcall(vim.api.nvim_buf_clear_namespace, bufnr, H.sns, 0, -1) |
| 261 | H.extmarks[bufnr] = nil |
| 262 | end |
| 263 | |
| 264 | --- clears all utest diagnostics in a buffer |
| 265 | ---@param bufnr number |
| 266 | function H.diagnostics_clear_buffer(bufnr) |
| 267 | H.diagnostics[bufnr] = nil |
| 268 | vim.diagnostic.reset(H.dns, bufnr) |
| 269 | end |
| 270 | |
| 271 | --- clear diagnostic at a specific line |
| 272 | ---@param bufnr number |
| 273 | ---@param line number 0-indexed line number |
| 274 | function H.diagnostics_clear(bufnr, line) |
| 275 | if H.diagnostics[bufnr] then |
| 276 | H.diagnostics[bufnr][line] = nil |
| 277 | H.diagnostics_refresh(bufnr) |
| 278 | end |
| 279 | end |
| 280 | |
| 281 | --- set diagnostic at a line |
| 282 | ---@param bufnr number |
| 283 | ---@param line number 0-indexed line number |
| 284 | ---@param message string|nil |
| 285 | function H.diagnostics_set(bufnr, line, message) |
| 286 | if not H.diagnostics[bufnr] then H.diagnostics[bufnr] = {} end |
| 287 | H.diagnostics[bufnr][line] = { |
| 288 | lnum = line, |
| 289 | col = 0, |
| 290 | severity = vim.diagnostic.severity.ERROR, |
| 291 | source = "utest", |
| 292 | message = message or test_failed_msg, |
| 293 | } |
| 294 | H.diagnostics_refresh(bufnr) |
| 295 | end |
| 296 | |
| 297 | --- refresh diagnostics for a buffer |
| 298 | ---@param bufnr number |
| 299 | function H.diagnostics_refresh(bufnr) |
| 300 | local diags = {} |
| 301 | if H.diagnostics[bufnr] then |
| 302 | for _, diag in pairs(H.diagnostics[bufnr]) do |
| 303 | table.insert(diags, diag) |
| 304 | end |
| 305 | end |
| 306 | vim.diagnostic.set(H.dns, bufnr, diags) |
| 307 | end |
| 308 | |
| 309 | -- RUNNER |
| 310 | |
| 311 | ---@param test utest.Test |
| 312 | ---@param adapter utest.Adapter |
| 313 | ---@param bufnr number |
| 314 | function H.execute_test(test, adapter, bufnr) |
| 315 | local cmd = adapter.test_command(test, test.file) |
| 316 | local test_id = H.make_test_id(test.file, test.line, test.name) |
| 317 | local output_lines = {} |
| 318 | local timed_out = false |
| 319 | |
| 320 | H.results[test_id] = { |
| 321 | status = "running", |
| 322 | output = "", |
| 323 | timestamp = os.time(), |
| 324 | file = test.file, |
| 325 | } |
| 326 | H.sign_place(bufnr, test.line, "running", test_id) |
| 327 | |
| 328 | local timeout_timer, job_id = nil, nil |
| 329 | local function cleanup() |
| 330 | if timeout_timer then |
| 331 | timeout_timer:stop() |
| 332 | timeout_timer:close() |
| 333 | timeout_timer = nil |
| 334 | end |
| 335 | if job_id and H.jobs[job_id] then H.jobs[job_id] = nil end |
| 336 | end |
| 337 | |
| 338 | local function on_output(_, data, _) |
| 339 | if data then |
| 340 | for _, line in ipairs(data) do |
| 341 | if line ~= "" then table.insert(output_lines, line) end |
| 342 | end |
| 343 | end |
| 344 | end |
| 345 | |
| 346 | local function on_exit(_, exit_code) |
| 347 | if timed_out then return end |
| 348 | cleanup() |
| 349 | |
| 350 | local full_output = table.concat(output_lines, "\n") |
| 351 | local results = adapter.parse_output(output_lines, test.file) |
| 352 | |
| 353 | -- find result for ths specific test |
| 354 | local test_result = nil |
| 355 | local search_name = test.name |
| 356 | if test.is_subtest and test.parent then |
| 357 | search_name = test.parent .. "/" .. test.name:gsub(" ", "_") |
| 358 | end |
| 359 | for _, r in ipairs(results) do |
| 360 | if r.name == search_name or r.name == test.name then |
| 361 | test_result = r |
| 362 | break |
| 363 | end |
| 364 | end |
| 365 | |
| 366 | -- fallback: use exit code if no specific result found |
| 367 | if not test_result then |
| 368 | test_result = { |
| 369 | name = test.name, |
| 370 | status = exit_code == 0 and "success" or "fail", |
| 371 | output = output_lines, |
| 372 | error_line = nil, |
| 373 | } |
| 374 | end |
| 375 | |
| 376 | -- ensure status validity |
| 377 | local final_status = test_result.status |
| 378 | if final_status ~= "success" and final_status ~= "fail" and final_status ~= "skipped" then |
| 379 | final_status = exit_code == 0 and "success" or "fail" |
| 380 | end |
| 381 | test_result.status = final_status |
| 382 | |
| 383 | local test_output = adapter.extract_test_output(output_lines, search_name) |
| 384 | local error_message = test_result.status == "fail" |
| 385 | and adapter.extract_error_message(output_lines) |
| 386 | or nil |
| 387 | |
| 388 | H.results[test_id] = { |
| 389 | status = test_result.status, |
| 390 | output = table.concat(test_output, "\n"), |
| 391 | raw_output = full_output, |
| 392 | error_message = error_message, |
| 393 | timestamp = os.time(), |
| 394 | file = test.file, |
| 395 | line = test.line, |
| 396 | name = test.name, |
| 397 | } |
| 398 | |
| 399 | -- update ui |
| 400 | vim.schedule(function() |
| 401 | if not vim.api.nvim_buf_is_valid(bufnr) then return end |
| 402 | H.sign_place(bufnr, test.line, test_result.status, test_id) |
| 403 | if test_result.status == "fail" then |
| 404 | -- Only set diagnostic if there's no diagnostic already at this line |
| 405 | -- This prevents multiple diagnostics when parent/child tests both fail |
| 406 | local existing = vim.diagnostic.get(bufnr, { namespace = H.dns, lnum = test.line }) |
| 407 | if #existing == 0 then |
| 408 | H.diagnostics_set( |
| 409 | bufnr, |
| 410 | test.line, |
| 411 | H.results[test_id].output or H.results[test_id].error_message or test_failed_msg |
| 412 | ) |
| 413 | end |
| 414 | else |
| 415 | H.diagnostics_clear(bufnr, test.line) |
| 416 | end |
| 417 | end) |
| 418 | end |
| 419 | |
| 420 | job_id = vim.fn.jobstart(cmd, { |
| 421 | cwd = adapter.get_cwd(test.file), |
| 422 | on_stdout = on_output, |
| 423 | on_stderr = on_output, |
| 424 | on_exit = on_exit, |
| 425 | stdout_buffered = true, |
| 426 | stderr_buffered = true, |
| 427 | }) |
| 428 | |
| 429 | if job_id < 0 then |
| 430 | vim.notify("[utest] failed to start test: " .. test.name, vim.log.levels.ERROR) |
| 431 | cleanup() |
| 432 | return |
| 433 | end |
| 434 | |
| 435 | H.jobs[job_id] = { |
| 436 | job_id = job_id, |
| 437 | test_id = test_id, |
| 438 | file = test.file, |
| 439 | line = test.line, |
| 440 | name = test.name, |
| 441 | start_time = os.time(), |
| 442 | output = output_lines, |
| 443 | } |
| 444 | |
| 445 | local timeout = utest.config.timeout * 1000 |
| 446 | timeout_timer = vim.uv.new_timer() |
| 447 | |
| 448 | -- stylua: ignore |
| 449 | timeout_timer:start(timeout, 0, vim.schedule_wrap(function() ---@diagnostic disable-line: need-check-nil |
| 450 | if H.jobs[job_id] then |
| 451 | timed_out = true |
| 452 | vim.fn.jobstop(job_id) |
| 453 | H.results[test_id] = { |
| 454 | status = "fail", |
| 455 | output = table.concat(output_lines, "\n"), |
| 456 | error_message = "Test timed out after " .. utest.config.timeout .. "s", |
| 457 | timestamp = os.time(), |
| 458 | file = test.file, |
| 459 | line = test.line, |
| 460 | name = test.name, |
| 461 | } |
| 462 | H.sign_place(bufnr, test.line, "fail", test_id) |
| 463 | H.diagnostics_set(bufnr, test.line, "Test timed out") |
| 464 | cleanup() |
| 465 | end |
| 466 | end)) |
| 467 | end |
| 468 | |
| 469 | -- TREESITTER PARSER |
| 470 | |
| 471 | ---@param lang string |
| 472 | ---@param query string |
| 473 | ---@return vim.treesitter.Query|nil |
| 474 | function H.get_query(lang, query) |
| 475 | if H.queries[lang] then return H.queries[lang] end |
| 476 | |
| 477 | local ok, q = pcall(vim.treesitter.query.parse, lang, query) |
| 478 | if not ok then return nil end |
| 479 | |
| 480 | H.queries[lang] = q |
| 481 | return q |
| 482 | end |
| 483 | |
| 484 | ---@param bufnr number |
| 485 | ---@param adapter utest.Adapter |
| 486 | ---@return utest.Test[] |
| 487 | function H.find_tests(bufnr, adapter) |
| 488 | local query = H.get_query(adapter.ft, adapter.query) |
| 489 | if not query then |
| 490 | vim.notify("[utest] failed to run " .. adapter.ft .. " adapter query", vim.log.levels.ERROR) |
| 491 | return {} |
| 492 | end |
| 493 | |
| 494 | local pok, parser = pcall(vim.treesitter.get_parser, bufnr, adapter.ft) |
| 495 | if not pok or not parser then |
| 496 | vim.notify("[utest] failed to get treesitter parser", vim.log.levels.ERROR) |
| 497 | return {} |
| 498 | end |
| 499 | |
| 500 | local tree = parser:parse()[1] |
| 501 | if not tree then return {} end |
| 502 | |
| 503 | local file = vim.api.nvim_buf_get_name(bufnr) |
| 504 | local root = tree:root() |
| 505 | local tests = {} |
| 506 | |
| 507 | for _, match, _ in query:iter_matches(root, bufnr, 0, -1) do |
| 508 | local test_name, test_def = nil, nil |
| 509 | for id, nodes in pairs(match) do |
| 510 | local name = query.captures[id] |
| 511 | if not name then goto continue_match end |
| 512 | |
| 513 | local node = type(nodes) == "table" and nodes[1] or nodes |
| 514 | if name == "test.name" then |
| 515 | test_name = vim.treesitter.get_node_text(node, bufnr) |
| 516 | test_name = test_name:gsub('^"', ""):gsub('"$', "") |
| 517 | elseif name == "test.definition" then |
| 518 | test_def = node |
| 519 | end |
| 520 | |
| 521 | ::continue_match:: |
| 522 | end |
| 523 | |
| 524 | if test_name and test_def then |
| 525 | local start_row, start_col, end_row, end_col = test_def:range() |
| 526 | table.insert(tests, { |
| 527 | name = test_name, |
| 528 | file = file, |
| 529 | line = start_row, |
| 530 | col = start_col, |
| 531 | end_line = end_row, |
| 532 | end_col = end_col, |
| 533 | is_subtest = adapter.is_subtest(test_name), |
| 534 | parent = nil, |
| 535 | }) |
| 536 | end |
| 537 | end |
| 538 | |
| 539 | -- Deduplicate: when multiple query patterns match overlapping AST regions |
| 540 | -- (e.g. function_declaration + table test entry), keep the narrower match |
| 541 | table.sort(tests, function(a, b) |
| 542 | if a.line ~= b.line then return a.line < b.line end |
| 543 | return (a.end_line - a.line) < (b.end_line - b.line) |
| 544 | end) |
| 545 | |
| 546 | local seen = {} |
| 547 | local deduped = {} |
| 548 | for _, test in ipairs(tests) do |
| 549 | local key = test.line .. ":" .. test.name |
| 550 | if not seen[key] then |
| 551 | seen[key] = true |
| 552 | table.insert(deduped, test) |
| 553 | end |
| 554 | end |
| 555 | tests = deduped |
| 556 | |
| 557 | -- Resolve parent relationships for subtests (including nested subtests) |
| 558 | -- Uses line ranges to determine proper parent hierarchy |
| 559 | table.sort(tests, function(a, b) |
| 560 | return a.line < b.line |
| 561 | end) |
| 562 | |
| 563 | -- Build parent hierarchy by checking which tests contain others based on line ranges |
| 564 | -- Treesitter uses half-open intervals [start, end), so we use <= for start and < for end |
| 565 | for i, test in ipairs(tests) do |
| 566 | if test.is_subtest then |
| 567 | local innermost_parent = nil |
| 568 | local innermost_parent_line = -1 |
| 569 | |
| 570 | for j, potential_parent in ipairs(tests) do |
| 571 | if i ~= j then |
| 572 | -- Check if potential_parent contains this test using line ranges |
| 573 | if potential_parent.line <= test.line and test.line < potential_parent.end_line then |
| 574 | -- Found a containing test, check if it's the innermost one |
| 575 | if potential_parent.line > innermost_parent_line then |
| 576 | innermost_parent = potential_parent |
| 577 | innermost_parent_line = potential_parent.line |
| 578 | end |
| 579 | end |
| 580 | end |
| 581 | end |
| 582 | |
| 583 | if innermost_parent then |
| 584 | -- Build full parent path for nested subtests |
| 585 | if innermost_parent.parent then -- TODO: too go dependent |
| 586 | test.parent = innermost_parent.parent .. "/" .. innermost_parent.name |
| 587 | else |
| 588 | test.parent = innermost_parent.name |
| 589 | end |
| 590 | end |
| 591 | end |
| 592 | end |
| 593 | |
| 594 | return tests |
| 595 | end |
| 596 | |
| 597 | ---@param bufnr number |
| 598 | ---@param cursor_line number |
| 599 | ---@param adapter utest.Adapter |
| 600 | ---@return utest.Test|nil |
| 601 | function H.find_nearest_test(bufnr, cursor_line, adapter) |
| 602 | local tests = H.find_tests(bufnr, adapter) |
| 603 | if #tests == 0 then return nil end |
| 604 | |
| 605 | local nearest = nil |
| 606 | local nearest_distance = math.huge |
| 607 | for _, test in ipairs(tests) do |
| 608 | if cursor_line >= test.line and cursor_line <= test.end_line then |
| 609 | -- prefer the innermost test (subtest) |
| 610 | if not nearest or test.line > nearest.line then nearest = test end |
| 611 | elseif cursor_line >= test.line then |
| 612 | local distance = cursor_line - test.line |
| 613 | if distance < nearest_distance then |
| 614 | nearest = test |
| 615 | nearest_distance = distance |
| 616 | end |
| 617 | end |
| 618 | end |
| 619 | return nearest |
| 620 | end |
| 621 | |
| 622 | return utest |