Skip to content

Commit

Permalink
feat: structured parsers and diagnostics messages
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rcarriga committed Oct 24, 2021
1 parent 71290da commit 1b7cbee
Show file tree
Hide file tree
Showing 22 changed files with 1,670 additions and 161 deletions.
55 changes: 19 additions & 36 deletions autoload/ultest/process.vim
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,27 @@ for processor in g:ultest#processors
endif
endfor

function! s:CallProcessor(event, args) abort
for processor in g:ultest#active_processors
let func = get(processor, a:event, "")
if func != ""
if get(processor, "lua")
call luaeval(func."(unpack(_A))", a:args)
else
call call(func, a:args)
endif
endif
endfor
endfunction

function ultest#process#new(test) abort
call ultest#process#pre(a:test)
if index(g:ultest_buffers, a:test.file) == -1
let g:ultest_buffers = add(g:ultest_buffers, a:test.file)
endif
let tests = getbufvar(a:test.file, "ultest_tests", {})
let tests[a:test.id] = a:test
for processor in g:ultest#active_processors
let new = get(processor, "new", "")
if new != ""
call function(new)(a:test)
endif
endfor
call s:CallProcessor("new", [a:test])
endfunction

function ultest#process#start(test) abort
Expand All @@ -29,24 +37,14 @@ function ultest#process#start(test) abort
if has_key(results, a:test.id)
call remove(results, a:test.id)
endif
for processor in g:ultest#active_processors
let start = get(processor, "start", "")
if start != ""
call function(start)(a:test)
endif
endfor
call s:CallProcessor("start", [a:test])
endfunction

function ultest#process#move(test) abort
call ultest#process#pre(a:test)
let tests = getbufvar(a:test.file, "ultest_tests")
let tests[a:test.id] = a:test
for processor in g:ultest#active_processors
let start = get(processor, "move", "")
if start != ""
call function(start)(a:test)
endif
endfor
call s:CallProcessor("move", [a:test])
endfunction

function ultest#process#replace(test, result) abort
Expand All @@ -55,12 +53,7 @@ function ultest#process#replace(test, result) abort
let tests[a:test.id] = a:test
let results = getbufvar(a:result.file, "ultest_results")
let results[a:result.id] = a:result
for processor in g:ultest#active_processors
let exit = get(processor, "replace", "")
if exit != ""
call function(exit)(a:result)
endif
endfor
call s:CallProcessor("replace", [a:result])
endfunction

function ultest#process#clear(test) abort
Expand All @@ -73,12 +66,7 @@ function ultest#process#clear(test) abort
if has_key(results, a:test.id)
call remove(results, a:test.id)
endif
for processor in g:ultest#active_processors
let clear = get(processor, "clear", "")
if clear != ""
call function(clear)(a:test)
endif
endfor
call s:CallProcessor("clear", [a:test])
endfunction

function ultest#process#exit(test, result) abort
Expand All @@ -90,12 +78,7 @@ function ultest#process#exit(test, result) abort
let tests[a:test.id] = a:test
let results = getbufvar(a:result.file, "ultest_results")
let results[a:result.id] = a:result
for processor in g:ultest#active_processors
let exit = get(processor, "exit", "")
if exit != ""
call function(exit)(a:result)
endif
endfor
call s:CallProcessor("exit", [a:result])
endfunction

function ultest#process#pre(test) abort
Expand Down
43 changes: 20 additions & 23 deletions lua/ultest.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,37 +28,34 @@ local function dap_run_test(test, build_config)
end

local output_handler = function(_, body)
if vim.tbl_contains({"stdout", "stderr"}, body.category) then
if vim.tbl_contains({ "stdout", "stderr" }, body.category) then
io.write(body.output)
io.flush()
end
end

require("dap").run(
user_config.dap,
{
before = function(config)
local output_file = io.open(output_name, "w")
io.output(output_file)
vim.fn["ultest#handler#external_start"](test.id, test.file, output_name)
dap.listeners.after.event_output[handler_id] = output_handler
dap.listeners.before.event_terminated[handler_id] = terminated_handler
dap.listeners.after.event_exited[handler_id] = exit_handler
return config
end,
after = function()
dap.listeners.after.event_exited[handler_id] = nil
dap.listeners.before.event_terminated[handler_id] = nil
dap.listeners.after.event_output[handler_id] = nil
end
}
)
require("dap").run(user_config.dap, {
before = function(config)
local output_file = io.open(output_name, "w")
io.output(output_file)
vim.fn["ultest#handler#external_start"](test.id, test.file, output_name)
dap.listeners.after.event_output[handler_id] = output_handler
dap.listeners.before.event_terminated[handler_id] = terminated_handler
dap.listeners.after.event_exited[handler_id] = exit_handler
return config
end,
after = function()
dap.listeners.after.event_exited[handler_id] = nil
dap.listeners.before.event_terminated[handler_id] = nil
dap.listeners.after.event_output[handler_id] = nil
end,
})
end

local function get_builder(test, config)
local builder =
config.build_config or builders[vim.fn["ultest#adapter#get_runner"](test.file)] or
builders[vim.fn["getbufvar"](test.file, "&filetype")]
local builder = config.build_config
or builders[vim.fn["ultest#adapter#get_runner"](test.file)]
or builders[vim.fn["getbufvar"](test.file, "&filetype")]

if builder == nil then
print("Unsupported runner, need to provide a customer nvim-dap config builder")
Expand Down
132 changes: 132 additions & 0 deletions lua/ultest/diagnostic/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
local M = {}

local api = vim.api
local diag = vim.diagnostic

local tracking_namespace = api.nvim_create_namespace("_ultest_diagnostic_tracking")
local diag_namespace = api.nvim_create_namespace("ultest_diagnostic")

---@class NvimDiagnostic
---@field lnum integer The starting line of the diagnostic
---@field end_lnum integer The final line of the diagnostic
---@field col integer The starting column of the diagnostic
---@field end_col integer The final column of the diagnostic
---@field severity string The severity of the diagnostic |vim.diagnostic.severity|
---@field message string The diagnostic text
---@field source string The source of the diagnostic

---@class UltestTest
---@field type "test" | "file" | "namespace"
---@field id string
---@field name string
---@field file string
---@field line integer
---@field col integer
---@field running integer
---@field namespaces string[]
--
---@class UltestResult
---@field id string
---@field file string
---@field code integer
---@field output string
---@field error_message string[] | nil
---@field error_line integer | nil

local marks = {}
local error_code_lines = {}
local attached_buffers = {}

local function init_mark(bufnr, result)
marks[result.id] =
api.nvim_buf_set_extmark(bufnr, tracking_namespace, result.error_line - 1, 0, {end_line = result.error_line})
error_code_lines[result.id] = api.nvim_buf_get_lines(bufnr, result.error_line - 1, result.error_line, false)[1]
end

local function create_diagnostics(bufnr, results)
local diagnostics = {}
for _, result in pairs(results) do
if not marks[result.id] then
init_mark(bufnr, result)
end
local mark = api.nvim_buf_get_extmark_by_id(bufnr, tracking_namespace, marks[result.id], {})
local mark_code = api.nvim_buf_get_lines(bufnr, mark[1], mark[1] + 1, false)[1]
if mark_code == error_code_lines[result.id] then
diagnostics[#diagnostics + 1] = {
lnum = mark[1],
col = 0,
message = table.concat(result.error_message, "\n"),
source = "ultest"
}
end
end
return diagnostics
end

local function draw_buffer(file)
local bufnr = vim.fn.bufnr(file)
---@type UltestResult[]
local results = api.nvim_buf_get_var(bufnr, "ultest_results")

local valid_results =
vim.tbl_filter(
function(result)
return result.error_line and result.error_message
end,
results
)

local diagnostics = create_diagnostics(bufnr, valid_results)

diag.set(diag_namespace, bufnr, diagnostics)
end

local function clear_mark(test)
local bufnr = vim.fn.bufnr(test.file)
local mark_id = marks[test.id]
if not mark_id then
return
end
marks[test.id] = nil
api.nvim_buf_del_extmark(bufnr, tracking_namespace, mark_id)
end

local function attach_to_buf(file)
local bufnr = vim.fn.bufnr(file)
attached_buffers[file] = true

vim.api.nvim_buf_attach(
bufnr,
false,
{
on_lines = function()
draw_buffer(file)
end
}
)
end

---@param test UltestTest
function M.clear(test)
draw_buffer(test.file)
end

---@param test UltestTest
---@param result UltestResult
function M.exit(test, result)
if not attached_buffers[test.file] then
attach_to_buf(test.file)
end
clear_mark(test)

draw_buffer(test.file)
end

---@param test UltestTest
function M.delete(test)
clear_mark(test)

draw_buffer(test.file)
end

return M
11 changes: 11 additions & 0 deletions plugin/ultest.vim
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ let g:ultest_output_cols = get(g:, "ultest_output_cols", 0)
" (default: 1)
let g:ultest_show_in_file = get(g:, "ultest_show_in_file", 1)

"" Enable diagnostic error messages (NeoVim only)
" (default: 1)
let g:ultest_diagnostic_messages = get(g:, "ultest_diagnostic_messages", 1)

""
" Use virtual text (if available) instead of signs to show test results in file.
" (default: 0)
Expand Down Expand Up @@ -230,6 +234,13 @@ let g:ultest#processors = [
\ "move": "ultest#summary#render",
\ "replace": "ultest#summary#render"
\ },
\ {
\ "condition": has("nvim") && g:ultest_diagnostic_messages,
\ "lua": v:true,
\ "clear": "require('ultest.diagnostic').clear",
\ "exit": "require('ultest.diagnostic').exit",
\ "delete": "require('ultest.diagnostic').delete",
\ },
\] + get(g:, "ultest_custom_processors", [])

""
Expand Down
1 change: 1 addition & 0 deletions rplugin/python3/ultest/handler/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ def clear_results(self, file_name: str):
positions = self._tracker.file_positions(file_name)
if not positions:
logger.error("Successfully cleared results for unknown file")
return

for position in positions:
if position.id in cleared:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import re
from dataclasses import dataclass
from typing import Iterator, List, Optional
from typing import Iterable, Iterator, List, Optional

from ...logging import get_logger


@dataclass(frozen=True)
class ParseResult:
name: str
namespaces: List[str]
from ....logging import get_logger
from .base import ParseResult
from .parsec import ParseError
from .python.pytest import pytest_output


@dataclass
Expand All @@ -20,10 +17,6 @@ class OutputPatterns:


_BASE_PATTERNS = {
"python#pytest": OutputPatterns(
failed_test=r"^(FAILED|ERROR) .+?::(?P<namespaces>.+::)?(?P<name>.*?)( |$)",
namespace_separator="::",
),
"python#pyunit": OutputPatterns(
failed_test=r"^FAIL: (?P<name>.*) \(.*?(?P<namespaces>\..+)\)",
namespace_separator=r"\.",
Expand All @@ -50,16 +43,27 @@ class OutputPatterns:

class OutputParser:
def __init__(self, disable_patterns: List[str]) -> None:
self._parsers = {"python#pytest": pytest_output}
self._patterns = {
runner: patterns
for runner, patterns in _BASE_PATTERNS.items()
if runner not in disable_patterns
}

def can_parse(self, runner: str) -> bool:
return runner in self._patterns
return runner in self._patterns or runner in self._parsers

def parse_failed(self, runner: str, output: str) -> Iterable[ParseResult]:
if runner in self._parsers:
try:
return self._parsers[runner].parse(_ANSI_ESCAPE.sub("", output)).results
except ParseError:
return []
return self._regex_parse_failed(runner, output.splitlines())

def parse_failed(self, runner: str, output: List[str]) -> Iterator[ParseResult]:
def _regex_parse_failed(
self, runner: str, output: List[str]
) -> Iterator[ParseResult]:
pattern = self._patterns[runner]
fail_pattern = re.compile(pattern.failed_test)
for line in output:
Expand All @@ -86,4 +90,4 @@ def parse_failed(self, runner: str, output: List[str]) -> Iterator[ParseResult]:
if pattern.failed_name_prefix
else match["name"]
)
yield ParseResult(name=name, namespaces=namespaces)
yield ParseResult(name=name, namespaces=namespaces, file="")
Loading

0 comments on commit 1b7cbee

Please sign in to comment.