Skip to content

Commit 8491e1b

Browse files
jorgemmsilvamrcjkb
andauthored
feat(tests): support nextest junit + enable colour output (#838)
Co-authored-by: Marc Jakobi <[email protected]>
1 parent eb9beab commit 8491e1b

File tree

8 files changed

+371
-152
lines changed

8 files changed

+371
-152
lines changed

lua/rustaceanvim/cache.lua

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
local M = {}
2+
3+
--- Nextest profile used to generate junit reports (needs to be written to a toml file)
4+
--- IMPORTANT: When modifying this config, increment the revision suffix in the file name below!
5+
local NEXTEST_CONFIG = [[# profile used to generate junit reports when in nextest mode
6+
[profile.rustaceanvim.junit]
7+
path = "junit.xml"
8+
store-failure-output = true
9+
store-success-output = true
10+
]]
11+
12+
---@return string path to nextest.toml file
13+
function M.nextest_config_path()
14+
local cache_dir = vim.fs.joinpath(vim.fn.stdpath('cache'), 'rustaceanvim')
15+
local config_path = vim.fs.joinpath(cache_dir, 'nextest_1.toml')
16+
17+
-- Check if file already exists
18+
local stat = vim.uv.fs_stat(config_path)
19+
if stat then
20+
return config_path
21+
end
22+
23+
-- Create cache directory if it doesn't exist
24+
vim.fn.mkdir(cache_dir, 'p')
25+
26+
-- Write the config file
27+
local file = io.open(config_path, 'w')
28+
if not file then
29+
error('Failed to create nextest config file: ' .. config_path)
30+
end
31+
32+
file:write(NEXTEST_CONFIG)
33+
file:close()
34+
35+
return config_path
36+
end
37+
38+
return M

lua/rustaceanvim/executors/background.lua

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ local M = {}
1313
---@class rustaceanvim.Diagnostic: vim.Diagnostic
1414
---@field test_id string
1515

16+
---@param path string
17+
---@return string content
18+
local function read_file(path)
19+
local file_fd, open_err = vim.uv.fs_open(path, 'r', 438)
20+
assert(not open_err, open_err)
21+
assert(file_fd, 'expected file descriptor')
22+
local stat, stat_err = vim.uv.fs_fstat(file_fd)
23+
assert(not stat_err, stat_err)
24+
assert(stat, 'expected file stats')
25+
local data, read_err = vim.uv.fs_read(file_fd, stat.size, 0)
26+
assert(data, 'expected file content')
27+
assert(not read_err, read_err)
28+
return data
29+
end
30+
1631
M.execute_command = function(command, args, cwd, opts)
1732
---@type rustaceanvim.ExecutorOpts
1833
opts = vim.tbl_deep_extend('force', { bufnr = 0 }, opts or {})
@@ -27,7 +42,6 @@ M.execute_command = function(command, args, cwd, opts)
2742
local is_single_test = args[1] == 'test'
2843
local notify_prefix = (is_single_test and 'test ' or 'tests ')
2944
local cmd = vim.list_extend({ command }, args)
30-
local fname = vim.api.nvim_buf_get_name(opts.bufnr)
3145
vim.system(cmd, { cwd = cwd, env = opts.env }, function(sc)
3246
---@cast sc vim.SystemCompleted
3347
if sc.code == 0 then
@@ -38,7 +52,18 @@ M.execute_command = function(command, args, cwd, opts)
3852
return
3953
end
4054
local output = (sc.stderr or '') .. '\n' .. (sc.stdout or '')
41-
local diagnostics = require('rustaceanvim.test').parse_diagnostics(fname, output, opts.bufnr)
55+
local diagnostics
56+
local is_cargo_test = args[1] == 'test'
57+
if is_cargo_test then
58+
diagnostics = require('rustaceanvim.test').parse_cargo_test_diagnostics(output, opts.bufnr)
59+
else
60+
local junit_xml = read_file((cwd or vim.fn.getcwd()) .. '/target/nextest/rustaceanvim/junit.xml')
61+
if not junit_xml then
62+
vim.notify('Failed to read junit.xml file', vim.log.levels.ERROR)
63+
return
64+
end
65+
diagnostics = require('rustaceanvim.test').parse_nextest_diagnostics(junit_xml, opts.bufnr)
66+
end
4267
local summary = get_test_summary(sc.stdout or '')
4368
vim.schedule(function()
4469
vim.diagnostic.set(diag_namespace, opts.bufnr, diagnostics)

lua/rustaceanvim/neotest/init.lua

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ end
243243
---@field pos_id string
244244
---@field type neotest.PositionType
245245
---@field tree neotest.Tree
246+
---@field workspace_root string?
247+
---@field is_cargo_test boolean
246248

247249
---@package
248250
---@param run_args neotest.RunArgs
@@ -263,13 +265,15 @@ function NeotestAdapter.build_spec(run_args)
263265
if not runnable then
264266
return
265267
end
268+
local exe, args, cwd, env = require('rustaceanvim.runnables').get_command(runnable)
266269
local context = {
267270
file = pos.path,
268271
pos_id = pos.id,
269272
type = pos.type,
270273
tree = tree,
274+
workspace_root = NeotestAdapter.root(pos.path),
275+
is_cargo_test = args[1] == 'test',
271276
}
272-
local exe, args, cwd, env = require('rustaceanvim.runnables').get_command(runnable)
273277
if run_args.strategy == 'dap' then
274278
local dap = require('rustaceanvim.dap')
275279
overrides.sanitize_command_for_debugging(runnable.args.cargoArgs)
@@ -303,16 +307,8 @@ function NeotestAdapter.build_spec(run_args)
303307
else
304308
overrides.undo_debug_sanitize(runnable.args.cargoArgs)
305309
end
306-
local is_cargo_test = args[1] == 'test'
307-
local insert_pos = is_cargo_test and 2 or 3
310+
local insert_pos = context.is_cargo_test and 2 or 3
308311
table.insert(args, insert_pos, '--no-fail-fast')
309-
if is_cargo_test then
310-
-- cargo test needs to pass --color=never to the test runner too
311-
table.insert(args, '--color=never')
312-
else
313-
table.insert(args, 2, '--color')
314-
table.insert(args, 3, 'never')
315-
end
316312
---@type rustaceanvim.neotest.RunSpec
317313
---@diagnostic disable-next-line: missing-fields
318314
local run_spec = {
@@ -349,54 +345,78 @@ function NeotestAdapter.results(spec, strategy_result)
349345
local context = spec.context
350346
local ctx_pos_id = context.pos_id
351347
---@type string
352-
local output_content = lib.files.read(strategy_result.output)
353348
if strategy_result.code == 0 then
354349
results[ctx_pos_id] = {
355350
status = 'passed',
356351
output = strategy_result.output,
357352
}
358353
return results
359354
end
360-
---@type table<string,neotest.Error[]>
361-
local errors_by_test_id = {}
362-
output_content = output_content:gsub('\r\n', '\n')
363-
local diagnostics = require('rustaceanvim.test').parse_diagnostics(context.file, output_content, 0)
364-
for _, diagnostic in pairs(diagnostics) do
365-
---@type neotest.Error
366-
local err = {
367-
line = diagnostic.lnum,
368-
message = diagnostic.message,
369-
}
370-
errors_by_test_id[diagnostic.test_id] = errors_by_test_id[diagnostic.test_id] or {}
371-
table.insert(errors_by_test_id[diagnostic.test_id], err)
355+
356+
---@type rustaceanvim.Diagnostic[]
357+
local diagnostics
358+
local output_content = ''
359+
local junit_xml = ''
360+
if context.is_cargo_test then
361+
local success
362+
success, output_content = pcall(function()
363+
return lib.files.read(strategy_result.output)
364+
end)
365+
if not success then
366+
vim.notify('Failed to read output file', vim.log.levels.ERROR)
367+
return results
368+
end
369+
diagnostics = require('rustaceanvim.test').parse_cargo_test_diagnostics(output_content, 0)
370+
else
371+
local success
372+
success, junit_xml = pcall(function()
373+
return lib.files.read(
374+
vim.fs.joinpath(context.workspace_root or vim.fn.getcwd(), 'target', 'nextest', 'rustaceanvim', 'junit.xml')
375+
)
376+
end)
377+
if not success then
378+
vim.notify('Failed to read junit.xml file', vim.log.levels.ERROR)
379+
return results
380+
end
381+
diagnostics = require('rustaceanvim.test').parse_nextest_diagnostics(junit_xml, 0)
372382
end
383+
373384
if not vim.tbl_contains({ 'file', 'test', 'namespace' }, context.type) then
374385
return results
375386
end
376387
results[ctx_pos_id] = {
377388
status = 'failed',
378389
output = strategy_result.output,
379390
}
380-
local has_failures = not vim.tbl_isempty(diagnostics)
391+
392+
if vim.tbl_isempty(diagnostics) then
393+
return results -- no failures
394+
end
395+
381396
for _, node in get_file_root(context.tree):iter_nodes() do
382397
local data = node:data()
383-
for test_id, errors in pairs(errors_by_test_id) do
384-
if vim.endswith(data.id, test_id) then
385-
results[data.id] = {
386-
status = 'failed',
387-
errors = errors,
388-
short = output_content,
389-
}
390-
elseif has_failures and data.type == 'test' then
391-
-- Initialise as skipped. Passed positions will be parsed and set later.
392-
results[data.id] = {
393-
status = 'skipped',
394-
}
395-
end
398+
local failure_diagnostic = vim.iter(diagnostics):find(function(diag)
399+
return vim.endswith(data.id, diag.test_id)
400+
end)
401+
402+
if failure_diagnostic then
403+
results[data.id] = {
404+
status = 'failed',
405+
errors = { { line = failure_diagnostic.lnum, message = failure_diagnostic.message } },
406+
short = failure_diagnostic.message,
407+
}
408+
elseif data.type == 'test' then
409+
-- Initialise as skipped. Passed positions will be parsed and set later.
410+
results[data.id] = {
411+
status = 'skipped',
412+
}
396413
end
397414
end
398-
if has_failures then
399-
require('rustaceanvim.neotest.parser').populate_pass_positions(results, context, output_content)
415+
416+
if context.is_cargo_test then
417+
require('rustaceanvim.neotest.parser').populate_pass_positions_cargo_test(results, context, output_content)
418+
else
419+
require('rustaceanvim.neotest.parser').populate_pass_positions_nextest(results, context, junit_xml)
400420
end
401421
return results
402422
end

lua/rustaceanvim/neotest/parser.lua

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,33 @@ local trans = require('rustaceanvim.neotest.trans')
66
---
77
---@param results table<string, neotest.Result>
88
---@param context rustaceanvim.neotest.RunContext
9-
---@param output_content string
9+
---@param junit_xml string
1010
---@return table<string, neotest.Result> results
11-
function M.populate_pass_positions(results, context, output_content)
12-
-- XXX: match doesn't work here because it needs to
13-
-- match on the end of each line
14-
-- TODO: Use cargo-nextest's JUnit output in the future?
15-
local lines = vim.split(output_content, '\n') or {}
16-
vim
17-
.iter(lines)
18-
---@param line string
19-
:map(function(line)
20-
return line:match('PASS%s.*%s(%S+)$') or line:match('test%s(%S+)%s...%sok')
21-
end)
22-
---@param result string | nil
23-
:filter(function(result)
24-
return result ~= nil
25-
end)
26-
---@param pos string
27-
:map(function(pos)
28-
return trans.get_position_id(context.file, pos)
29-
end)
30-
---@param pos string
31-
:each(function(pos)
32-
results[pos] = {
11+
function M.populate_pass_positions_nextest(results, context, junit_xml)
12+
for test_name, contents in junit_xml:gmatch('<testcase.-name="([^"]+)".->(.-)</testcase>') do
13+
if not contents:match('</failure>') then
14+
results[trans.get_position_id(context.file, test_name)] = {
3315
status = 'passed',
3416
}
35-
end)
17+
end
18+
end
19+
20+
return results
21+
end
22+
23+
---NOTE: This mutates results
24+
---
25+
---@param results table<string, neotest.Result>
26+
---@param context rustaceanvim.neotest.RunContext
27+
---@param output_content string
28+
---@return table<string, neotest.Result> results
29+
function M.populate_pass_positions_cargo_test(results, context, output_content)
30+
-- NOTE: ignore ANSI character for ok, if present: ^[[32mok^[[0;10m
31+
for test_name in output_content:gmatch('\ntest%s+([^\n]-)%s+%.%.%.%s+\27?%[?[0-9;]-m?ok\27?%[?[0-9;]-m?\r?\n') do
32+
results[trans.get_position_id(context.file, test_name)] = {
33+
status = 'passed',
34+
}
35+
end
3636
return results
3737
end
3838

lua/rustaceanvim/overrides.lua

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ function M.snippet_text_edits_to_text_edits(text_edits)
3333
end
3434
end
3535

36+
---@param arg_list string[] list of arguments
37+
---@return string[] runner args
38+
---@return string[] executable args
39+
local function partition_executable_args(arg_list)
40+
local delimiter = '--'
41+
local before = {}
42+
local after = {}
43+
local found_delimiter = false
44+
45+
for _, value in ipairs(arg_list) do
46+
if value == delimiter then
47+
found_delimiter = true
48+
elseif not found_delimiter then
49+
table.insert(before, value)
50+
else
51+
table.insert(after, value)
52+
end
53+
end
54+
55+
return before, after
56+
end
57+
3658
---Transforms the args to cargo-nextest args if it is detected.
3759
---Mutates command!
3860
---@param args string[]
@@ -44,23 +66,43 @@ function M.try_nextest_transform(args)
4466
args[1] = 'run'
4567
table.insert(args, 1, 'nextest')
4668
end
47-
if args[#args] == '--nocapture' then
48-
table.insert(args, 3, '--nocapture')
49-
table.remove(args, #args)
69+
local nextest_args, executable_args = partition_executable_args(args)
70+
71+
-- specify custom profile for junit output
72+
table.insert(nextest_args, '--profile')
73+
table.insert(nextest_args, 'rustaceanvim')
74+
table.insert(nextest_args, '--config-file')
75+
table.insert(nextest_args, require('rustaceanvim.cache').nextest_config_path())
76+
77+
-- tranform `-- test_something --exact` into `-E 'test("test_something")'`
78+
for i = 1, #executable_args do
79+
if executable_args[i] == '--exact' then
80+
local test_name = executable_args[i - 1]
81+
table.remove(executable_args, i - 1)
82+
table.remove(executable_args, i - 1)
83+
table.insert(nextest_args, test_name)
84+
break
85+
end
5086
end
87+
5188
local nextest_unsupported_flags = {
5289
'--show-output',
5390
}
5491
local indexes_to_remove_reverse_order = {}
55-
for i, arg in ipairs(args) do
92+
for i, arg in ipairs(executable_args) do
5693
if vim.list_contains(nextest_unsupported_flags, arg) then
5794
table.insert(indexes_to_remove_reverse_order, 1, i)
5895
end
5996
end
6097
for _, i in pairs(indexes_to_remove_reverse_order) do
61-
table.remove(args, i)
98+
table.remove(executable_args, i)
99+
end
100+
101+
table.insert(nextest_args, '--')
102+
for _, v in ipairs(executable_args) do
103+
table.insert(nextest_args, v)
62104
end
63-
return args
105+
return nextest_args
64106
end
65107

66108
-- sanitize_command_for_debugging substitutes the command arguments so it can be used to run a

0 commit comments

Comments
 (0)