all repos

utest.nvim @ 13e8b100b160e29a3f8cf2a89970d8a6f9e84dc8

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