all repos

utest.nvim @ 2c72626

test runner that you shouldn't be using
4 files changed, 895 insertions(+), 0 deletions(-)
i did it
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-03-30 18:13:19 +0300
Change ID: mzrtynzklosqurquuotpsvuxuxysslvr
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.

              
A stylua.toml
···
                
                1
                +column_width = 100

              
                
                2
                +line_endings = "Unix"

              
                
                3
                +indent_type = "Spaces"

              
                
                4
                +indent_width = 2

              
                
                5
                +quote_style = "AutoPreferDouble"

              
                
                6
                +no_call_parentheses = true

              
                
                7
                +collapse_simple_statement = "ConditionalOnly"