all repos

utest.nvim @ 2c726269e4dfbcf6aae5a23699aa4e9007b7b75a

test runner that you shouldn't be using

utest.nvim/lua/utest.lua (view raw)

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