Skip to content

Commit a376b2d

Browse files
committed
feat: structured parsers and diagnostics messages
Implements the requirements for #73 to show diagnsotic messages beside the lines that cause an error. A parser will need to be implemented for each runner. Structured parsing is also required for issue #70 so ultest can parse results of multiple files.
1 parent 71290da commit a376b2d

File tree

22 files changed

+1676
-159
lines changed

22 files changed

+1676
-159
lines changed

autoload/ultest/process.vim

+19-36
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@ for processor in g:ultest#processors
66
endif
77
endfor
88

9+
function! s:CallProcessor(event, args) abort
10+
for processor in g:ultest#active_processors
11+
let func = get(processor, a:event, "")
12+
if func != ""
13+
if get(processor, "lua")
14+
call luaeval(func."(unpack(_A))", a:args)
15+
else
16+
call call(func, a:args)
17+
endif
18+
endif
19+
endfor
20+
endfunction
21+
922
function ultest#process#new(test) abort
1023
call ultest#process#pre(a:test)
1124
if index(g:ultest_buffers, a:test.file) == -1
1225
let g:ultest_buffers = add(g:ultest_buffers, a:test.file)
1326
endif
1427
let tests = getbufvar(a:test.file, "ultest_tests", {})
1528
let tests[a:test.id] = a:test
16-
for processor in g:ultest#active_processors
17-
let new = get(processor, "new", "")
18-
if new != ""
19-
call function(new)(a:test)
20-
endif
21-
endfor
29+
call s:CallProcessor("new", [a:test])
2230
endfunction
2331

2432
function ultest#process#start(test) abort
@@ -29,24 +37,14 @@ function ultest#process#start(test) abort
2937
if has_key(results, a:test.id)
3038
call remove(results, a:test.id)
3139
endif
32-
for processor in g:ultest#active_processors
33-
let start = get(processor, "start", "")
34-
if start != ""
35-
call function(start)(a:test)
36-
endif
37-
endfor
40+
call s:CallProcessor("start", [a:test])
3841
endfunction
3942

4043
function ultest#process#move(test) abort
4144
call ultest#process#pre(a:test)
4245
let tests = getbufvar(a:test.file, "ultest_tests")
4346
let tests[a:test.id] = a:test
44-
for processor in g:ultest#active_processors
45-
let start = get(processor, "move", "")
46-
if start != ""
47-
call function(start)(a:test)
48-
endif
49-
endfor
47+
call s:CallProcessor("move", [a:test])
5048
endfunction
5149

5250
function ultest#process#replace(test, result) abort
@@ -55,12 +53,7 @@ function ultest#process#replace(test, result) abort
5553
let tests[a:test.id] = a:test
5654
let results = getbufvar(a:result.file, "ultest_results")
5755
let results[a:result.id] = a:result
58-
for processor in g:ultest#active_processors
59-
let exit = get(processor, "replace", "")
60-
if exit != ""
61-
call function(exit)(a:result)
62-
endif
63-
endfor
56+
call s:CallProcessor("replace", [a:result])
6457
endfunction
6558

6659
function ultest#process#clear(test) abort
@@ -73,12 +66,7 @@ function ultest#process#clear(test) abort
7366
if has_key(results, a:test.id)
7467
call remove(results, a:test.id)
7568
endif
76-
for processor in g:ultest#active_processors
77-
let clear = get(processor, "clear", "")
78-
if clear != ""
79-
call function(clear)(a:test)
80-
endif
81-
endfor
69+
call s:CallProcessor("clear", [a:test])
8270
endfunction
8371

8472
function ultest#process#exit(test, result) abort
@@ -90,12 +78,7 @@ function ultest#process#exit(test, result) abort
9078
let tests[a:test.id] = a:test
9179
let results = getbufvar(a:result.file, "ultest_results")
9280
let results[a:result.id] = a:result
93-
for processor in g:ultest#active_processors
94-
let exit = get(processor, "exit", "")
95-
if exit != ""
96-
call function(exit)(a:result)
97-
endif
98-
endfor
81+
call s:CallProcessor("exit", [a:result])
9982
endfunction
10083

10184
function ultest#process#pre(test) abort

lua/ultest.lua

+20-23
Original file line numberDiff line numberDiff line change
@@ -28,37 +28,34 @@ local function dap_run_test(test, build_config)
2828
end
2929

3030
local output_handler = function(_, body)
31-
if vim.tbl_contains({"stdout", "stderr"}, body.category) then
31+
if vim.tbl_contains({ "stdout", "stderr" }, body.category) then
3232
io.write(body.output)
3333
io.flush()
3434
end
3535
end
3636

37-
require("dap").run(
38-
user_config.dap,
39-
{
40-
before = function(config)
41-
local output_file = io.open(output_name, "w")
42-
io.output(output_file)
43-
vim.fn["ultest#handler#external_start"](test.id, test.file, output_name)
44-
dap.listeners.after.event_output[handler_id] = output_handler
45-
dap.listeners.before.event_terminated[handler_id] = terminated_handler
46-
dap.listeners.after.event_exited[handler_id] = exit_handler
47-
return config
48-
end,
49-
after = function()
50-
dap.listeners.after.event_exited[handler_id] = nil
51-
dap.listeners.before.event_terminated[handler_id] = nil
52-
dap.listeners.after.event_output[handler_id] = nil
53-
end
54-
}
55-
)
37+
require("dap").run(user_config.dap, {
38+
before = function(config)
39+
local output_file = io.open(output_name, "w")
40+
io.output(output_file)
41+
vim.fn["ultest#handler#external_start"](test.id, test.file, output_name)
42+
dap.listeners.after.event_output[handler_id] = output_handler
43+
dap.listeners.before.event_terminated[handler_id] = terminated_handler
44+
dap.listeners.after.event_exited[handler_id] = exit_handler
45+
return config
46+
end,
47+
after = function()
48+
dap.listeners.after.event_exited[handler_id] = nil
49+
dap.listeners.before.event_terminated[handler_id] = nil
50+
dap.listeners.after.event_output[handler_id] = nil
51+
end,
52+
})
5653
end
5754

5855
local function get_builder(test, config)
59-
local builder =
60-
config.build_config or builders[vim.fn["ultest#adapter#get_runner"](test.file)] or
61-
builders[vim.fn["getbufvar"](test.file, "&filetype")]
56+
local builder = config.build_config
57+
or builders[vim.fn["ultest#adapter#get_runner"](test.file)]
58+
or builders[vim.fn["getbufvar"](test.file, "&filetype")]
6259

6360
if builder == nil then
6461
print("Unsupported runner, need to provide a customer nvim-dap config builder")

lua/ultest/diagnostic/init.lua

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
local M = {}
2+
3+
local api = vim.api
4+
local diag = vim.diagnostic
5+
6+
local tracking_namespace = api.nvim_create_namespace("_ultest_diagnostic_tracking")
7+
local diag_namespace = api.nvim_create_namespace("ultest_diagnostic")
8+
9+
---@class NvimDiagnostic
10+
---@field lnum integer The starting line of the diagnostic
11+
---@field end_lnum integer The final line of the diagnostic
12+
---@field col integer The starting column of the diagnostic
13+
---@field end_col integer The final column of the diagnostic
14+
---@field severity string The severity of the diagnostic |vim.diagnostic.severity|
15+
---@field message string The diagnostic text
16+
---@field source string The source of the diagnostic
17+
18+
---@class UltestTest
19+
---@field type "test" | "file" | "namespace"
20+
---@field id string
21+
---@field name string
22+
---@field file string
23+
---@field line integer
24+
---@field col integer
25+
---@field running integer
26+
---@field namespaces string[]
27+
--
28+
---@class UltestResult
29+
---@field id string
30+
---@field file string
31+
---@field code integer
32+
---@field output string
33+
---@field error_message string[] | nil
34+
---@field error_line integer | nil
35+
36+
local marks = {}
37+
local error_code_lines = {}
38+
local attached_buffers = {}
39+
40+
local function init_mark(bufnr, result)
41+
marks[result.id] = api.nvim_buf_set_extmark(
42+
bufnr,
43+
tracking_namespace,
44+
result.error_line - 1,
45+
0,
46+
{ end_line = result.error_line }
47+
)
48+
error_code_lines[result.id] = api.nvim_buf_get_lines(
49+
bufnr,
50+
result.error_line - 1,
51+
result.error_line,
52+
false
53+
)[1]
54+
end
55+
56+
local function create_diagnostics(bufnr, results)
57+
local diagnostics = {}
58+
for _, result in pairs(results) do
59+
if not marks[result.id] then
60+
init_mark(bufnr, result)
61+
end
62+
local mark = api.nvim_buf_get_extmark_by_id(bufnr, tracking_namespace, marks[result.id], {})
63+
local mark_code = api.nvim_buf_get_lines(bufnr, mark[1], mark[1] + 1, false)[1]
64+
if mark_code == error_code_lines[result.id] then
65+
diagnostics[#diagnostics + 1] = {
66+
lnum = mark[1],
67+
col = 0,
68+
message = table.concat(result.error_message, "\n"),
69+
source = "ultest",
70+
}
71+
end
72+
end
73+
return diagnostics
74+
end
75+
76+
local function draw_buffer(file)
77+
local bufnr = vim.fn.bufnr(file)
78+
---@type UltestResult[]
79+
local results = api.nvim_buf_get_var(bufnr, "ultest_results")
80+
81+
local valid_results = vim.tbl_filter(function(result)
82+
return result.error_line and result.error_message
83+
end, results)
84+
85+
local diagnostics = create_diagnostics(bufnr, valid_results)
86+
87+
diag.set(diag_namespace, bufnr, diagnostics)
88+
end
89+
90+
local function clear_mark(test)
91+
local bufnr = vim.fn.bufnr(test.file)
92+
local mark_id = marks[test.id]
93+
if not mark_id then
94+
return
95+
end
96+
marks[test.id] = nil
97+
api.nvim_buf_del_extmark(bufnr, tracking_namespace, mark_id)
98+
end
99+
100+
local function attach_to_buf(file)
101+
local bufnr = vim.fn.bufnr(file)
102+
attached_buffers[file] = true
103+
104+
vim.api.nvim_buf_attach(bufnr, false, {
105+
on_lines = function()
106+
draw_buffer(file)
107+
end,
108+
})
109+
end
110+
111+
---@param test UltestTest
112+
function M.clear(test)
113+
draw_buffer(test.file)
114+
end
115+
116+
---@param test UltestTest
117+
---@param result UltestResult
118+
function M.exit(test, result)
119+
if not attached_buffers[test.file] then
120+
attach_to_buf(test.file)
121+
end
122+
clear_mark(test)
123+
124+
draw_buffer(test.file)
125+
end
126+
127+
---@param test UltestTest
128+
function M.delete(test)
129+
clear_mark(test)
130+
131+
draw_buffer(test.file)
132+
end
133+
134+
return M

plugin/ultest.vim

+11
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ let g:ultest_output_cols = get(g:, "ultest_output_cols", 0)
132132
" (default: 1)
133133
let g:ultest_show_in_file = get(g:, "ultest_show_in_file", 1)
134134

135+
"" Enable diagnostic error messages (NeoVim only)
136+
" (default: 1)
137+
let g:ultest_diagnostic_messages = get(g:, "ultest_diagnostic_messages", 1)
138+
135139
""
136140
" Use virtual text (if available) instead of signs to show test results in file.
137141
" (default: 0)
@@ -230,6 +234,13 @@ let g:ultest#processors = [
230234
\ "move": "ultest#summary#render",
231235
\ "replace": "ultest#summary#render"
232236
\ },
237+
\ {
238+
\ "condition": has("nvim") && g:ultest_diagnostic_messages,
239+
\ "lua": v:true,
240+
\ "clear": "require('ultest.diagnostic').clear",
241+
\ "exit": "require('ultest.diagnostic').exit",
242+
\ "delete": "require('ultest.diagnostic').delete",
243+
\ },
233244
\] + get(g:, "ultest_custom_processors", [])
234245

235246
""

rplugin/python3/ultest/handler/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def clear_results(self, file_name: str):
251251
positions = self._tracker.file_positions(file_name)
252252
if not positions:
253253
logger.error("Successfully cleared results for unknown file")
254+
return
254255

255256
for position in positions:
256257
if position.id in cleared:

rplugin/python3/ultest/handler/parsers/output.py rplugin/python3/ultest/handler/parsers/output/__init__.py

+15-15
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import re
22
from dataclasses import dataclass
3-
from typing import Iterator, List, Optional
3+
from typing import Iterable, Iterator, List, Optional
44

5-
from ...logging import get_logger
6-
7-
8-
@dataclass(frozen=True)
9-
class ParseResult:
10-
name: str
11-
namespaces: List[str]
5+
from ....logging import get_logger
6+
from .base import ParseResult
7+
from .python.pytest import PytestParser
128

139

1410
@dataclass
@@ -20,10 +16,6 @@ class OutputPatterns:
2016

2117

2218
_BASE_PATTERNS = {
23-
"python#pytest": OutputPatterns(
24-
failed_test=r"^(FAILED|ERROR) .+?::(?P<namespaces>.+::)?(?P<name>.*?)( |$)",
25-
namespace_separator="::",
26-
),
2719
"python#pyunit": OutputPatterns(
2820
failed_test=r"^FAIL: (?P<name>.*) \(.*?(?P<namespaces>\..+)\)",
2921
namespace_separator=r"\.",
@@ -50,16 +42,24 @@ class OutputPatterns:
5042

5143
class OutputParser:
5244
def __init__(self, disable_patterns: List[str]) -> None:
45+
self._parsers = {"python#pytest": PytestParser()}
5346
self._patterns = {
5447
runner: patterns
5548
for runner, patterns in _BASE_PATTERNS.items()
5649
if runner not in disable_patterns
5750
}
5851

5952
def can_parse(self, runner: str) -> bool:
60-
return runner in self._patterns
53+
return runner in self._patterns or runner in self._parsers
54+
55+
def parse_failed(self, runner: str, output: List[str]) -> Iterable[ParseResult]:
56+
if runner in self._parsers:
57+
return self._parsers[runner].parse_ansi("".join(output)).results
58+
return self._regex_parse_failed(runner, output)
6159

62-
def parse_failed(self, runner: str, output: List[str]) -> Iterator[ParseResult]:
60+
def _regex_parse_failed(
61+
self, runner: str, output: List[str]
62+
) -> Iterator[ParseResult]:
6363
pattern = self._patterns[runner]
6464
fail_pattern = re.compile(pattern.failed_test)
6565
for line in output:
@@ -86,4 +86,4 @@ def parse_failed(self, runner: str, output: List[str]) -> Iterator[ParseResult]:
8686
if pattern.failed_name_prefix
8787
else match["name"]
8888
)
89-
yield ParseResult(name=name, namespaces=namespaces)
89+
yield ParseResult(name=name, namespaces=namespaces, file="")

0 commit comments

Comments
 (0)