From 1ecdd846955f49f1efdeffea7fcc2dd35d4cd60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Sat, 23 Oct 2021 09:18:32 +0100 Subject: [PATCH 1/7] 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. --- autoload/ultest/process.vim | 55 +- lua/ultest.lua | 43 +- lua/ultest/diagnostic/init.lua | 132 +++ plugin/ultest.vim | 11 + rplugin/python3/ultest/handler/__init__.py | 1 + .../parsers/{output.py => output/__init__.py} | 34 +- .../ultest/handler/parsers/output/base.py | 42 + .../ultest/handler/parsers/output/parsec.py | 776 ++++++++++++++++++ .../ultest/handler/parsers/output/parsec.pyi | 119 +++ .../handler/parsers/output/python/__init__.py | 0 .../handler/parsers/output/python/pytest.py | 148 ++++ .../python3/ultest/handler/runner/__init__.py | 61 +- .../python3/ultest/handler/runner/handle.py | 6 +- rplugin/python3/ultest/models/result.py | 7 +- rplugin/python3/ultest/models/tree.py | 2 +- stylua.toml | 5 + tests/mocks/__init__.py | 4 +- tests/mocks/test_outputs/pytest | 137 +++- tests/unit/handler/parsers/output/__init__.py | 0 .../handler/parsers/output/python/__init__.py | 0 .../parsers/output/python/test_pytest.py | 210 +++++ tests/unit/handler/parsers/test_output.py | 29 +- 22 files changed, 1665 insertions(+), 157 deletions(-) create mode 100644 lua/ultest/diagnostic/init.lua rename rplugin/python3/ultest/handler/parsers/{output.py => output/__init__.py} (76%) create mode 100644 rplugin/python3/ultest/handler/parsers/output/base.py create mode 100644 rplugin/python3/ultest/handler/parsers/output/parsec.py create mode 100644 rplugin/python3/ultest/handler/parsers/output/parsec.pyi create mode 100644 rplugin/python3/ultest/handler/parsers/output/python/__init__.py create mode 100644 rplugin/python3/ultest/handler/parsers/output/python/pytest.py create mode 100644 stylua.toml create mode 100644 tests/unit/handler/parsers/output/__init__.py create mode 100644 tests/unit/handler/parsers/output/python/__init__.py create mode 100644 tests/unit/handler/parsers/output/python/test_pytest.py diff --git a/autoload/ultest/process.vim b/autoload/ultest/process.vim index 6fdf311..d53f063 100644 --- a/autoload/ultest/process.vim +++ b/autoload/ultest/process.vim @@ -6,6 +6,19 @@ 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 @@ -13,12 +26,7 @@ function ultest#process#new(test) abort 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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/lua/ultest.lua b/lua/ultest.lua index 19948ce..87277fe 100644 --- a/lua/ultest.lua +++ b/lua/ultest.lua @@ -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") diff --git a/lua/ultest/diagnostic/init.lua b/lua/ultest/diagnostic/init.lua new file mode 100644 index 0000000..e4fff98 --- /dev/null +++ b/lua/ultest/diagnostic/init.lua @@ -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 diff --git a/plugin/ultest.vim b/plugin/ultest.vim index 1ce2210..c8ebe43 100644 --- a/plugin/ultest.vim +++ b/plugin/ultest.vim @@ -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) @@ -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", []) "" diff --git a/rplugin/python3/ultest/handler/__init__.py b/rplugin/python3/ultest/handler/__init__.py index e5b69bc..7eb792b 100644 --- a/rplugin/python3/ultest/handler/__init__.py +++ b/rplugin/python3/ultest/handler/__init__.py @@ -253,6 +253,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: diff --git a/rplugin/python3/ultest/handler/parsers/output.py b/rplugin/python3/ultest/handler/parsers/output/__init__.py similarity index 76% rename from rplugin/python3/ultest/handler/parsers/output.py rename to rplugin/python3/ultest/handler/parsers/output/__init__.py index 59f57da..c2ff5bc 100644 --- a/rplugin/python3/ultest/handler/parsers/output.py +++ b/rplugin/python3/ultest/handler/parsers/output/__init__.py @@ -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 @@ -20,10 +17,6 @@ class OutputPatterns: _BASE_PATTERNS = { - "python#pytest": OutputPatterns( - failed_test=r"^(FAILED|ERROR) .+?::(?P.+::)?(?P[^[\s]*)(.+])?( |$)", - namespace_separator="::", - ), "python#pyunit": OutputPatterns( failed_test=r"^FAIL: (?P.*) \(.*?(?P\..+)\)", namespace_separator=r"\.", @@ -50,6 +43,7 @@ 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() @@ -57,9 +51,19 @@ def __init__(self, disable_patterns: List[str]) -> None: } 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: @@ -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="") diff --git a/rplugin/python3/ultest/handler/parsers/output/base.py b/rplugin/python3/ultest/handler/parsers/output/base.py new file mode 100644 index 0000000..bdbaf66 --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/base.py @@ -0,0 +1,42 @@ +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List, Optional, Set + +from .parsec import ParseError + + +@dataclass(frozen=True) +class ParseResult: + name: str + namespaces: List[str] + file: str + message: Optional[List[str]] = None + output: Optional[List[str]] = None + line: Optional[int] = None + + +@dataclass(frozen=True) +class ParsedOutput: + results: List[ParseResult] + + +_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +class BaseParser(ABC): + @property + @abstractmethod + def runners(self) -> Set[str]: + ... + + @abstractmethod + def parse(self, output: str) -> ParsedOutput: + ... + + def parse_ansi(self, output: str) -> ParsedOutput: + clean_output, _ = _ANSI_ESCAPE.subn("", output) + try: + return self.parse(clean_output.replace("\r\n", "\n")) + except ParseError: + return ParsedOutput(results=[]) diff --git a/rplugin/python3/ultest/handler/parsers/output/parsec.py b/rplugin/python3/ultest/handler/parsers/output/parsec.py new file mode 100644 index 0000000..6cddb71 --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/parsec.py @@ -0,0 +1,776 @@ +# https://raw.githubusercontent.com/sighingnow/parsec.py/master/src/parsec/__init__.py +__author__ = "He Tao, sighingnow@gmail.com" + +import re +from collections import namedtuple +from functools import wraps +from typing import Generic, TypeVar + +########################################################################## +# Text.Parsec.Error +########################################################################## + + +class ParseError(RuntimeError): + """Parser error.""" + + def __init__(self, expected, text, index): + super(ParseError, self).__init__() # compatible with Python 2. + self.expected = expected + self.text = text + self.index = index + + @staticmethod + def loc_info(text, index): + """Location of `index` in source code `text`.""" + if index > len(text): + raise ValueError("Invalid index.") + line, last_ln = text.count("\n", 0, index), text.rfind("\n", 0, index) + col = index - (last_ln + 1) + return (line, col) + + def loc(self): + """Locate the error position in the source code text.""" + try: + return "{}:{}".format(*ParseError.loc_info(self.text, self.index)) + except ValueError: + return "".format(self.index) + + def __str__(self): + return "expected {} at {}".format(self.expected, self.loc()) + + +########################################################################## +# Definition the Value model of parsec.py. +########################################################################## + + +class Value(namedtuple("Value", "status index value expected")): + """Represent the result of the Parser.""" + + @staticmethod + def success(index, actual): + """Create success value.""" + return Value(True, index, actual, None) + + @staticmethod + def failure(index, expected): + """Create failure value.""" + return Value(False, index, None, expected) + + def aggregate(self, other=None): + """collect the furthest failure from self and other.""" + if not self.status: + return self + if not other: + return self + if not other.status: + return other + return Value(True, other.index, self.value + other.value, None) + + @staticmethod + def combinate(values): + """aggregate multiple values into tuple""" + prev_v = None + for v in values: + if prev_v: + if not v: + return prev_v + if not v.status: + return v + out_values = tuple([v.value for v in values]) + return Value(True, values[-1].index, out_values, None) + + def __str__(self): + return "Value: state: {}, @index: {}, values: {}, expected: {}".format( + self.status, self.index, self.value, self.expected + ) + + +########################################################################## +# Text.Parsec.Prim +########################################################################## + +U = TypeVar("U") + + +class Parser(Generic[U]): + """ + A Parser is an object that wraps a function to do the parsing work. + Arguments of the function should be a string to be parsed and the index on + which to begin parsing. + The function should return either Value.success(next_index, value) if + parsing successfully, or Value.failure(index, expected) on the failure. + """ + + def __init__(self, fn): + """`fn` is the function to wrap.""" + self.fn = fn + + def __call__(self, text, index): + """call wrapped function.""" + return self.fn(text, index) + + def parse(self, text): + """Parses a given string `text`.""" + return self.parse_partial(text)[0] + + def parse_partial(self, text): + """Parse the longest possible prefix of a given string. + Return a tuple of the result value and the rest of the string. + If failed, raise a ParseError.""" + if not isinstance(text, str): + raise TypeError("Can only parsing string but got {!r}".format(text)) + res = self(text, 0) + if res.status: + return (res.value, text[res.index :]) + else: + raise ParseError(res.expected, text, res.index) + + def parse_strict(self, text): + """Parse the longest possible prefix of the entire given string. + If the parser worked successfully and NONE text was rested, return the + result value, else raise a ParseError. + The difference between `parse` and `parse_strict` is that whether entire + given text must be used.""" + # pylint: disable=comparison-with-callable + # Here the `<` is not comparison. + return (self < eof()).parse_partial(text)[0] + + def bind(self, fn): + """This is the monadic binding operation. Returns a parser which, if + parser is successful, passes the result to fn, and continues with the + parser returned from fn. + """ + + @Parser + def bind_parser(text, index): + res = self(text, index) + return res if not res.status else fn(res.value)(text, res.index) + + return bind_parser + + def compose(self, other): + """(>>) Sequentially compose two actions, discarding any value produced + by the first.""" + + @Parser + def compose_parser(text, index): + res = self(text, index) + return res if not res.status else other(text, res.index) + + return compose_parser + + def joint(self, *parsers): + """(+) Joint two or more parsers into one. Return the aggregate of two results + from this two parser.""" + return joint(self, *parsers) + + def choice(self, other): + """(|) This combinator implements choice. The parser p | q first applies p. + If it succeeds, the value of p is returned. + If p fails **without consuming any input**, parser q is tried. + NOTICE: without backtrack.""" + + @Parser + def choice_parser(text, index): + res = self(text, index) + return res if res.status or res.index != index else other(text, index) + + return choice_parser + + def try_choice(self, other): + """(^) Choice with backtrack. This combinator is used whenever arbitrary + look ahead is needed. The parser p || q first applies p, if it success, + the value of p is returned. If p fails, it pretends that it hasn't consumed + any input, and then parser q is tried. + """ + + @Parser + def try_choice_parser(text, index): + res = self(text, index) + return res if res.status else other(text, index) + + return try_choice_parser + + def skip(self, other): + """(<<) Ends with a specified parser, and at the end parser consumed the + end flag.""" + + @Parser + def ends_with_parser(text, index): + res = self(text, index) + if not res.status: + return res + end = other(text, res.index) + if end.status: + return Value.success(end.index, res.value) + else: + return Value.failure(end.index, "ends with {}".format(end.expected)) + + return ends_with_parser + + def ends_with(self, other): + """(<) Ends with a specified parser, and at the end parser hasn't consumed + any input.""" + + @Parser + def ends_with_parser(text, index): + res = self(text, index) + if not res.status: + return res + end = other(text, res.index) + if end.status: + return res + else: + return Value.failure(end.index, "ends with {}".format(end.expected)) + + return ends_with_parser + + def parsecmap(self, fn): + """Returns a parser that transforms the produced value of parser with `fn`.""" + return self.bind( + lambda res: Parser(lambda _, index: Value.success(index, fn(res))) + ) + + def parsecapp(self, other): + """Returns a parser that applies the produced value of this parser to the produced value of `other`.""" + # pylint: disable=unnecessary-lambda + return self.bind(lambda res: other.parsecmap(lambda x: res(x))) + + def result(self, res): + """Return a value according to the parameter `res` when parse successfully.""" + return self >> Parser(lambda _, index: Value.success(index, res)) + + def mark(self): + """Mark the line and column information of the result of this parser.""" + + def pos(text, index): + return ParseError.loc_info(text, index) + + @Parser + def mark_parser(text, index): + res = self(text, index) + if res.status: + return Value.success( + res.index, (pos(text, index), res.value, pos(text, res.index)) + ) + else: + return res # failed. + + return mark_parser + + def desc(self, description): + """Describe a parser, when it failed, print out the description text.""" + return self | Parser(lambda _, index: Value.failure(index, description)) + + def __or__(self, other): + """Implements the `(|)` operator, means `choice`.""" + return self.choice(other) + + def __xor__(self, other): + """Implements the `(^)` operator, means `try_choice`.""" + return self.try_choice(other) + + def __add__(self, other): + """Implements the `(+)` operator, means `joint`.""" + return self.joint(other) + + def __rshift__(self, other): + """Implements the `(>>)` operator, means `compose`.""" + return self.compose(other) + + def __irshift__(self, other): + """Implements the `(>>=)` operator, means `bind`.""" + return self.bind(other) + + def __lshift__(self, other): + """Implements the `(<<)` operator, means `skip`.""" + return self.skip(other) + + def __lt__(self, other): + """Implements the `(<)` operator, means `ends_with`.""" + return self.ends_with(other) + + +def parse(p, text, index=0): + """Parse a string and return the result or raise a ParseError.""" + return p.parse(text[index:]) + + +def bind(p, fn): + """Bind two parsers, implements the operator of `(>>=)`.""" + return p.bind(fn) + + +def compose(pa, pb): + """Compose two parsers, implements the operator of `(>>)`.""" + return pa.compose(pb) + + +def joint(*parsers): + """Joint two or more parsers, implements the operator of `(+)`.""" + + @Parser + def joint_parser(text, index): + values = [] + prev_v = None + for p in parsers: + if prev_v: + index = prev_v.index + prev_v = v = p(text, index) + if not v.status: + return v + values.append(v) + return Value.combinate(values) + + return joint_parser + + +def choice(pa, pb): + """Choice one from two parsers, implements the operator of `(|)`.""" + return pa.choice(pb) + + +def try_choice(pa, pb): + """Choice one from two parsers with backtrack, implements the operator of `(^)`.""" + return pa.try_choice(pb) + + +def skip(pa, pb): + """Ends with a specified parser, and at the end parser consumed the end flag. + Implements the operator of `(<<)`.""" + return pa.skip(pb) + + +def ends_with(pa, pb): + """Ends with a specified parser, and at the end parser hasn't consumed any input. + Implements the operator of `(<)`.""" + return pa.ends_with(pb) + + +def parsecmap(p, fn): + """Returns a parser that transforms the produced value of parser with `fn`.""" + return p.parsecmap(fn) + + +def parsecapp(p, other): + """Returns a parser that applies the produced value of this parser to the produced value of `other`. + There should be an operator `(<*>)`, but that is impossible in Python. + """ + return p.parsecapp(other) + + +def result(p, res): + """Return a value according to the parameter `res` when parse successfully.""" + return p.result(res) + + +def mark(p): + """Mark the line and column information of the result of the parser `p`.""" + return p.mark() + + +def desc(p, description): + """Describe a parser, when it failed, print out the description text.""" + return p.desc(description) + + +########################################################################## +# Parser Generator +# +# The most powerful way to construct a parser is to use the generate decorator. +# the `generate` creates a parser from a generator that should yield parsers. +# These parsers are applied successively and their results are sent back to the +# generator using `.send()` protocol. The generator should return the result or +# another parser, which is equivalent to applying it and returning its result. +# +# Note that `return` with arguments inside generator is not supported in Python 2. +# Instead, we can raise a `StopIteration` to return the result in Python 2. +# +# See #15 and `test_generate_raise` in tests/test_parsec.py +########################################################################## + + +def generate(fn): + """Parser generator. (combinator syntax).""" + if isinstance(fn, str): + return lambda f: generate(f).desc(fn) + + @wraps(fn) + @Parser + def generated(text, index): + iterator, value = fn(), None + try: + while True: + parser = iterator.send(value) + res = parser(text, index) + if not res.status: # this parser failed. + return res + value, index = res.value, res.index # iterate + except StopIteration as stop: + endval = stop.value + if isinstance(endval, Parser): + return endval(text, index) + else: + return Value.success(index, endval) + except RuntimeError as error: + stop = error.__cause__ + endval = stop.value + if isinstance(endval, Parser): + return endval(text, index) + else: + return Value.success(index, endval) + + return generated.desc(fn.__name__) + + +########################################################################## +# Text.Parsec.Combinator +########################################################################## + + +def times(p, mint, maxt=None): + """Repeat a parser between `mint` and `maxt` times. DO AS MUCH MATCH AS IT CAN. + Return a list of values.""" + maxt = maxt if maxt else mint + + @Parser + def times_parser(text, index): + cnt, values, res = 0, Value.success(index, []), None + while cnt < maxt: + res = p(text, index) + if res.status: + values = values.aggregate(Value.success(res.index, [res.value])) + index, cnt = res.index, cnt + 1 + else: + if cnt >= mint: + break + else: + return res # failed, throw exception. + if cnt >= maxt: # finish. + break + # If we don't have any remaining text to start next loop, we need break. + # + # We cannot put the `index < len(text)` in where because some parser can + # success even when we have no any text. We also need to detect if the + # parser consume no text. + # + # See: #28 + if index >= len(text): + if cnt >= mint: + break # we already have decent result to return + else: + r = p(text, index) + if ( + index != r.index + ): # report error when the parser cannot success with no text + return Value.failure( + index, "already meets the end, no enough text" + ) + return values + + return times_parser + + +def count(p, n): + """`count p n` parses n occurrences of p. If n is smaller or equal to zero, + the parser equals to return []. Returns a list of n values returned by p.""" + return times(p, n, n) + + +def optional(p, default_value=None): + """`Make a parser as optional. If success, return the result, otherwise return + default_value silently, without raising any exception. If default_value is not + provided None is returned instead. + """ + + @Parser + def optional_parser(text, index): + res = p(text, index) + if res.status: + return Value.success(res.index, res.value) + else: + # Return the maybe existing default value without doing anything. + return Value.success(index, default_value) + + return optional_parser + + +def many(p): + """Repeat a parser 0 to infinity times. DO AS MUCH MATCH AS IT CAN. + Return a list of values.""" + return times(p, 0, float("inf")) + + +def many1(p): + """Repeat a parser 1 to infinity times. DO AS MUCH MATCH AS IT CAN. + Return a list of values.""" + return times(p, 1, float("inf")) + + +def separated(p, sep, mint, maxt=None, end=None): + """Repeat a parser `p` separated by `s` between `mint` and `maxt` times. + When `end` is None, a trailing separator is optional. + When `end` is True, a trailing separator is required. + When `end` is False, a trailing separator will not be parsed. + MATCHES AS MUCH AS POSSIBLE. + Return list of values returned by `p`.""" + maxt = maxt if maxt else mint + + @Parser + def sep_parser(text, index): + cnt, values, res = 0, Value.success(index, []), None + sep_values = values + while cnt < maxt: + res = p(text, index) + if res.status: + values = sep_values.aggregate(Value.success(res.index, [res.value])) + index, cnt = res.index, cnt + 1 + elif cnt < mint: + return res # error: need more elements, but no `p` found. + else: + if end in [True, None]: + # consume previously found trailing separator (if any) + values = sep_values + break + + res = sep(text, index) + if res.status: # `sep` found, consume it (advance index) + index, sep_values = res.index, Value.success(res.index, values.value) + elif cnt < mint: + return res # error: need more elements, but no `sep` found. + elif end is True: + return res # error: trailing separator required + else: + break + + return values + + return sep_parser + + +def sepBy(p, sep): + """`sepBy(p, sep)` parses zero or more occurrences of p, separated by `sep`. + Returns a list of values returned by `p`.""" + return separated(p, sep, 0, maxt=float("inf"), end=False) + + +def sepBy1(p, sep): + """`sepBy1(p, sep)` parses one or more occurrences of `p`, separated by + `sep`. Returns a list of values returned by `p`.""" + return separated(p, sep, 1, maxt=float("inf"), end=False) + + +def endBy(p, sep): + """`endBy(p, sep)` parses zero or more occurrences of `p`, separated and + ended by `sep`. Returns a list of values returned by `p`.""" + return separated(p, sep, 0, maxt=float("inf"), end=True) + + +def endBy1(p, sep): + """`endBy1(p, sep) parses one or more occurrences of `p`, separated and + ended by `sep`. Returns a list of values returned by `p`.""" + return separated(p, sep, 1, maxt=float("inf"), end=True) + + +def sepEndBy(p, sep): + """`sepEndBy(p, sep)` parses zero or more occurrences of `p`, separated and + optionally ended by `sep`. Returns a list of + values returned by `p`.""" + return separated(p, sep, 0, maxt=float("inf")) + + +def sepEndBy1(p, sep): + """`sepEndBy1(p, sep)` parses one or more occurrences of `p`, separated and + optionally ended by `sep`. Returns a list of values returned by `p`.""" + return separated(p, sep, 1, maxt=float("inf")) + + +########################################################################## +# Text.Parsec.Char +########################################################################## + + +def any(): + """Parses a arbitrary character.""" + + @Parser + def any_parser(text, index=0): + if index < len(text): + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "a random char") + + return any_parser + + +def one_of(s): + """Parses a char from specified string.""" + + @Parser + def one_of_parser(text, index=0): + if index < len(text) and text[index] in s: + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "one of {}".format(s)) + + return one_of_parser + + +def none_of(s): + """Parses a char NOT from specified string.""" + + @Parser + def none_of_parser(text, index=0): + if index < len(text) and text[index] not in s: + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "none of {}".format(s)) + + return none_of_parser + + +def space(): + """Parses a whitespace character.""" + + @Parser + def space_parser(text, index=0): + if index < len(text) and text[index].isspace(): + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "one space") + + return space_parser + + +def spaces(): + """Parses zero or more whitespace characters.""" + return many(space()) + + +def letter(): + """Parse a letter in alphabet.""" + + @Parser + def letter_parser(text, index=0): + if index < len(text) and text[index].isalpha(): + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "a letter") + + return letter_parser + + +def digit(): + """Parse a digit.""" + + @Parser + def digit_parser(text, index=0): + if index < len(text) and text[index].isdigit(): + return Value.success(index + 1, text[index]) + else: + return Value.failure(index, "a digit") + + return digit_parser + + +def eof(): + """Parses EOF flag of a string.""" + + @Parser + def eof_parser(text, index=0): + if index >= len(text): + return Value.success(index, None) + else: + return Value.failure(index, "EOF") + + return eof_parser + + +def string(s): + """Parses a string.""" + + @Parser + def string_parser(text, index=0): + slen, tlen = len(s), len(text) + if text[index : index + slen] == s: + return Value.success(index + slen, s) + else: + matched = 0 + while ( + matched < slen + and index + matched < tlen + and text[index + matched] == s[matched] + ): + matched = matched + 1 + return Value.failure(index + matched, s) + + return string_parser + + +def regex(exp, flags=0): + """Parses according to a regular expression.""" + if isinstance(exp, str): + exp = re.compile(exp, flags) + + @Parser + def regex_parser(text, index): + match = exp.match(text, index) + if match: + return Value.success(match.end(), match.group(0)) + else: + return Value.failure(index, exp.pattern) + + return regex_parser + + +########################################################################## +# Useful utility parsers +########################################################################## + + +def fail_with(message): + return Parser(lambda _, index: Value.failure(index, message)) + + +def exclude(p: Parser, exclude: Parser): + """Fails parser p if parser `exclude` matches""" + + @Parser + def exclude_parser(text, index): + res = exclude(text, index) + if res.status: + return Value.failure(index, f"something other than {res.value}") + else: + return p(text, index) + + return exclude_parser + + +def lookahead(p: Parser): + """Parses without consuming""" + + @Parser + def lookahead_parser(text, index): + res = p(text, index) + if res.status: + return Value.success(index, res.value) + else: + return Value.failure(index, res.expected) + + return lookahead_parser + + +def unit(p: Parser): + """Converts a parser into a single unit. Only consumes input if the parser succeeds""" + + @Parser + def unit_parser(text, index): + res = p(text, index) + if res.status: + return Value.success(res.index, res.value) + else: + return Value.failure(index, res.expected) + + return unit_parser diff --git a/rplugin/python3/ultest/handler/parsers/output/parsec.pyi b/rplugin/python3/ultest/handler/parsers/output/parsec.pyi new file mode 100644 index 0000000..171c21d --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/parsec.pyi @@ -0,0 +1,119 @@ +import collections as C +import re +import typing as T + +CA = T + +_U = T.TypeVar("_U") +_V = T.TypeVar("_V") +_W = T.TypeVar("_W") +_VS = T.TypeVar("_VS", bound=CA.Sequence) +_LocInfo = tuple[int, int] + +class ParseError(RuntimeError): + expect: str + text: str + index: int + def __init__(self, expected: str, text: str, index: int) -> None: ... + @staticmethod + def loc_info(text: str, index: int) -> _LocInfo: ... + def loc(self) -> str: ... + def __str__(self) -> str: ... + +class Value(C.namedtuple("Value", "status index value expected"), T.Generic[_U]): + @staticmethod + def success(index: int, actual: _U) -> Value[_U]: ... + @staticmethod + def failure(index: int, expected: str) -> Value[_U]: ... + def aggregate( + self: Value[CA.Sequence[_V]], other: T.Optional[Value[CA.Sequence[_V]]] = ... + ) -> Value[CA.Sequence[_V]]: ... + @staticmethod + def combinate(values: CA.Iterable[Value[_V]]) -> Value[tuple[_V, ...]]: ... + def __str__(self) -> str: ... + +class Parser(T.Generic[_U]): + def __init__(self, fn: CA.Callable[[str, int], Value[_U]]) -> None: ... + def __call__(self, text: str, index: int) -> Value[_U]: ... + def parse(self, text: str) -> _U: ... + def parse_partial(self, text: str) -> tuple[_U, str]: ... + def parse_strict(self, text: str) -> _U: ... + def bind(self, fn: CA.Callable[[_U], Parser[_V]]) -> Parser[_V]: ... + def compose(self, other: Parser[_V]) -> Parser[_V]: ... + def joint(self, *parsers: Parser[_U]) -> Parser[tuple[_U, ...]]: ... + def choice(self, other: Parser[_V]) -> Parser[_U | _V]: ... + def try_choice(self, other: Parser[_V]) -> Parser[_U | _V]: ... + def skip(self, other: Parser) -> Parser[_U]: ... + def ends_with(self, other: Parser) -> Parser[_U]: ... + def parsecmap(self, fn: CA.Callable[[_U], _V]) -> Parser[_V]: ... + def parsecapp( + self: Parser[CA.Callable[[_V], _W]], other: Parser[_V] + ) -> Parser[_W]: ... + def result(self, res: _V) -> Parser[_V]: ... + def mark(self) -> Parser[tuple[_LocInfo, _U, _LocInfo]]: ... + def desc(self, description: str) -> Parser[_U]: ... + def __or__(self, other: Parser[_V]) -> Parser[_U | _V]: ... + def __xor__(self, other: Parser[_V]) -> Parser[_U | _V]: ... + def __add__(self, other: Parser[_V]) -> Parser[tuple[_U, _V]]: ... + def __rshift__(self, other: Parser[_V]) -> Parser[_V]: ... + def __irshift__(self, other: CA.Callable[[_U], Parser[_V]]) -> Parser[_V]: ... + def __lshift__(self, other: Parser) -> Parser[_U]: ... + def __lt__(self, other: Parser) -> Parser[_U]: ... + +def parse(p: Parser[_V], text: str, index: int) -> _V: ... +def bind(p: Parser[_U], fn: CA.Callable[[_U], Parser[_V]]) -> Parser[_V]: ... +def compose(pa: Parser, pb: Parser[_V]) -> Parser[_V]: ... +def joint(*parsers: Parser[_U]) -> Parser[tuple[_U, ...]]: ... +def choice(pa: Parser[_U], pb: Parser[_V]) -> Parser[_U | _V]: ... +def try_choice(pa: Parser[_U], pb: Parser[_V]) -> Parser[_U | _V]: ... +def skip(pa: Parser[_U], pb: Parser) -> Parser[_U]: ... +def ends_with(pa: Parser[_U], pb: Parser) -> Parser[_U]: ... +def parsecmap(p: Parser[_U], fn: CA.Callable[[_U], _V]) -> Parser[_V]: ... +def parsecapp(p: Parser[CA.Callable[[_U], _V]], other: Parser[_U]) -> Parser[_V]: ... +def result(p: Parser, res: _U) -> Parser[_U]: ... +def mark(p: Parser[_U]) -> Parser[tuple[_LocInfo, _U, _LocInfo]]: ... +def desc(p: Parser[_U], description: str) -> Parser[_U]: ... +@T.overload +def generate( + fn: str, +) -> CA.Callable[..., CA.Generator[Parser[T.Any], T.Any, Parser[_V] | _V]]: ... +@T.overload +def generate( + fn: CA.Callable[..., CA.Generator[Parser[T.Any], T.Any, Parser[_V] | _V]] +) -> Parser[_V]: ... +def times( + p: Parser[_U], mint: int, maxt: T.Optional[float] = ... +) -> Parser[list[_U]]: ... +def count(p: Parser[_U], n: int) -> Parser[list[_U]]: ... +def optional( + p: Parser[_U], default_value: T.Optional[_V] = ... +) -> Parser[_U | _V | None]: ... +def many(p: Parser[_U]) -> Parser[list[_U]]: ... +def many1(p: Parser[_U]) -> Parser[list[_U]]: ... +def separated( + p: Parser[_U], + sep: Parser, + mint: int, + maxt: T.Optional[int] = ..., + end: T.Optional[bool] = ..., +) -> Parser[list[_U]]: ... +def sepBy(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def sepBy1(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def endBy(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def endBy1(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def sepEndBy(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def sepEndBy1(p: Parser[_U], sep: Parser) -> Parser[list[_U]]: ... +def any() -> Parser: ... +def one_of(s: CA.Container[_U]) -> Parser[_U]: ... +def none_of(s: CA.Container[_U]) -> Parser[_U]: ... +def space() -> Parser[str]: ... +def spaces() -> Parser[list[str]]: ... +def letter() -> Parser[str]: ... +def digit() -> Parser[str]: ... +def eof() -> Parser[None]: ... +def string(s: _VS) -> Parser[_VS]: ... +def regex(exp: str | re.Pattern, flags: re.RegexFlag = ...) -> Parser[str]: ... +def fail_with(message: str) -> Parser: ... +def exclude(p: Parser[_U], exclude: Parser) -> Parser[_U]: ... +def lookahead(p: Parser[_U]) -> Parser[_U]: ... +def unit(p: Parser[_U]) -> Parser[_U]: ... diff --git a/rplugin/python3/ultest/handler/parsers/output/python/__init__.py b/rplugin/python3/ultest/handler/parsers/output/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py new file mode 100644 index 0000000..08277b3 --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py @@ -0,0 +1,148 @@ +from .. import parsec as p +from ..base import ParsedOutput, ParseResult +from ..parsec import generate + +join_chars = lambda chars: "".join(chars) + + +@generate +def pytest_output(): + yield pytest_test_results_summary + failed = yield p.many(failed_test_section) + yield pytest_summary_info + return ParsedOutput(results=failed) + + +@generate +def failed_test_section(): + namespaces, test_name = yield failed_test_section_title + yield until_eol + yield failed_test_section_code + trace_origin = yield p.optional(until_eol >> failed_test_stacktrace) + error_message = yield failed_test_section_error_message + yield until_eol + error_file, error_line_no = yield failed_test_error_location + yield p.optional(failed_test_captured_stdout, []) + if trace_origin: + test_file = trace_origin[0] + test_line_no = trace_origin[1] + else: + test_file = error_file + test_line_no = error_line_no + return ParseResult( + name=test_name, + namespaces=namespaces, + file=test_file, + message=error_message, + line=test_line_no, + ) + + +@generate +def failed_test_stacktrace(): + file_name, line_no = yield failed_test_error_location + yield p.string("_ _") >> until_eol + yield p.many1(p.exclude(failed_test_code_line, failed_test_section_error_message)) + return file_name, line_no + + +@generate +def failed_test_section_title(): + yield p.many1(p.string("_")) >> p.space() + name_elements = ( + yield p.many1(p.none_of(" ")) + .parsecmap(join_chars) + .parsecmap(lambda elems: elems.split(".")) + ) + namespaces = name_elements[:-1] + test_name = name_elements[-1] + yield p.space() >> p.many1(p.string("_")) + return (namespaces, test_name) + + +@generate +def failed_test_error_message_line(): + yield p.string("E") + yield p.many(p.string(" ")) + error_text = yield until_eol + return error_text + + +@generate +def failed_test_section_code(): + code = yield p.many1( + p.exclude( + failed_test_code_line, + failed_test_section_error_message ^ failed_test_stacktrace, + ) + ) + return code + + +@generate +def failed_test_code_line(): + error_text = yield until_eol + return error_text + + +@generate +def failed_test_section_error_message(): + lines = yield p.many1(failed_test_error_message_line) + return lines + + +@generate +def pytest_summary_info(): + yield p.many1(p.string("=")) + yield p.string(" short test summary info ") + yield until_eol + summary = yield p.many(until_eol) + return summary + + +@generate +def pytest_test_results_summary(): + summary = yield p.many(p.exclude(until_eol, pytest_failed_tests_title)) + yield pytest_failed_tests_title + return summary + + +@generate +def eol(): + new_line = yield p.string("\r\n") ^ p.string("\n") + return new_line + + +@generate +def until_eol(): + text = yield p.many(p.exclude(p.any(), eol)).parsecmap(join_chars) + yield eol + return text + + +@generate +def failed_test_error_location(): + file_name = yield p.many1(p.none_of(" :")).parsecmap(join_chars) + yield p.string(":") + line_no = yield p.many1(p.digit()).parsecmap(join_chars).parsecmap(int) + yield p.string(":") + yield until_eol + return file_name, line_no + + +@generate +def failed_test_captured_stdout(): + yield p.many1(p.string("-")) + yield p.string(" Captured stdout call ") + yield until_eol + stdout = yield p.many( + p.exclude(until_eol, failed_test_section_title ^ pytest_summary_info) + ) + return stdout + + +@generate +def pytest_failed_tests_title(): + yield p.many1(p.string("=")) + yield p.string(" FAILURES ") + yield until_eol diff --git a/rplugin/python3/ultest/handler/runner/__init__.py b/rplugin/python3/ultest/handler/runner/__init__.py index 6f14957..820661e 100644 --- a/rplugin/python3/ultest/handler/runner/__init__.py +++ b/rplugin/python3/ultest/handler/runner/__init__.py @@ -1,6 +1,6 @@ from collections import defaultdict from functools import partial -from typing import Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple +from typing import Callable, Dict, Iterable, Optional, Set, Tuple from ...logging import get_logger from ...models import File, Namespace, Position, Result, Test, Tree @@ -40,7 +40,7 @@ def run( ): runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) - if not self._output_parser.can_parse(runner) or len(tree) == 1: + if not self._output_parser.can_parse(runner): self._run_separately(tree, on_start, on_finish, env) return self._run_group(tree, file_tree, file_name, on_start, on_finish, env) @@ -201,17 +201,32 @@ def _process_results( for position in file_tree if isinstance(position, Namespace) } - output = [] + output = "" if code: with open(output_path, "r") as cmd_out: - output = cmd_out.readlines() + output = cmd_out.read() - parsed_failures = self._output_parser.parse_failed(runner, output) - failed = self._get_failed_set(parsed_failures, tree) + parsed_failures = ( + self._output_parser.parse_failed(runner, output) if code else [] + ) + failed = { + (failed.name, *(failed.namespaces)): failed for failed in parsed_failures + } get_code = partial(self._get_exit_code, tree.data, code, failed, namespaces) for pos in tree: + pos_namespaces = [ + namespaces[namespace_id].name for namespace_id in pos.namespaces + ] + if (pos.name, *pos_namespaces) in failed: + parsed_result = failed[(pos.name, *pos_namespaces)] + error_line = parsed_result.line + error_message = parsed_result.message + else: + error_line = None + error_message = None + self._register_result( pos, Result( @@ -219,6 +234,8 @@ def _process_results( file=pos.file, code=get_code(pos) if code else 0, output=output_path, + error_line=error_line, + error_message=error_message, ), on_finish, ) @@ -227,7 +244,7 @@ def _get_exit_code( self, root: Position, group_code: int, - failed: Set[Tuple[str, ...]], + failed: Dict[Tuple[str, ...], ParseResult], namespaces: Dict[str, Namespace], pos: Position, ): @@ -257,31 +274,6 @@ def _get_exit_code( return 0 - def _get_failed_set( - self, parsed_failures: Iterator[ParseResult], tree: Tree[Position] - ) -> Set[Tuple[str, ...]]: - def from_root(namespaces: List[str]): - for index, namespace in enumerate(namespaces): - if namespace == tree.data.name: - return namespaces[index:] - - logger.warn( - f"No namespaces found from root {tree.data.name} in parsed result {namespaces}" - ) - return [] - - return { - ( - failed.name, - *( - from_root(failed.namespaces) - if not isinstance(tree.data, File) - else failed.namespaces - ), - ) - for failed in parsed_failures - } - def _register_started( self, position: Position, on_start: Callable[[Position], None] ): @@ -298,5 +290,6 @@ def _register_result( ): logger.fdebug("Registering {position.id} as exited with result {result}") self._results[position.file][position.id] = result - self._running.remove(position.id) - on_finish(position, result) + if position.id in self._running: + self._running.remove(position.id) + on_finish(position, result) diff --git a/rplugin/python3/ultest/handler/runner/handle.py b/rplugin/python3/ultest/handler/runner/handle.py index 8d10d93..4d4aaba 100644 --- a/rplugin/python3/ultest/handler/runner/handle.py +++ b/rplugin/python3/ultest/handler/runner/handle.py @@ -89,6 +89,10 @@ def forward(): raise # EIO means EOF on some systems break - out_file.flush() + try: + out_file.flush() + except ValueError: + # File is closed + ... Thread(target=forward).start() diff --git a/rplugin/python3/ultest/models/result.py b/rplugin/python3/ultest/models/result.py index 6a9d4de..3285aed 100644 --- a/rplugin/python3/ultest/models/result.py +++ b/rplugin/python3/ultest/models/result.py @@ -1,5 +1,6 @@ import json from dataclasses import asdict, dataclass +from typing import List, Optional @dataclass @@ -9,10 +10,14 @@ class Result: file: str code: int output: str + error_message: Optional[List[str]] = None + error_line: Optional[int] = None def __str__(self): props = self.dict() return json.dumps(props) def dict(self): - return asdict(self) + return { + name: field for name, field in asdict(self).items() if field is not None + } diff --git a/rplugin/python3/ultest/models/tree.py b/rplugin/python3/ultest/models/tree.py index 3b4f5bc..bc6e210 100644 --- a/rplugin/python3/ultest/models/tree.py +++ b/rplugin/python3/ultest/models/tree.py @@ -29,7 +29,7 @@ def __init__(self, data: TreeData, children: List["Tree[TreeData]"]) -> None: self._length = 1 + sum(len(child) for child in self._children) def __repr__(self) -> str: - return f"Tree(data={self._data}, children={self._children})" + return f"Tree(data={self._data!r}, children={self._children!r})" @classmethod def from_list(cls, data) -> "Tree[TreeData]": diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..609d773 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,5 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py index 96d0a11..b363aec 100644 --- a/tests/mocks/__init__.py +++ b/tests/mocks/__init__.py @@ -2,11 +2,11 @@ from typing import List -def get_output(runner: str) -> List[str]: +def get_output(runner: str) -> str: dirname = os.path.dirname(__file__) filename = os.path.join(dirname, "test_outputs", runner) with open(filename) as output: - return output.readlines() + return output.read() def get_test_file(name: str) -> str: diff --git a/tests/mocks/test_outputs/pytest b/tests/mocks/test_outputs/pytest index 370b417..c83a3a5 100644 --- a/tests/mocks/test_outputs/pytest +++ b/tests/mocks/test_outputs/pytest @@ -1,34 +1,123 @@ ============================= test session starts ============================== -platform linux -- Python 3.9.2, pytest-6.2.3, py-1.10.0, pluggy-0.13.1 +platform linux -- Python 3.8.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /home/ronan/tests -plugins: cov-2.11.1 -collected 3 items +collected 12 items -test_a.py F.E [100%] +test_a.py +>>>>>>>>>>>>>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>> +> /home/ronan/tests/test_a.py(21)test_b() +-> a[0] = 3 +(Pdb) +>>>>>>>>>>>>>>>>>>>>> PDB continue (IO-capturing resumed) >>>>>>>>>>>>>>>>>>>>>> +FFF [ 25%] +test_b.py F.F. [ 58%] +test_x.py F [ 66%] +tests/test_c.py +>>>>>>>>>>>>>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>> +> /home/ronan/tests/tests/test_c.py(12)test_b22() +-> print("hello") +(Pdb) +>>>>>>>>>>>>>>>>>>>>> PDB continue (IO-capturing resumed) >>>>>>>>>>>>>>>>>>>>>> +..F. [100%] -==================================== ERRORS ==================================== -___________________________ ERROR at setup of test_a ___________________________ -file /home/ronan/tests/test_a.py, line 17 - def test_a(self): -E fixture 'self' not found -> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, cov, doctest_namespace, monkeypatch, no_cover, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory -> use 'pytest --fixtures [testpath]' for help on them. - -/home/ronan/tests/test_a.py:17 =================================== FAILURES =================================== -______________________________ TestMyClass.test_d ______________________________ +____________________________________ test_b ____________________________________ + + def test_b(): + """ + tests + + :param: b teststst + """ + a = [[3, 1, 2, 3], 2, 4, 5] + breakpoint() +> a[0] = 3 +E Exception: OH NO + +test_a.py:21: Exception +----------------------------- Captured stdout call ----------------------------- +[3, 2, 4, 5] +[3, 2, 4, 5] +_______________________________ TestClass.test_b _______________________________ + +self = + + def test_b(self): +> self.assertEqual({ + "a": 1, + "b": 2, + "c": 3}, + {"a": 1, + "b": 5, + "c": 3, + "d": 4}) +E AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} +E - {'a': 1, 'b': 2, 'c': 3} +E ? ^ +E  +E + {'a': 1, 'b': 5, 'c': 3, 'd': 4} +E ? ^ ++++++++ + +test_a.py:29: AssertionError +_______________________________ TestClass.test_c _______________________________ + +self = + + def test_c(self): +> a_function() + +test_a.py:39: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def a_function(): + x = 3 + print(x) +> raise Exception +E Exception + +tests/__init__.py:6: Exception +----------------------------- Captured stdout call ----------------------------- +3 +____________________________________ test_a ____________________________________ + + def test_a(): +> assert 2 == 3 +E assert 2 == 3 + +test_b.py:5: AssertionError +____________________________________ test_d ____________________________________ + + def test_d(): +> assert 2 == 3 +E assert 2 == 3 + +test_b.py:16: AssertionError +____________________________________ test_a ____________________________________ + + def test_a(): + x = {} + x[1,2] = 2 + print(x) +> assert False +E assert False -self = +test_x.py:6: AssertionError +----------------------------- Captured stdout call ----------------------------- +{(1, 2): 2} +___________________________________ test_a30 ___________________________________ - def test_d(self): # type: ignore - class MyClass: - ... -> assert 33 == 3 -E AssertionError: assert 33 == 3 + def test_a30(): +> assert 2 == 3 +E assert 2 == 3 -test_a.py:7: AssertionError +tests/test_c.py:23: AssertionError =========================== short test summary info ============================ -FAILED test_a.py::TestMyClass::test_d - AssertionError: assert 33 == 3 +FAILED test_a.py::test_b - Exception: OH NO +FAILED test_a.py::TestClass::test_b - AssertionError: {'a': 1, 'b': 2, 'c': 3... +FAILED test_a.py::TestClass::test_c - Exception +FAILED test_b.py::test_a - assert 2 == 3 +FAILED test_b.py::test_d - assert 2 == 3 +FAILED test_x.py::test_a - assert False +FAILED tests/test_c.py::test_a30 - assert 2 == 3 FAILED test_a.py::test_parametrize[5] - assert 5 == 3 -ERROR test_a.py::test_a -===================== 1 failed, 1 passed, 1 error in 0.07s ===================== +========================= 7 failed, 5 passed in 3.74s ========================== diff --git a/tests/unit/handler/parsers/output/__init__.py b/tests/unit/handler/parsers/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/handler/parsers/output/python/__init__.py b/tests/unit/handler/parsers/output/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/handler/parsers/output/python/test_pytest.py b/tests/unit/handler/parsers/output/python/test_pytest.py new file mode 100644 index 0000000..38a12ba --- /dev/null +++ b/tests/unit/handler/parsers/output/python/test_pytest.py @@ -0,0 +1,210 @@ +from dataclasses import asdict +from unittest import TestCase + +from rplugin.python3.ultest.handler.parsers.output import OutputParser +from rplugin.python3.ultest.handler.parsers.output.python.pytest import ( + ParseResult, + failed_test_section, + failed_test_section_code, + failed_test_section_error_message, + failed_test_section_title, +) +from tests.mocks import get_output + + +class TestPytestParser(TestCase): + def test_parse_file(self): + output = get_output("pytest") + parser = OutputParser([]) + result = parser.parse_failed("python#pytest", output) + self.assertEqual( + result, + [ + ParseResult( + name="test_b", + namespaces=[], + file="test_a.py", + message=["Exception: OH NO"], + output=None, + line=21, + ), + ParseResult( + name="test_b", + namespaces=["TestClass"], + file="test_a.py", + message=[ + "AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "- {'a': 1, 'b': 2, 'c': 3}", + "? ^", + "", + "+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "? ^ ++++++++", + ], + output=None, + line=29, + ), + ParseResult( + name="test_c", + namespaces=["TestClass"], + file="test_a.py", + message=["Exception"], + output=None, + line=39, + ), + ParseResult( + name="test_a", + namespaces=[], + file="test_b.py", + message=["assert 2 == 3"], + output=None, + line=5, + ), + ParseResult( + name="test_d", + namespaces=[], + file="test_b.py", + message=["assert 2 == 3"], + output=None, + line=16, + ), + ParseResult( + name="test_a", + namespaces=[], + file="test_x.py", + message=["assert False"], + output=None, + line=6, + ), + ParseResult( + name="test_a30", + namespaces=[], + file="tests/test_c.py", + message=["assert 2 == 3"], + output=None, + line=23, + ), + ], + ) + + def test_parse_failed_test_section_title(self): + raw = "_____ MyClass.test_a ______" + result = failed_test_section_title.parse(raw) + self.assertEqual(result, (["MyClass"], "test_a")) + + def test_parse_failed_test_section_error(self): + self.maxDiff = None + raw = """E AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} +E - {'a': 1, 'b': 2, 'c': 3} +E ? ^ +E +E + {'a': 1, 'b': 5, 'c': 3, 'd': 4} +E ? ^ ++++++++ +""" + result = failed_test_section_error_message.parse(raw) + expected = [ + "AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "- {'a': 1, 'b': 2, 'c': 3}", + "? ^", + "", + "+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "? ^ ++++++++", + ] + self.assertEqual(expected, result) + + def test_parse_failed_test_section_code(self): + self.maxDiff = None + raw = """self = + + def test_b(self): +> self.assertEqual({ + "a": 1, + "b": 2, + "c": 3}, + {"a": 1, + "b": 5, + "c": 3, + "d": 4}) +E This should not be parsed""" + result, _ = failed_test_section_code.parse_partial(raw) + expected = [ + "self = ", + "", + " def test_b(self):", + "> self.assertEqual({", + ' "a": 1,', + ' "b": 2,', + ' "c": 3},', + ' {"a": 1,', + ' "b": 5,', + ' "c": 3,', + ' "d": 4})', + ] + self.assertEqual(expected, result) + + def test_parse_failed_test_section(self): + raw = """_____________________________________________________________________ MyClass.test_b _____________________________________________________________________ + +self = + + def test_b(self): + \""" + tests + + :param: b teststst + \""" + a = [[3, 1, 2, 3], 2, 4, 5] + breakpoint() +> a[0] = 3 +E Exception: OH NO + +test_a.py:20: Exception +-------------------------------------------------------------- Captured stdout call -------------------------------------------------------------- +[3, 2, 4, 5] +[3, 2, 4, 5] +""" + result = failed_test_section.parse(raw) + self.assertEqual( + result, + ParseResult( + file="test_a.py", + name="test_b", + namespaces=["MyClass"], + message=["Exception: OH NO"], + line=20, + ), + ) + + def test_parse_failed_test_section_with_trace(self): + raw = """_______________________________ TestClass.test_c _______________________________ + +self = + + def test_c(self): +> a_function() + +test_a.py:39: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def a_function(): + x = 3 + print(x) +> raise Exception("OH NO") +E Exception: OH NO + +tests/__init__.py:6: Exception OH NO +----------------------------- Captured stdout call ----------------------------- +3 +""" + result = failed_test_section.parse(raw) + self.assertEqual( + asdict(result), + asdict( + ParseResult( + file="test_a.py", + name="test_c", + namespaces=["TestClass"], + message=["Exception: OH NO"], + line=39, + ) + ), + ) diff --git a/tests/unit/handler/parsers/test_output.py b/tests/unit/handler/parsers/test_output.py index 7fc568c..b4053a3 100644 --- a/tests/unit/handler/parsers/test_output.py +++ b/tests/unit/handler/parsers/test_output.py @@ -8,23 +8,11 @@ class TestOutputParser(TestCase): def setUp(self) -> None: self.parser = OutputParser([]) - def test_parse_pytest(self): - output = get_output("pytest") - failed = list(self.parser.parse_failed("python#pytest", output)) - self.assertEqual( - failed, - [ - ParseResult(name="test_d", namespaces=["TestMyClass"]), - ParseResult(name="test_parametrize", namespaces=[]), - ParseResult(name="test_a", namespaces=[]), - ], - ) - def test_parse_pyunit(self): output = get_output("pyunit") failed = list(self.parser.parse_failed("python#pyunit", output)) self.assertEqual( - failed, [ParseResult(name="test_d", namespaces=["TestMyClass"])] + failed, [ParseResult(file="", name="test_d", namespaces=["TestMyClass"])] ) def test_parse_gotest(self): @@ -33,8 +21,8 @@ def test_parse_gotest(self): self.assertEqual( failed, [ - ParseResult(name="TestA", namespaces=[]), - ParseResult(name="TestB", namespaces=[]), + ParseResult(file="", name="TestA", namespaces=[]), + ParseResult(file="", name="TestB", namespaces=[]), ], ) @@ -45,10 +33,11 @@ def test_parse_jest(self): failed, [ ParseResult( + file="", name="it shouldn't pass", namespaces=["First namespace", "Another namespace"], ), - ParseResult(name="it shouldn't pass again", namespaces=[]), + ParseResult(file="", name="it shouldn't pass again", namespaces=[]), ], ) @@ -58,8 +47,8 @@ def test_parse_exunit(self): self.assertEqual( failed, [ - ParseResult(name="the world", namespaces=[]), - ParseResult(name="greets the world", namespaces=[]), + ParseResult(file="", name="the world", namespaces=[]), + ParseResult(file="", name="greets the world", namespaces=[]), ], ) @@ -69,7 +58,7 @@ def test_parse_richgo(self): self.assertEqual( failed, [ - ParseResult(name="TestA", namespaces=[]), - ParseResult(name="TestAAAB", namespaces=[]), + ParseResult(file="", name="TestA", namespaces=[]), + ParseResult(file="", name="TestAAAB", namespaces=[]), ], ) From 7e0271b7a6526bb992a314ef1e2b11b8c4c4bcba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Mon, 25 Oct 2021 11:51:21 +0100 Subject: [PATCH 2/7] feat: process in batches BREAKING CHANGE: Process functions accept lists In preparation for suite testing, the process functions have changed to accept lists instead of single tests/results. This greatly improves the performance of the summary and diagnostics. --- autoload/ultest/adapter.vim | 2 +- autoload/ultest/process.vim | 157 +++++++++++------- autoload/ultest/signs.vim | 58 ++++--- autoload/ultest/statusline.vim | 17 -- lua/ultest/diagnostic/init.lua | 91 ++++++---- plugin/ultest.vim | 14 +- rplugin/python3/ultest/handler/__init__.py | 20 ++- .../python3/ultest/handler/runner/__init__.py | 115 +++++++------ rplugin/python3/ultest/handler/tracker.py | 20 ++- rplugin/python3/ultest/vim_client/__init__.py | 4 + 10 files changed, 290 insertions(+), 208 deletions(-) delete mode 100644 autoload/ultest/statusline.vim diff --git a/autoload/ultest/adapter.vim b/autoload/ultest/adapter.vim index 30e1fd6..c79fcd9 100644 --- a/autoload/ultest/adapter.vim +++ b/autoload/ultest/adapter.vim @@ -17,7 +17,7 @@ function! ultest#adapter#build_cmd(test, scope) abort execute 'cd' g:test#project_root endif let a:test.file = fnamemodify(a:test.file, get(g:, "test#filename_modifier", ":.")) - call ultest#process#pre(a:test) + call ultest#process#pre([a:test]) let runner = test#determine_runner(a:test.file) let executable = test#base#executable(runner) diff --git a/autoload/ultest/process.vim b/autoload/ultest/process.vim index d53f063..e741502 100644 --- a/autoload/ultest/process.vim +++ b/autoload/ultest/process.vim @@ -19,75 +19,116 @@ function! s:CallProcessor(event, args) abort 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 - call s:CallProcessor("new", [a:test]) +function! s:UpdateBufferTests(tests) abort + let new_tests = {} + for test in a:tests + if index(g:ultest_buffers, test.file) == -1 + let g:ultest_buffers = add(g:ultest_buffers, test.file) + endif + if !has_key(new_tests, test.file) + let new_tests[test.file] = {} + endif + let new_tests[test.file][test.id] = test + endfor + for [file, new_file_tests] in items(new_tests) + let tests = getbufvar(file, "ultest_tests", {}) + call extend(tests, new_file_tests) + endfor endfunction -function ultest#process#start(test) abort - call ultest#process#pre(a:test) - let tests = getbufvar(a:test.file, "ultest_tests", {}) - let tests[a:test.id] = a:test - let results = getbufvar(a:test.file, "ultest_results") - if has_key(results, a:test.id) - call remove(results, a:test.id) - endif - call s:CallProcessor("start", [a:test]) +function! s:UpdateBufferResults(results) abort + let new_results = {} + for result in a:results + if !has_key(new_results, result.file) + let new_results[result.file] = {} + endif + let new_results[result.file][result.id] = result + endfor + for [file, new_file_results] in items(new_results) + let tests = getbufvar(file, "ultest_results", {}) + call extend(tests, new_file_results) + endfor 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 - call s:CallProcessor("move", [a:test]) +function! s:ClearTests(tests) abort + for test in a:tests + let buf_tests = getbufvar(test.file, "ultest_tests") + if has_key(buf_tests, test.id) + call remove(buf_tests, test.id) + endif + endfor endfunction -function ultest#process#replace(test, result) abort - call ultest#process#pre(a:test) - let tests = getbufvar(a:test.file, "ultest_tests") - let tests[a:test.id] = a:test - let results = getbufvar(a:result.file, "ultest_results") - let results[a:result.id] = a:result - call s:CallProcessor("replace", [a:result]) +function! s:ClearTestResults(tests) abort + for test in a:tests + let results = getbufvar(test.file, "ultest_results") + if has_key(results, test.id) + call remove(results, test.id) + endif + endfor endfunction -function ultest#process#clear(test) abort - call ultest#process#pre(a:test) - let tests = getbufvar(a:test.file, "ultest_tests") - if has_key(tests, a:test.id) - call remove(tests, a:test.id) - endif - let results = getbufvar(a:test.file, "ultest_results") - if has_key(results, a:test.id) - call remove(results, a:test.id) - endif - call s:CallProcessor("clear", [a:test]) +function! s:SeparateTestAndResults(combined) abort + let tests = [] + let results = [] + for [test, result] in a:combined + call add(tests, test) + call add(results, result) + endfor + return [tests, results] endfunction -function ultest#process#exit(test, result) abort - call ultest#process#pre(a:test) - if !has_key(getbufvar(a:result.file, "ultest_tests", {}), a:result.id) - return - endif - let tests = getbufvar(a:test.file, "ultest_tests", {}) - let tests[a:test.id] = a:test - let results = getbufvar(a:result.file, "ultest_results") - let results[a:result.id] = a:result - call s:CallProcessor("exit", [a:result]) +function ultest#process#new(tests) abort + call ultest#process#pre(a:tests) + call s:UpdateBufferTests(a:tests) + call s:CallProcessor("new", [a:tests]) +endfunction + +function ultest#process#start(tests) abort + call ultest#process#pre(a:tests) + call s:UpdateBufferTests(a:tests) + call s:ClearTestResults(a:tests) + call s:CallProcessor("start", [a:tests]) +endfunction + +function ultest#process#move(tests) abort + call ultest#process#pre(a:tests) + call s:UpdateBufferTests(a:tests) + call s:CallProcessor("move", [a:tests]) +endfunction + +function ultest#process#replace(combined) abort + let [tests, results] = s:SeparateTestAndResults(a:combined) + call ultest#process#pre(tests) + call s:UpdateBufferTests(tests) + call s:UpdateBufferResults(results) + call s:CallProcessor("replace", [results]) endfunction -function ultest#process#pre(test) abort - if type(a:test.name) == v:t_list - if exists("*list2str") - let newName = list2str(a:test.name) - else - let newName = join(map(a:test.name, {nr, val -> nr2char(val)}), '') +function ultest#process#clear(tests) abort + call ultest#process#pre(a:tests) + call s:ClearTests(a:tests) + call s:ClearTestResults(a:tests) + call s:CallProcessor("clear", [a:tests]) +endfunction + +function ultest#process#exit(combined) abort + let [tests, results] = s:SeparateTestAndResults(a:combined) + call ultest#process#pre(tests) + call s:UpdateBufferTests(tests) + call s:UpdateBufferResults(results) + call s:CallProcessor("exit", [results]) +endfunction + +function ultest#process#pre(tests) abort + for test in a:tests + if type(test.name) == v:t_list + if exists("*list2str") + let newName = list2str(test.name) + else + let newName = join(map(test.name, {nr, val -> nr2char(val)}), '') + endif + let test.name = newName endif - let a:test.name = newName - endif + endfor endfunction diff --git a/autoload/ultest/signs.vim b/autoload/ultest/signs.vim index ea68030..6440eb3 100644 --- a/autoload/ultest/signs.vim +++ b/autoload/ultest/signs.vim @@ -1,36 +1,42 @@ -function! ultest#signs#move(test) abort - if (a:test.type != "test") | return | endif - let result = get(getbufvar(a:test.file, "ultest_results"), a:test.id, {}) - if result != {} - call ultest#signs#process(result) - else - call ultest#signs#start(a:test) - endif +function! ultest#signs#move(tests) abort + for test in a:tests + if (test.type != "test") | continue | endif + let result = get(getbufvar(test.file, "ultest_results"), test.id, {}) + if result != {} + call ultest#signs#process([result]) + else + call ultest#signs#start([test]) + endif + endfor endfunction -function! ultest#signs#start(test) abort - if (a:test.type != "test") | return | endif - call ultest#signs#unplace(a:test) - if !a:test.running | return | endif +function! ultest#signs#start(tests) abort + for test in a:tests + if (test.type != "test") | continue | endif + call ultest#signs#unplace([test]) + if !test.running | continue | endif if s:UseVirtual() - call s:PlaceVirtualText(a:test, g:ultest_running_text, "UltestRunning") + call s:PlaceVirtualText(test, g:ultest_running_text, "UltestRunning") else - call s:PlaceSign(a:test, "test_running") + call s:PlaceSign(test, "test_running") endif + endfor endfunction -function! ultest#signs#process(result) abort - let test = getbufvar(a:result.file, "ultest_tests")[a:result.id] - if (test.type != "test") | return | endif - call ultest#signs#unplace(test) +function! ultest#signs#process(results) abort + for result in a:results + let test = getbufvar(result.file, "ultest_tests")[result.id] + if (test.type != "test") | continue | endif + call ultest#signs#unplace([test]) if s:UseVirtual() - let text_highlight = a:result.code ? "UltestFail" : "UltestPass" - let text = a:result.code ? g:ultest_fail_text : g:ultest_pass_text + let text_highlight = result.code ? "UltestFail" : "UltestPass" + let text = result.code ? g:ultest_fail_text : g:ultest_pass_text call s:PlaceVirtualText(test, text, text_highlight) else - let test_icon = a:result.code ? "test_fail" : "test_pass" + let test_icon = result.code ? "test_fail" : "test_pass" call s:PlaceSign(test, test_icon) endif + endfor endfunction function! s:UseVirtual() abort @@ -48,15 +54,17 @@ function! s:PlaceVirtualText(test, text, highlight) abort call nvim_buf_set_virtual_text(buffer, namespace, str2nr(a:test.line) - 1, [[a:text, a:highlight]], {}) endfunction -function! ultest#signs#unplace(test) - if (a:test.type != "test") | return | endif +function! ultest#signs#unplace(tests) + for test in a:tests + if (test.type != "test") | continue | endif if s:UseVirtual() - let namespace = s:GetNamespace(a:test) + let namespace = s:GetNamespace(test) call nvim_buf_clear_namespace(0, namespace, 0, -1) else - call sign_unplace(a:test.id, {"buffer": a:test.file}) + call sign_unplace(test.id, {"buffer": test.file}) redraw endif + endfor endfunction function! s:GetNamespace(test) diff --git a/autoload/ultest/statusline.vim b/autoload/ultest/statusline.vim deleted file mode 100644 index 6bf9101..0000000 --- a/autoload/ultest/statusline.vim +++ /dev/null @@ -1,17 +0,0 @@ -function! ultest#statusline#process(test) abort - call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 0) + 1) - if a:test["code"] - call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 0) + 1) - else - call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 0) + 1) - endif -endfunction - -function! ultest#statusline#remove(test) abort - call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 1) - 1) - if a:test["code"] - call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 1) - 1) - else - call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 1) - 1) - endif -endfunction diff --git a/lua/ultest/diagnostic/init.lua b/lua/ultest/diagnostic/init.lua index e4fff98..761d9cd 100644 --- a/lua/ultest/diagnostic/init.lua +++ b/lua/ultest/diagnostic/init.lua @@ -38,9 +38,19 @@ 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] + 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) @@ -56,7 +66,7 @@ local function create_diagnostics(bufnr, results) lnum = mark[1], col = 0, message = table.concat(result.error_message, "\n"), - source = "ultest" + source = "ultest", } end end @@ -68,13 +78,9 @@ local function draw_buffer(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 valid_results = vim.tbl_filter(function(result) + return result.error_line and result.error_message + end, results) local diagnostics = create_diagnostics(bufnr, valid_results) @@ -95,38 +101,55 @@ 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 - } - ) + 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) +local function get_files(tests) + local files = {} + for _, test in pairs(tests) do + if not files[test.file] then + files[test.file] = true + end + end + return files end ----@param test UltestTest ----@param result UltestResult -function M.exit(test, result) - if not attached_buffers[test.file] then - attach_to_buf(test.file) +---@param tests UltestTest[] +function M.clear(tests) + local files = get_files(tests) + for file, _ in pairs(files) do + draw_buffer(file) end - clear_mark(test) +end - draw_buffer(test.file) +---@param results UltestResult[] +function M.exit(results) + for _, result in pairs(results) do + clear_mark(result) + end + local files = get_files(results) + for file, _ in pairs(files) do + if not attached_buffers[file] then + attach_to_buf(file) + end + draw_buffer(file) + end end ----@param test UltestTest -function M.delete(test) - clear_mark(test) +---@param tests UltestTest[] +function M.delete(tests) + for _, test in pairs(tests) do + clear_mark(test) + end + local files = get_files(tests) - draw_buffer(test.file) + for file, _ in pairs(files) do + draw_buffer(file) + end end return M diff --git a/plugin/ultest.vim b/plugin/ultest.vim index c8ebe43..15873ba 100644 --- a/plugin/ultest.vim +++ b/plugin/ultest.vim @@ -200,17 +200,19 @@ let g:ultest_attach_width = get(g:, "ultest_attach_width", 0) " This is experimental and could change! " Receivers are dictionaries with any of the following keys: " -" 'new': A function which takes a new position which has been discovered. +" 'lua': If true, then all functions will be called as lua functions " -" 'move': A function which takes a position which has been moved. +" 'new': A function which takes a list of new positions which have been discovered. " -" 'replace': A function which takes a position which has previously been cleared but has been replaced. +" 'move': A function which takes a list of positions which have been moved. " -" 'start': A function which takes a position which has been run. +" 'replace': A function which takes a list of positions which have previously been cleared but has been replaced. " -" 'exit': A function which takes a position result once it has completed. +" 'start': A function which takes a list of positions which have been run. " -" 'clear': A function which takes a position which has been removed for some +" 'exit': A function which takes a list of positions result once it have completed. +" +" 'clear': A function which takes a list of positions which have been removed for some " reason. " " Positions can be either a file, namespace or test, distinguished with a diff --git a/rplugin/python3/ultest/handler/__init__.py b/rplugin/python3/ultest/handler/__init__.py index 7eb792b..b0303a4 100644 --- a/rplugin/python3/ultest/handler/__init__.py +++ b/rplugin/python3/ultest/handler/__init__.py @@ -99,13 +99,15 @@ def external_result(self, pos_id: str, file_name: str, exit_code: int): position, tree, exit_code, self._on_test_finish ) - def _on_test_start(self, position: Position): - self._vim.call("ultest#process#start", position) + def _on_test_start(self, positions: List[Position]): + self._vim.call("ultest#process#start", positions) - def _on_test_finish(self, position: Position, result: Result): - self._vim.call("ultest#process#exit", position, result) - if self._show_on_run and result.code and result.output: - self._vim.schedule(self._present_output, result) + def _on_test_finish(self, results: List[Tuple[Position, Result]]): + self._vim.call("ultest#process#exit", results) + if self._show_on_run: + for _, result in results: + if result.code and result.output: + self._vim.schedule(self._present_output, result) def _present_output(self, result): if result.code and self._vim.sync_call("expand", "%") == result.file: @@ -255,11 +257,13 @@ def clear_results(self, file_name: str): logger.error("Successfully cleared results for unknown file") return + cleared_positions = [] for position in positions: if position.id in cleared: position.running = 0 - self._vim.sync_call("ultest#process#clear", position) - self._vim.sync_call("ultest#process#new", position) + cleared_positions.append(position) + self._vim.sync_call("ultest#process#clear", cleared_positions) + self._vim.sync_call("ultest#process#new", cleared_positions) def _parse_position(self, pos_dict: Dict) -> Optional[Position]: pos_type = pos_dict.get("type") diff --git a/rplugin/python3/ultest/handler/runner/__init__.py b/rplugin/python3/ultest/handler/runner/__init__.py index 820661e..e763650 100644 --- a/rplugin/python3/ultest/handler/runner/__init__.py +++ b/rplugin/python3/ultest/handler/runner/__init__.py @@ -1,6 +1,6 @@ from collections import defaultdict from functools import partial -from typing import Callable, Dict, Iterable, Optional, Set, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple from ...logging import get_logger from ...models import File, Namespace, Position, Result, Test, Tree @@ -34,8 +34,8 @@ def run( tree: Tree[Position], file_tree: Tree[Position], file_name: str, - on_start: Callable[[Position], None], - on_finish: Callable[[Position, Result], None], + on_start: Callable[[List[Position]], None], + on_finish: Callable[[List[Tuple[Position, Result]]], None], env: Optional[Dict] = None, ): @@ -70,21 +70,20 @@ def register_external_start( tree: Tree[Position], file_tree: Tree[Position], output_path: str, - on_start: Callable[[Position], None], + on_start: Callable[[List[Position]], None], ): logger.finfo( "Saving external stdout path '{output_path}' for test {tree.data.id}" ) self._external_outputs[tree.data.id] = output_path - for pos in tree: - self._register_started(pos, on_start) + self._register_started(list(tree), on_start) def register_external_result( self, tree: Tree[Position], file_tree: Tree[Position], code: int, - on_finish: Callable[[Position, Result], None], + on_finish: Callable[[List[Tuple[Position, Result]]], None], ): file_name = tree.data.file runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) @@ -96,12 +95,14 @@ def register_external_result( logger.error(f"No output path registered for position {tree.data.id}") return if not self._output_parser.can_parse(runner): - for pos in tree: - self._register_result( - pos, - result=Result(id=pos.id, file=pos.file, code=code, output=path), - on_finish=on_finish, - ) + results = [ + (pos, Result(id=pos.id, file=pos.file, code=code, output=path)) + for pos in tree + ] + self._register_result( + results, + on_finish=on_finish, + ) return self._process_results( tree=tree, @@ -131,8 +132,8 @@ def _get_cwd(self) -> Optional[str]: def _run_separately( self, tree: Tree[Position], - on_start: Callable[[Position], None], - on_finish: Callable[[Position, Result], None], + on_start: Callable[[List[Position]], None], + on_finish: Callable[[List[Tuple[Position, Result]]], None], env: Optional[Dict] = None, ): """ @@ -140,13 +141,10 @@ def _run_separately( a separate thread. """ root = self._get_cwd() - tests = [] - for pos in tree: - if isinstance(pos, Test): - tests.append(pos) + tests = [pos for pos in tree if isinstance(pos, Test)] + self._register_started(tests, on_start) for test in tests: - self._register_started(test, on_start) cmd = self._vim.sync_call("ultest#adapter#build_cmd", test, "nearest") async def run(cmd=cmd, test=test): @@ -154,8 +152,17 @@ async def run(cmd=cmd, test=test): cmd, test.file, test.id, cwd=root, env=env ) self._register_result( - test, - Result(id=test.id, file=test.file, code=code, output=output_path), + [ + ( + test, + Result( + id=test.id, + file=test.file, + code=code, + output=output_path, + ), + ) + ], on_finish, ) @@ -166,8 +173,8 @@ def _run_group( tree: Tree[Position], file_tree: Tree[Position], file_name: str, - on_start: Callable[[Position], None], - on_finish: Callable[[Position, Result], None], + on_start: Callable[[List[Position]], None], + on_finish: Callable[[List[Tuple[Position, Result]]], None], env: Optional[Dict] = None, ): runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) @@ -175,8 +182,7 @@ def _run_group( cmd = self._vim.sync_call("ultest#adapter#build_cmd", tree[0], scope) root = self._get_cwd() - for pos in tree: - self._register_started(pos, on_start) + self._register_started(list(tree), on_start) async def run(cmd=cmd): (code, output_path) = await self._processes.run( @@ -193,7 +199,7 @@ def _process_results( code: int, output_path: str, runner: str, - on_finish: Callable[[Position, Result], None], + on_finish: Callable[[List[Tuple[Position, Result]]], None], ): namespaces = { @@ -215,6 +221,7 @@ def _process_results( get_code = partial(self._get_exit_code, tree.data, code, failed, namespaces) + results = [] for pos in tree: pos_namespaces = [ namespaces[namespace_id].name for namespace_id in pos.namespaces @@ -227,18 +234,20 @@ def _process_results( error_line = None error_message = None - self._register_result( - pos, - Result( - id=pos.id, - file=pos.file, - code=get_code(pos) if code else 0, - output=output_path, - error_line=error_line, - error_message=error_message, - ), - on_finish, + results.append( + ( + pos, + Result( + id=pos.id, + file=pos.file, + code=get_code(pos) if code else 0, + output=output_path, + error_line=error_line, + error_message=error_message, + ), + ) ) + self._register_result(results, on_finish) def _get_exit_code( self, @@ -275,21 +284,25 @@ def _get_exit_code( return 0 def _register_started( - self, position: Position, on_start: Callable[[Position], None] + self, positions: List[Position], on_start: Callable[[List[Position]], None] ): - logger.fdebug("Registering {position.id} as started") - position.running = 1 - self._running.add(position.id) - on_start(position) + for pos in positions: + logger.fdebug("Registering {pos.id} as started") + pos.running = 1 + self._running.add(pos.id) + on_start(positions) def _register_result( self, - position: Position, - result: Result, - on_finish: Callable[[Position, Result], None], + results: List[Tuple[Position, Result]], + on_finish: Callable[[List[Tuple[Position, Result]]], None], ): - logger.fdebug("Registering {position.id} as exited with result {result}") - self._results[position.file][position.id] = result - if position.id in self._running: - self._running.remove(position.id) - on_finish(position, result) + valid = [] + for pos, result in results: + logger.fdebug("Registering {pos.id} as exited with result {result}") + self._results[pos.file][pos.id] = result + if pos.id in self._running: + self._running.remove(pos.id) + valid.append((pos, result)) + + on_finish(valid) diff --git a/rplugin/python3/ultest/handler/tracker.py b/rplugin/python3/ultest/handler/tracker.py index 941badf..a766bad 100644 --- a/rplugin/python3/ultest/handler/tracker.py +++ b/rplugin/python3/ultest/handler/tracker.py @@ -64,6 +64,9 @@ async def _async_update( "ultest_sorted_tests", [test.id for test in tests], ) + moved = [] + replaced = [] + new = [] for test in tests: if test.id in recorded_tests: recorded = recorded_tests.pop(test.id) @@ -72,17 +75,21 @@ async def _async_update( logger.fdebug( "Moving test {test.id} from {recorded.line} to {test.line} in {file_name}" ) - self._vim.call("ultest#process#move", test) + moved.append(test) else: existing_result = self._runner.get_result(test.id, test.file) if existing_result: logger.fdebug( "Replacing test {test.id} to {test.line} in {file_name}" ) - self._vim.call("ultest#process#replace", test, existing_result) + replaced.append((test, existing_result)) else: logger.fdebug("New test {test.id} found in {file_name}") - self._vim.call("ultest#process#new", test) + new.append(test) + + self._vim.call("ultest#process#new", new) + self._vim.call("ultest#process#replace", replaced) + self._vim.call("ultest#process#move", moved) self._remove_old_positions(recorded_tests) self._vim.command("doau User UltestPositionsUpdate") @@ -120,10 +127,7 @@ async def _parse_positions(self, file: str, vim_patterns: Dict) -> Tree[Position def _remove_old_positions(self, positions: Dict[str, Position]): if positions: - logger.fdebug( - "Removing tests {[recorded for recorded in recorded_tests]} from {file_name}" - ) - for removed in positions.values(): - self._vim.call("ultest#process#clear", removed) + logger.fdebug("Removing tests {list(positions)}") + self._vim.call("ultest#process#clear", list(positions.values())) else: logger.fdebug("No tests removed") diff --git a/rplugin/python3/ultest/vim_client/__init__.py b/rplugin/python3/ultest/vim_client/__init__.py index dccb90b..d90b86d 100644 --- a/rplugin/python3/ultest/vim_client/__init__.py +++ b/rplugin/python3/ultest/vim_client/__init__.py @@ -138,6 +138,10 @@ def _convert_arg(self, arg): return f"'{arg}'" if isinstance(arg, bool): arg = 1 if arg else 0 + if isinstance(arg, list): + return f"[{','.join(self._convert_arg(elem) for elem in arg)}]" + if isinstance(arg, tuple): + return self._convert_arg(list(arg)) return str(arg) def _needs_quotes(self, arg: str) -> bool: From 3fa144faefe2e04fa21149f3b743d91dbddaf1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Mon, 25 Oct 2021 17:10:55 +0100 Subject: [PATCH 3/7] feat(output): structured unittest support --- lua/ultest/diagnostic/init.lua | 2 +- .../ultest/handler/parsers/output/__init__.py | 10 +- .../handler/parsers/output/python/pytest.py | 16 +-- .../handler/parsers/output/python/unittest.py | 90 ++++++++++++++ .../ultest/handler/parsers/output/util.py | 17 +++ scripts/style | 2 +- tests/mocks/test_outputs/pyunit | 45 ++++++- .../parsers/output/python/test_unittest.py | 111 ++++++++++++++++++ tests/unit/handler/parsers/test_output.py | 7 -- 9 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 rplugin/python3/ultest/handler/parsers/output/python/unittest.py create mode 100644 rplugin/python3/ultest/handler/parsers/output/util.py create mode 100644 tests/unit/handler/parsers/output/python/test_unittest.py diff --git a/lua/ultest/diagnostic/init.lua b/lua/ultest/diagnostic/init.lua index 761d9cd..519c811 100644 --- a/lua/ultest/diagnostic/init.lua +++ b/lua/ultest/diagnostic/init.lua @@ -79,7 +79,7 @@ local function draw_buffer(file) 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 + return type(result) == "table" and result.error_line and result.error_message end, results) local diagnostics = create_diagnostics(bufnr, valid_results) diff --git a/rplugin/python3/ultest/handler/parsers/output/__init__.py b/rplugin/python3/ultest/handler/parsers/output/__init__.py index c2ff5bc..f8d65a5 100644 --- a/rplugin/python3/ultest/handler/parsers/output/__init__.py +++ b/rplugin/python3/ultest/handler/parsers/output/__init__.py @@ -6,6 +6,7 @@ from .base import ParseResult from .parsec import ParseError from .python.pytest import pytest_output +from .python.unittest import unittest_output @dataclass @@ -17,10 +18,6 @@ class OutputPatterns: _BASE_PATTERNS = { - "python#pyunit": OutputPatterns( - failed_test=r"^FAIL: (?P.*) \(.*?(?P\..+)\)", - namespace_separator=r"\.", - ), "go#gotest": OutputPatterns(failed_test=r"^.*--- FAIL: (?P.+?) "), "go#richgo": OutputPatterns( failed_test=r"^FAIL\s\|\s(?P.+?) \(.*\)", @@ -43,7 +40,10 @@ class OutputPatterns: class OutputParser: def __init__(self, disable_patterns: List[str]) -> None: - self._parsers = {"python#pytest": pytest_output} + self._parsers = { + "python#pytest": pytest_output, + "python#pyunit": unittest_output, + } self._patterns = { runner: patterns for runner, patterns in _BASE_PATTERNS.items() diff --git a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py index 08277b3..fd541df 100644 --- a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py +++ b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py @@ -1,8 +1,7 @@ from .. import parsec as p from ..base import ParsedOutput, ParseResult from ..parsec import generate - -join_chars = lambda chars: "".join(chars) +from ..util import join_chars, until_eol @generate @@ -107,19 +106,6 @@ def pytest_test_results_summary(): return summary -@generate -def eol(): - new_line = yield p.string("\r\n") ^ p.string("\n") - return new_line - - -@generate -def until_eol(): - text = yield p.many(p.exclude(p.any(), eol)).parsecmap(join_chars) - yield eol - return text - - @generate def failed_test_error_location(): file_name = yield p.many1(p.none_of(" :")).parsecmap(join_chars) diff --git a/rplugin/python3/ultest/handler/parsers/output/python/unittest.py b/rplugin/python3/ultest/handler/parsers/output/python/unittest.py new file mode 100644 index 0000000..d7d0e07 --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/python/unittest.py @@ -0,0 +1,90 @@ +from .. import parsec as p +from ..base import ParsedOutput, ParseResult +from ..parsec import generate +from ..util import eol, join_chars, until_eol + + +class ErroredTestError(Exception): + ... + + +@generate +def unittest_output(): + try: + yield p.many(p.exclude(p.any(), failed_test_title)) + failed_tests = yield p.many1(failed_test) + yield p.many(p.any()) + return ParsedOutput(results=failed_tests) + except ErroredTestError: + return ParsedOutput(results=[]) + + +@generate +def failed_test(): + name, namespace = yield failed_test_title + file, error_line = yield failed_test_traceback + error_message = yield failed_test_error_message + return ParseResult( + name=name, + namespaces=[namespace], + file=file, + message=error_message, + line=error_line, + ) + + +@generate +def failed_test_title(): + text = ( + yield p.many1(p.string("=")) + >> eol + >> (p.string("FAIL") ^ p.string("ERROR")) + << p.string(": ") + ) + test = yield p.many1(p.none_of(" ")).parsecmap(join_chars) + yield p.space() + namespace = ( + yield (p.string("(") >> p.many1(p.none_of(")")) << (p.string(")") >> until_eol)) + .parsecmap(join_chars) + .parsecmap(lambda s: s.split(".")[-1]) + ) + if namespace == "_FailedTest": + # Can't infer namespace from file that couldn't be imported + raise ErroredTestError + yield p.many1(p.string("-")) >> eol + return test, namespace + + +@generate +def traceback_location(): + file = ( + yield p.spaces() + >> p.string('File "') + >> p.many1(p.none_of('"')).parsecmap(join_chars) + << p.string('"') + ) + line = yield ( + p.string(", line ") + >> p.many1(p.digit()).parsecmap(join_chars).parsecmap(int) + << until_eol + ) + return file, line + + +@generate +def failed_test_traceback(): + yield p.string("Traceback") >> until_eol + file, line = yield traceback_location + yield p.many1(p.string(" ") >> until_eol) + return file, line + + +@generate +def failed_test_error_message(): + message = yield p.many1(p.exclude(until_eol, p.string("--") ^ p.string("=="))) + remove_index = len(message) - 0 + for line in reversed(message): + if line != "": + break + remove_index -= 1 + return message[:remove_index] # Ends with blank lines diff --git a/rplugin/python3/ultest/handler/parsers/output/util.py b/rplugin/python3/ultest/handler/parsers/output/util.py new file mode 100644 index 0000000..695f024 --- /dev/null +++ b/rplugin/python3/ultest/handler/parsers/output/util.py @@ -0,0 +1,17 @@ +from . import parsec as p +from .parsec import generate + +join_chars = lambda chars: "".join(chars) + + +@generate +def until_eol(): + text = yield p.many(p.exclude(p.any(), eol)).parsecmap(join_chars) + yield eol + return text + + +@generate +def eol(): + new_line = yield p.string("\r\n") ^ p.string("\n") + return new_line diff --git a/scripts/style b/scripts/style index 57c0f20..6b04b86 100755 --- a/scripts/style +++ b/scripts/style @@ -8,7 +8,7 @@ if [[ $1 == "-w" ]]; then black "${PYTHON_DIRS[@]}" isort "${PYTHON_DIRS[@]}" autoflake --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports --remove-duplicate-keys --recursive -i "${PYTHON_DIRS[@]}" - find -name \*.lua -print0 | xargs -0 luafmt -w replace -i 2 + stylua . else black --check "${PYTHON_DIRS[@]}" isort --check "${PYTHON_DIRS[@]}" diff --git a/tests/mocks/test_outputs/pyunit b/tests/mocks/test_outputs/pyunit index c7cb023..d48cfbd 100644 --- a/tests/mocks/test_outputs/pyunit +++ b/tests/mocks/test_outputs/pyunit @@ -1,13 +1,46 @@ -F. +F3 +EF.F ====================================================================== -FAIL: test_d (test_a.TestMyClass) +ERROR: test_c (test_a.TestClass) ---------------------------------------------------------------------- Traceback (most recent call last): - File "/home/ronan/tests/test_a.py", line 9, in test_d - assert 33 == 3 + File "/home/ronan/tests/test_a.py", line 37, in test_c + a_function() + File "/home/ronan/tests/tests/__init__.py", line 6, in a_function + raise Exception +Exception + +====================================================================== +FAIL: test_b (test_a.TestClass) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/ronan/tests/test_a.py", line 34, in test_b + self.assertEqual({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 5, "c": 3, "d": 4}) +AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} +- {'a': 1, 'b': 2, 'c': 3} +? ^ + ++ {'a': 1, 'b': 5, 'c': 3, 'd': 4} +? ^ ++++++++ + + +====================================================================== +FAIL: test_a (test_b.AnotherClass) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/ronan/tests/test_b.py", line 7, in test_a + assert 2 == 3 +AssertionError + +====================================================================== +FAIL: test_thing (tests.test_c.TestStuff) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/ronan/tests/tests/test_c.py", line 6, in test_thing + assert False AssertionError ---------------------------------------------------------------------- -Ran 2 tests in 0.001s +Ran 5 tests in 0.001s -FAILED (failures=1) +FAILED (failures=3, errors=1) diff --git a/tests/unit/handler/parsers/output/python/test_unittest.py b/tests/unit/handler/parsers/output/python/test_unittest.py new file mode 100644 index 0000000..472f27a --- /dev/null +++ b/tests/unit/handler/parsers/output/python/test_unittest.py @@ -0,0 +1,111 @@ +from unittest import TestCase + +from rplugin.python3.ultest.handler.parsers.output import OutputParser +from rplugin.python3.ultest.handler.parsers.output.python.unittest import ( + ErroredTestError, + ParseResult, + failed_test, +) +from tests.mocks import get_output + + +class TestUnittestParser(TestCase): + def test_parse_failed_test(self): + raw = """====================================================================== +FAIL: test_b (test_a.TestClass) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/ronan/tests/test_a.py", line 34, in test_b + self.assertEqual({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 5, "c": 3, "d": 4}) +AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} +- {'a': 1, 'b': 2, 'c': 3} +? ^ + ++ {'a': 1, 'b': 5, 'c': 3, 'd': 4} +? ^ ++++++++ + +""" + + expected = ParseResult( + name="test_b", + namespaces=["TestClass"], + file="/home/ronan/tests/test_a.py", + line=34, + message=[ + "AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "- {'a': 1, 'b': 2, 'c': 3}", + "? ^", + "", + "+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "? ^ ++++++++", + ], + ) + result = failed_test.parse(raw) + self.assertEqual(result, expected) + + def test_parse_errored_test_raises(self): + raw = """====================================================================== +ERROR: test_c (unittest.loader._FailedTest) +---------------------------------------------------------------------- +ImportError: Failed to import test module: test_c +Traceback (most recent call last): + File "/home/ronan/.pyenv/versions/3.8.6/lib/python3.8/unittest/loader.py", line 436, in _find_test_path + module = self._get_module_from_name(name) + File "/home/ronan/.pyenv/versions/3.8.6/lib/python3.8/unittest/loader.py", line 377, in _get_module_from_name + __import__(name) + File "/home/ronan/tests/test_c.py", line 6, in + class CTests(TestCase): + File "/home/ronan/tests/test_c.py", line 8, in CTests + @not_a_decorator +NameError: name 'not_a_decorator' is not defined + +""" + with self.assertRaises(ErroredTestError): + failed_test.parse(raw) + + def test_parse_unittest(self): + parser = OutputParser([]) + raw = get_output("pyunit") + result = parser.parse_failed("python#pyunit", raw) + expected = [ + ParseResult( + name="test_c", + namespaces=["TestClass"], + file="/home/ronan/tests/test_a.py", + message=["Exception"], + output=None, + line=37, + ), + ParseResult( + name="test_b", + namespaces=["TestClass"], + file="/home/ronan/tests/test_a.py", + message=[ + "AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "- {'a': 1, 'b': 2, 'c': 3}", + "? ^", + "", + "+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}", + "? ^ ++++++++", + ], + output=None, + line=34, + ), + ParseResult( + name="test_a", + namespaces=["AnotherClass"], + file="/home/ronan/tests/test_b.py", + message=["AssertionError"], + output=None, + line=7, + ), + ParseResult( + name="test_thing", + namespaces=["TestStuff"], + file="/home/ronan/tests/tests/test_c.py", + message=["AssertionError"], + output=None, + line=6, + ), + ] + self.assertEqual(expected, result) diff --git a/tests/unit/handler/parsers/test_output.py b/tests/unit/handler/parsers/test_output.py index b4053a3..f887cbd 100644 --- a/tests/unit/handler/parsers/test_output.py +++ b/tests/unit/handler/parsers/test_output.py @@ -8,13 +8,6 @@ class TestOutputParser(TestCase): def setUp(self) -> None: self.parser = OutputParser([]) - def test_parse_pyunit(self): - output = get_output("pyunit") - failed = list(self.parser.parse_failed("python#pyunit", output)) - self.assertEqual( - failed, [ParseResult(file="", name="test_d", namespaces=["TestMyClass"])] - ) - def test_parse_gotest(self): output = get_output("gotest") failed = list(self.parser.parse_failed("go#gotest", output)) From e6d240728a76c7c33292c1ac008a1a089efb1d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Mon, 25 Oct 2021 18:01:21 +0100 Subject: [PATCH 4/7] fix(summary): avoid rendering file if no tests --- autoload/ultest/summary.vim | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/autoload/ultest/summary.vim b/autoload/ultest/summary.vim index 233c8dc..bd52fd6 100644 --- a/autoload/ultest/summary.vim +++ b/autoload/ultest/summary.vim @@ -125,10 +125,12 @@ function! s:RenderSummary() abort let structure = getbufvar(test_file, "ultest_file_structure") let tests = getbufvar(test_file, "ultest_tests", {}) let results = getbufvar(test_file, "ultest_results", {}) - let state = {"lines": lines, "matches": matches, "tests": tests, "results": results } - call s:RenderGroup("", structure, 0, state) - if test_file != g:ultest_buffers[-1] - call add(lines, "") + if tests != {} + let state = {"lines": lines, "matches": matches, "tests": tests, "results": results } + call s:RenderGroup("", structure, 0, state) + if test_file != g:ultest_buffers[-1] + call add(lines, "") + endif endif endfor if has("nvim") From cf195846e27cfb56ab316c5065d35cecb197ce9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Thu, 28 Oct 2021 07:15:04 +0100 Subject: [PATCH 5/7] feat(pytest): parse output sections Allows ultest to determine the suitable stack trace to use such as for hypothesis, the second trace should be used. --- .../handler/parsers/output/python/pytest.py | 133 ++++++++++++------ tests/mocks/test_outputs/pytest_hypothesis | 46 ++++++ .../parsers/output/python/test_pytest.py | 118 ++++++++++------ tests/unit/models/test_tree.py | 1 + 4 files changed, 214 insertions(+), 84 deletions(-) create mode 100644 tests/mocks/test_outputs/pytest_hypothesis diff --git a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py index fd541df..5dc5f0e 100644 --- a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py +++ b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py @@ -1,7 +1,18 @@ +from dataclasses import dataclass +from typing import List, Optional + from .. import parsec as p from ..base import ParsedOutput, ParseResult from ..parsec import generate -from ..util import join_chars, until_eol +from ..util import eol, join_chars, until_eol + + +@dataclass +class PytestCodeTrace: + code: List[str] + file: str + line: int + message: Optional[List[str]] = None @generate @@ -16,18 +27,19 @@ def pytest_output(): def failed_test_section(): namespaces, test_name = yield failed_test_section_title yield until_eol - yield failed_test_section_code - trace_origin = yield p.optional(until_eol >> failed_test_stacktrace) - error_message = yield failed_test_section_error_message - yield until_eol - error_file, error_line_no = yield failed_test_error_location - yield p.optional(failed_test_captured_stdout, []) - if trace_origin: - test_file = trace_origin[0] - test_line_no = trace_origin[1] - else: - test_file = error_file - test_line_no = error_line_no + traces: List[PytestCodeTrace] + traces = yield failed_test_code_sections + sections = yield failed_test_captured_output_sections + trace = traces[0] + # Hypothesis traces provide the test definition as the first layer of trace + if "Hypothesis" in sections and len(traces) > 1: + trace = traces[1] + test_file = trace.file + test_line_no = trace.line + error_message = None + for trace in traces: + if trace.message: + error_message = trace.message return ParseResult( name=test_name, namespaces=namespaces, @@ -37,14 +49,6 @@ def failed_test_section(): ) -@generate -def failed_test_stacktrace(): - file_name, line_no = yield failed_test_error_location - yield p.string("_ _") >> until_eol - yield p.many1(p.exclude(failed_test_code_line, failed_test_section_error_message)) - return file_name, line_no - - @generate def failed_test_section_title(): yield p.many1(p.string("_")) >> p.space() @@ -60,22 +64,45 @@ def failed_test_section_title(): @generate -def failed_test_error_message_line(): - yield p.string("E") - yield p.many(p.string(" ")) - error_text = yield until_eol - return error_text +def failed_test_captured_output_sections(): + sections = yield p.many(failed_test_captured_output) + return dict(sections) + + +failed_test_section_sep = p.many1(p.one_of("_ ")) >> eol @generate -def failed_test_section_code(): - code = yield p.many1( +def failed_test_code_sections(): + + sections = yield p.sepBy(failed_test_code_section, failed_test_section_sep) + return sections + + +@generate +def failed_test_code_section(): + code = yield p.many( p.exclude( failed_test_code_line, - failed_test_section_error_message ^ failed_test_stacktrace, + failed_test_section_error_message ^ failed_test_error_location, + ) + ) + message = yield p.optional(failed_test_section_error_message) + if code or message: + yield until_eol + trace = yield failed_test_error_location + yield p.many( + p.exclude( + until_eol, + failed_test_section_sep + ^ failed_test_captured_output_title + ^ failed_test_section_title + ^ pytest_summary_info_title, ) ) - return code + return PytestCodeTrace( + code=code, file=trace and trace[0], line=trace and trace[1], message=message + ) @generate @@ -90,19 +117,33 @@ def failed_test_section_error_message(): return lines +@generate +def failed_test_error_message_line(): + yield p.string("E") + yield p.many(p.string(" ")) + error_text = yield until_eol + return error_text + + @generate def pytest_summary_info(): + yield pytest_summary_info_title + summary = yield p.many(until_eol) + return summary + + +@generate +def pytest_summary_info_title(): yield p.many1(p.string("=")) yield p.string(" short test summary info ") yield until_eol - summary = yield p.many(until_eol) - return summary @generate def pytest_test_results_summary(): - summary = yield p.many(p.exclude(until_eol, pytest_failed_tests_title)) - yield pytest_failed_tests_title + failures_title = p.many1(p.string("=")) >> p.string(" FAILURES ") >> until_eol + summary = yield p.many(p.exclude(until_eol, failures_title)) + yield failures_title return summary @@ -117,18 +158,24 @@ def failed_test_error_location(): @generate -def failed_test_captured_stdout(): - yield p.many1(p.string("-")) - yield p.string(" Captured stdout call ") - yield until_eol +def failed_test_captured_output(): + title = yield failed_test_captured_output_title.parsecmap(join_chars) stdout = yield p.many( - p.exclude(until_eol, failed_test_section_title ^ pytest_summary_info) + p.exclude( + until_eol, + failed_test_section_title + ^ pytest_summary_info_title + ^ failed_test_captured_output_title, + ) ) - return stdout + return title, stdout @generate -def pytest_failed_tests_title(): - yield p.many1(p.string("=")) - yield p.string(" FAILURES ") +def failed_test_captured_output_title(): + yield p.many1(p.string("-")) + yield p.string(" ") + title = yield p.many1(p.exclude(p.any(), p.string(" ---"))) + yield p.string(" ") yield until_eol + return title diff --git a/tests/mocks/test_outputs/pytest_hypothesis b/tests/mocks/test_outputs/pytest_hypothesis new file mode 100644 index 0000000..fbf6f8a --- /dev/null +++ b/tests/mocks/test_outputs/pytest_hypothesis @@ -0,0 +1,46 @@ +================================================= test session starts ================================================== +platform linux -- Python 3.8.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 +rootdir: /home/ronan/Dev/repos/vim-ultest, configfile: pyproject.toml +plugins: asyncio-0.16.0, hypothesis-6.23.3, cov-3.0.0 +collecting ...  collected 4 items  + +tests/unit/models/test_tree.py F... [100%] + +======================================================= FAILURES ======================================================= +__________________________________________ test_get_nearest_from_strict_match __________________________________________ + + @given(sorted_tests()) +> def test_get_nearest_from_strict_match(tests: List[Union[Test, Namespace]]): + +tests/unit/models/test_tree.py:29: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +tests = [Test(id='', name='', file='', line=2, col=0, running=0, namespaces=[], type='test'), Test(id='', name='', file='', li...namespaces=[], type='test'), Test(id='', name='', file='', line=12, col=0, running=0, namespaces=[], type='test'), ...] + + @given(sorted_tests()) + def test_get_nearest_from_strict_match(tests: List[Union[Test, Namespace]]): + test_i = int(random.random() * len(tests)) + expected = tests[test_i] + tree = Tree[Position].from_list([File(file="", name="", id=""), *tests]) + result = tree.sorted_search(expected.line, lambda test: test.line, strict=True) +> assert expected != result.data +E AssertionError: assert Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test') != Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test') +E + where Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test') = Tree(data=Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test'), children=[]).data + +tests/unit/models/test_tree.py:34: AssertionError +------------------------------------------------------ Hypothesis ------------------------------------------------------ +Falsifying example: test_get_nearest_from_strict_match( + tests=[Test(id='', name='', file='', line=2, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=4, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=6, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=8, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=10, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=12, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=14, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=16, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=20, col=0, running=0, namespaces=[], type='test')], +) +=============================================== short test summary info ================================================ +FAILED tests/unit/models/test_tree.py::test_get_nearest_from_strict_match - AssertionError: assert Test(id='', name='... +============================================= 1 failed, 3 passed in 20.07s ============================================= diff --git a/tests/unit/handler/parsers/output/python/test_pytest.py b/tests/unit/handler/parsers/output/python/test_pytest.py index 38a12ba..9ebd6ca 100644 --- a/tests/unit/handler/parsers/output/python/test_pytest.py +++ b/tests/unit/handler/parsers/output/python/test_pytest.py @@ -1,11 +1,9 @@ -from dataclasses import asdict from unittest import TestCase from rplugin.python3.ultest.handler.parsers.output import OutputParser from rplugin.python3.ultest.handler.parsers.output.python.pytest import ( ParseResult, failed_test_section, - failed_test_section_code, failed_test_section_error_message, failed_test_section_title, ) @@ -86,6 +84,27 @@ def test_parse_file(self): ], ) + def test_parse_hypothesis_file(self): + output = get_output("pytest_hypothesis") + parser = OutputParser([]) + result = parser.parse_failed("python#pytest", output) + self.assertEqual( + result, + [ + ParseResult( + name="test_get_nearest_from_strict_match", + namespaces=[], + file="tests/unit/models/test_tree.py", + message=[ + "AssertionError: assert Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test') != Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test')", + "+ where Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test') = Tree(data=Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test'), children=[]).data", + ], + output=None, + line=34, + ) + ], + ) + def test_parse_failed_test_section_title(self): raw = "_____ MyClass.test_a ______" result = failed_test_section_title.parse(raw) @@ -111,36 +130,6 @@ def test_parse_failed_test_section_error(self): ] self.assertEqual(expected, result) - def test_parse_failed_test_section_code(self): - self.maxDiff = None - raw = """self = - - def test_b(self): -> self.assertEqual({ - "a": 1, - "b": 2, - "c": 3}, - {"a": 1, - "b": 5, - "c": 3, - "d": 4}) -E This should not be parsed""" - result, _ = failed_test_section_code.parse_partial(raw) - expected = [ - "self = ", - "", - " def test_b(self):", - "> self.assertEqual({", - ' "a": 1,', - ' "b": 2,', - ' "c": 3},', - ' {"a": 1,', - ' "b": 5,', - ' "c": 3,', - ' "d": 4})', - ] - self.assertEqual(expected, result) - def test_parse_failed_test_section(self): raw = """_____________________________________________________________________ MyClass.test_b _____________________________________________________________________ @@ -197,14 +186,61 @@ def a_function(): """ result = failed_test_section.parse(raw) self.assertEqual( - asdict(result), - asdict( - ParseResult( - file="test_a.py", - name="test_c", - namespaces=["TestClass"], - message=["Exception: OH NO"], - line=39, - ) + result, + ParseResult( + file="test_a.py", + name="test_c", + namespaces=["TestClass"], + message=["Exception: OH NO"], + line=39, + ), + ) + + def test_parse_failed_test_with_code_below_trace_location(self): + raw = """__________________________________________ test_get_nearest_from_strict_match __________________________________________ + + @given(sorted_tests()) +> def test_get_nearest_from_strict_match(tests: List[Union[Test, Namespace]]): + +tests/unit/models/test_tree.py:30: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +tests/unit/models/test_tree.py:35: in test_get_nearest_from_strict_match + logging.warn("AAAAAAH") +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +msg = 'AAAAAAH', args = (), kwargs = {} + + def warn(msg, *args, **kwargs): +> warnings.warn("The 'warn' function is deprecated, " + "use 'warning' instead", DeprecationWarning, 2) +E DeprecationWarning: The 'warn' function is deprecated, use 'warning' instead + +../../../.pyenv/versions/3.8.6/lib/python3.8/logging/__init__.py:2058: DeprecationWarning +------------------------------------------------------ Hypothesis ------------------------------------------------------ +Falsifying example: test_get_nearest_from_strict_match( + tests=[Test(id='', name='', file='', line=2, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='0', file='', line=4, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=6, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=8, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=10, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=12, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=14, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=16, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=18, col=0, running=0, namespaces=[], type='test'), + Test(id='', name='', file='', line=514, col=0, running=0, namespaces=[], type='test')], +)""" + + result = failed_test_section.parse(raw) + self.assertEqual( + result, + ParseResult( + name="test_get_nearest_from_strict_match", + namespaces=[], + file="tests/unit/models/test_tree.py", + message=[ + "DeprecationWarning: The 'warn' function is deprecated, use 'warning' instead" + ], + output=None, + line=35, ), ) diff --git a/tests/unit/models/test_tree.py b/tests/unit/models/test_tree.py index db16912..eb756ef 100644 --- a/tests/unit/models/test_tree.py +++ b/tests/unit/models/test_tree.py @@ -1,3 +1,4 @@ +import logging import random from typing import List, Union From 375f9b8accc8821eb3f9d014b77ca852c97a8753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Sun, 28 Nov 2021 17:13:16 +0000 Subject: [PATCH 6/7] feat(pytest): fallback to summary info --- requirements.txt | 1 - .../handler/parsers/output/python/pytest.py | 72 +++++++++++--- tests/mocks/test_outputs/pytest_hypothesis_2 | 65 +++++++++++++ .../parsers/output/python/test_pytest.py | 96 ++++++++++++++++++- tests/unit/models/test_tree.py | 1 - 5 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 tests/mocks/test_outputs/pytest_hypothesis_2 diff --git a/requirements.txt b/requirements.txt index efada01..8baa23d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -mypy pytest pytest-cov pytest-asyncio diff --git a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py index 5dc5f0e..07667c3 100644 --- a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py +++ b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from logging import getLogger from typing import List, Optional from .. import parsec as p @@ -6,6 +7,8 @@ from ..parsec import generate from ..util import eol, join_chars, until_eol +logger = getLogger(__name__) + @dataclass class PytestCodeTrace: @@ -18,15 +21,41 @@ class PytestCodeTrace: @generate def pytest_output(): yield pytest_test_results_summary - failed = yield p.many(failed_test_section) - yield pytest_summary_info - return ParsedOutput(results=failed) + parsed_outputs = yield p.many1(failed_test_section) + parsed_summary = yield pytest_summary_info + parsed_results = { + (r.file, r.name, *r.namespaces): r + for r in [*parsed_summary, *parsed_outputs] + if r + } + return ParsedOutput(results=list(parsed_results.values())) @generate def failed_test_section(): namespaces, test_name = yield failed_test_section_title yield until_eol + raw_output_lines = yield p.many1( + p.exclude(until_eol, failed_test_section_title ^ pytest_summary_info_title) + ) + output_text = "\n".join(raw_output_lines) + "\n" + try: + + file, err_msg, err_line = failed_test_section_output.parse(output_text) + return ParseResult( + name=test_name, + namespaces=namespaces, + file=file, + message=err_msg, + line=err_line, + ) + except Exception as e: + logger.debug(f"Failed to parse output: {e}\n----\n{output_text}\n----") + return None + + +@generate +def failed_test_section_output(): traces: List[PytestCodeTrace] traces = yield failed_test_code_sections sections = yield failed_test_captured_output_sections @@ -40,18 +69,16 @@ def failed_test_section(): for trace in traces: if trace.message: error_message = trace.message - return ParseResult( - name=test_name, - namespaces=namespaces, - file=test_file, - message=error_message, - line=test_line_no, + return ( + test_file, + error_message, + test_line_no, ) @generate def failed_test_section_title(): - yield p.many1(p.string("_")) >> p.space() + yield p.string("_") >> p.many1(p.string("_")) >> p.space() name_elements = ( yield p.many1(p.none_of(" ")) .parsecmap(join_chars) @@ -59,7 +86,7 @@ def failed_test_section_title(): ) namespaces = name_elements[:-1] test_name = name_elements[-1] - yield p.space() >> p.many1(p.string("_")) + yield until_eol return (namespaces, test_name) @@ -74,8 +101,7 @@ def failed_test_captured_output_sections(): @generate def failed_test_code_sections(): - - sections = yield p.sepBy(failed_test_code_section, failed_test_section_sep) + sections = yield p.sepBy1(failed_test_code_section, failed_test_section_sep) return sections @@ -125,11 +151,27 @@ def failed_test_error_message_line(): return error_text +@generate +def pytest_summary_failed_test(): + yield p.string("FAILED ") + names = yield p.sepBy1( + p.many1(p.none_of(": ")).parsecmap(join_chars), p.string("::") + ) + file, *namespaces = names + yield until_eol + return ParseResult( + name=namespaces[-1], + namespaces=namespaces[:-1], + file=file, + ) + + @generate def pytest_summary_info(): yield pytest_summary_info_title - summary = yield p.many(until_eol) - return summary + parsed_summary = yield p.many1(pytest_summary_failed_test) + yield p.many(until_eol) + return parsed_summary @generate diff --git a/tests/mocks/test_outputs/pytest_hypothesis_2 b/tests/mocks/test_outputs/pytest_hypothesis_2 new file mode 100644 index 0000000..50447f6 --- /dev/null +++ b/tests/mocks/test_outputs/pytest_hypothesis_2 @@ -0,0 +1,65 @@ +================================================= test session starts ================================================== +platform linux -- Python 3.8.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 +rootdir: /home/ronan/Dev/repos/hypothesis, configfile: pytest.ini +plugins: xdist-2.4.0, forked-1.3.0, hypothesis-6.24.0 +collecting ...  collected 1 item  + +tests/numpy/test_from_dtype.py F [100%] + +======================================================= FAILURES ======================================================= +______________________________________________ test_can_cast_for_scalars _______________________________________________ +Traceback (most recent call last): + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/tests/numpy/test_from_dtype.py", line 80, in test_can_cast_for_scalars + def test_can_cast_for_scalars(data): + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 1199, in wrapped_test + raise the_error_hypothesis_found + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 1168, in wrapped_test + state.run_engine() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 780, in run_engine + runner.run() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 475, in run + self._run() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 877, in _run + self.generate_new_examples() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 609, in generate_new_examples + zero_data = self.cached_test_function(bytes(BUFFER_SIZE)) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 1056, in cached_test_function + self.test_function(data) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 213, in test_function + self.__stoppable_test_function(data) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/engine.py", line 189, in __stoppable_test_function + self._test_function(data) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 727, in _execute_once_for_engine + escalate_hypothesis_internal_error() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 701, in _execute_once_for_engine + result = self.execute_once(data) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 639, in execute_once + result = self.test_runner(data, run) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/executors.py", line 52, in default_new_style_executor + return function(data) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 635, in run + return test(*args, **kwargs) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/tests/numpy/test_from_dtype.py", line 80, in test_can_cast_for_scalars + def test_can_cast_for_scalars(data): + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 577, in test + result = self.test(*args, **kwargs) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/tests/numpy/test_from_dtype.py", line 87, in test_can_cast_for_scalars + result = data.draw( + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/strategies/_internal/core.py", line 1692, in draw + result = self.conjecture_data.draw(strategy) + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/internal/conjecture/data.py", line 866, in draw + strategy.validate() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py", line 401, in validate + self.do_validate() + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/strategies/_internal/lazy.py", line 134, in do_validate + assert isinstance(w, SearchStrategy), f"{self!r} returned non-strategy {w!r}" + AssertionError: arrays(dtype=dtype('int16'), shape=(), elements=from_dtype(dtype('bool'))) returned non-strategy [] + ------------------------------------------------------ Hypothesis ------------------------------------------------------ + You can add @seed(11024453522097809882419571698055639517) to this test or run pytest with --hypothesis-seed=11024453522097809882419571698055639517 to reproduce this failure. + ================================================= slowest 20 durations ================================================= + 0.01s setup hypothesis-python/tests/numpy/test_from_dtype.py::test_can_cast_for_scalars + + (2 durations < 0.005s hidden. Use -vv to show these durations.) + =============================================== short test summary info ================================================ + FAILED tests/numpy/test_from_dtype.py::test_can_cast_for_scalars - AssertionError: arrays(dtype=dtype('int16'), shape... + ================================================== 1 failed in 0.06s =================================================== diff --git a/tests/unit/handler/parsers/output/python/test_pytest.py b/tests/unit/handler/parsers/output/python/test_pytest.py index 9ebd6ca..3dc72af 100644 --- a/tests/unit/handler/parsers/output/python/test_pytest.py +++ b/tests/unit/handler/parsers/output/python/test_pytest.py @@ -6,6 +6,7 @@ failed_test_section, failed_test_section_error_message, failed_test_section_title, + pytest_summary_info, ) from tests.mocks import get_output @@ -105,13 +106,41 @@ def test_parse_hypothesis_file(self): ], ) + def test_parse_summary_if_output_not_parsable(self): + raw = """ +================================================= test session starts ================================================== +... + +======================================================= FAILURES ======================================================= +______________________________________________ test_can_cast_for_scalars _______________________________________________ +This is not parsable +------------------------------------------------------ Hypothesis ------------------------------------------------------ +You can add @seed(11024453522097809882419571698055639517) to this test or run pytest with --hypothesis-seed=11024453522097809882419571698055639517 to reproduce this failure. +================================================= slowest 20 durations ================================================= +0.01s setup hypothesis-python/tests/numpy/test_from_dtype.py::test_can_cast_for_scalars + +(2 durations < 0.005s hidden. Use -vv to show these durations.) +=============================================== short test summary info ================================================ +FAILED tests/numpy/test_from_dtype.py::test_can_cast_for_scalars - AssertionError: arrays(dtype=dtype('int16'), shape... +================================================== 1 failed in 0.06s =================================================== +""" + expected = [ + ParseResult( + name="test_can_cast_for_scalars", + namespaces=[], + file="tests/numpy/test_from_dtype.py", + ) + ] + parser = OutputParser([]) + result = parser.parse_failed("python#pytest", raw) + self.assertEqual(result, expected) + def test_parse_failed_test_section_title(self): - raw = "_____ MyClass.test_a ______" + raw = "_____ MyClass.test_a ______\n" result = failed_test_section_title.parse(raw) self.assertEqual(result, (["MyClass"], "test_a")) def test_parse_failed_test_section_error(self): - self.maxDiff = None raw = """E AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} E - {'a': 1, 'b': 2, 'c': 3} E ? ^ @@ -130,6 +159,69 @@ def test_parse_failed_test_section_error(self): ] self.assertEqual(expected, result) + def test_parse_failed_test_short_summary(self): + raw = """======================================================================================================================== short test summary info ========================================================================================================================= +FAILED test_a.py::test_b - Exception: OH NO +FAILED test_a.py::TestClass::test_b - AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4} +FAILED test_b.py::AnotherClass::test_a - AssertionError: assert 2 == 3 +FAILED test_b.py::test_d - assert 2 == 3 +FAILED subtests/test_c.py::TestStuff::test_thing_2 - AssertionError: assert False +FAILED subtests/test_c.py::test_a - assert False +====================================================================================================================== 6 failed, 5 passed in 0.39s ======================================================================================================================= +""" + result = pytest_summary_info.parse(raw) + expected = [ + ParseResult( + name="test_b", + namespaces=[], + file="test_a.py", + message=None, + output=None, + line=None, + ), + ParseResult( + name="test_b", + namespaces=["TestClass"], + file="test_a.py", + message=None, + output=None, + line=None, + ), + ParseResult( + name="test_a", + namespaces=["AnotherClass"], + file="test_b.py", + message=None, + output=None, + line=None, + ), + ParseResult( + name="test_d", + namespaces=[], + file="test_b.py", + message=None, + output=None, + line=None, + ), + ParseResult( + name="test_thing_2", + namespaces=["TestStuff"], + file="subtests/test_c.py", + message=None, + output=None, + line=None, + ), + ParseResult( + name="test_a", + namespaces=[], + file="subtests/test_c.py", + message=None, + output=None, + line=None, + ), + ] + self.assertEqual(expected, result) + def test_parse_failed_test_section(self): raw = """_____________________________________________________________________ MyClass.test_b _____________________________________________________________________ diff --git a/tests/unit/models/test_tree.py b/tests/unit/models/test_tree.py index eb756ef..db16912 100644 --- a/tests/unit/models/test_tree.py +++ b/tests/unit/models/test_tree.py @@ -1,4 +1,3 @@ -import logging import random from typing import List, Union From f96880b21446d14205fb9d6ce0e1f6079bf7d2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3n=C3=A1n=20Carrigan?= Date: Sat, 4 Dec 2021 16:03:18 +0000 Subject: [PATCH 7/7] feat(output): provide cwd to parsers --- .../ultest/handler/parsers/output/__init__.py | 14 +++-- .../handler/parsers/output/python/pytest.py | 55 +++++++++++++++--- .../handler/parsers/output/python/unittest.py | 4 ++ .../python3/ultest/handler/runner/__init__.py | 7 ++- .../parsers/output/python/test_pytest.py | 57 +++++++++++++++++++ 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/rplugin/python3/ultest/handler/parsers/output/__init__.py b/rplugin/python3/ultest/handler/parsers/output/__init__.py index f8d65a5..d97bc1e 100644 --- a/rplugin/python3/ultest/handler/parsers/output/__init__.py +++ b/rplugin/python3/ultest/handler/parsers/output/__init__.py @@ -5,8 +5,8 @@ from ....logging import get_logger from .base import ParseResult from .parsec import ParseError -from .python.pytest import pytest_output -from .python.unittest import unittest_output +from .python.pytest import parse_pytest +from .python.unittest import parse_unittest @dataclass @@ -41,8 +41,8 @@ class OutputPatterns: class OutputParser: def __init__(self, disable_patterns: List[str]) -> None: self._parsers = { - "python#pytest": pytest_output, - "python#pyunit": unittest_output, + "python#pytest": parse_pytest, + "python#pyunit": parse_unittest, } self._patterns = { runner: patterns @@ -53,10 +53,12 @@ def __init__(self, disable_patterns: List[str]) -> None: def can_parse(self, runner: str) -> bool: return runner in self._patterns or runner in self._parsers - def parse_failed(self, runner: str, output: str) -> Iterable[ParseResult]: + def parse_failed(self, runner: str, output: str, cwd=None) -> Iterable[ParseResult]: if runner in self._parsers: try: - return self._parsers[runner].parse(_ANSI_ESCAPE.sub("", output)).results + return self._parsers[runner]( + _ANSI_ESCAPE.sub("", output), cwd=cwd + ).results except ParseError: return [] return self._regex_parse_failed(runner, output.splitlines()) diff --git a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py index 07667c3..0bf0a5e 100644 --- a/rplugin/python3/ultest/handler/parsers/output/python/pytest.py +++ b/rplugin/python3/ultest/handler/parsers/output/python/pytest.py @@ -1,5 +1,7 @@ +import os.path as path from dataclasses import dataclass from logging import getLogger +from pathlib import Path from typing import List, Optional from .. import parsec as p @@ -18,30 +20,42 @@ class PytestCodeTrace: message: Optional[List[str]] = None -@generate -def pytest_output(): - yield pytest_test_results_summary - parsed_outputs = yield p.many1(failed_test_section) - parsed_summary = yield pytest_summary_info +def parse_pytest(output: str, cwd: str = None): + parsed_outputs, parsed_summary = pytest_output.parse(output) + + def convert_to_relative(file: str): + if not Path(file).is_absolute(): + return file + return path.relpath(file, cwd) + parsed_results = { - (r.file, r.name, *r.namespaces): r + (convert_to_relative(r.file), r.name, *r.namespaces): r for r in [*parsed_summary, *parsed_outputs] if r } return ParsedOutput(results=list(parsed_results.values())) +@generate +def pytest_output(): + yield pytest_test_results_summary + parsed_outputs = yield p.many1(failed_test_section) + parsed_summary = yield pytest_summary_info + return parsed_outputs, parsed_summary + + @generate def failed_test_section(): namespaces, test_name = yield failed_test_section_title - yield until_eol raw_output_lines = yield p.many1( p.exclude(until_eol, failed_test_section_title ^ pytest_summary_info_title) ) output_text = "\n".join(raw_output_lines) + "\n" try: - file, err_msg, err_line = failed_test_section_output.parse(output_text) + file, err_msg, err_line = ( + failed_test_section_output ^ failed_test_section_collection_error + ).parse(output_text) return ParseResult( name=test_name, namespaces=namespaces, @@ -76,11 +90,34 @@ def failed_test_section_output(): ) +@generate +def failed_test_section_collection_error(): + yield p.string("Traceback") >> until_eol + test_file = ( + yield p.many1(p.string(" ")) + >> p.string('File "') + >> p.many1(p.none_of('"')).parsecmap(join_chars) + << p.string('", ') + ) + test_line_no = ( + yield p.string("line ") + >> p.many1(p.digit()).parsecmap(join_chars).parsecmap(int) + << until_eol + ) + yield p.many1(p.string(" ") >> until_eol) + error_message = yield until_eol + return ( + test_file, + [error_message], + test_line_no, + ) + + @generate def failed_test_section_title(): yield p.string("_") >> p.many1(p.string("_")) >> p.space() name_elements = ( - yield p.many1(p.none_of(" ")) + yield p.many1(p.none_of(" [")) .parsecmap(join_chars) .parsecmap(lambda elems: elems.split(".")) ) diff --git a/rplugin/python3/ultest/handler/parsers/output/python/unittest.py b/rplugin/python3/ultest/handler/parsers/output/python/unittest.py index d7d0e07..b6a9931 100644 --- a/rplugin/python3/ultest/handler/parsers/output/python/unittest.py +++ b/rplugin/python3/ultest/handler/parsers/output/python/unittest.py @@ -8,6 +8,10 @@ class ErroredTestError(Exception): ... +def parse_unittest(output: str, cwd: str = None): + return unittest_output.parse(output).results + + @generate def unittest_output(): try: diff --git a/rplugin/python3/ultest/handler/runner/__init__.py b/rplugin/python3/ultest/handler/runner/__init__.py index e763650..fff81c2 100644 --- a/rplugin/python3/ultest/handler/runner/__init__.py +++ b/rplugin/python3/ultest/handler/runner/__init__.py @@ -188,7 +188,9 @@ async def run(cmd=cmd): (code, output_path) = await self._processes.run( cmd, tree.data.file, tree.data.id, cwd=root, env=env ) - self._process_results(tree, file_tree, code, output_path, runner, on_finish) + self._process_results( + tree, file_tree, code, output_path, runner, on_finish, root + ) self._vim.launch(run(), tree.data.id) @@ -200,6 +202,7 @@ def _process_results( output_path: str, runner: str, on_finish: Callable[[List[Tuple[Position, Result]]], None], + cwd: Optional[str] = None, ): namespaces = { @@ -213,7 +216,7 @@ def _process_results( output = cmd_out.read() parsed_failures = ( - self._output_parser.parse_failed(runner, output) if code else [] + self._output_parser.parse_failed(runner, output, cwd) if code else [] ) failed = { (failed.name, *(failed.namespaces)): failed for failed in parsed_failures diff --git a/tests/unit/handler/parsers/output/python/test_pytest.py b/tests/unit/handler/parsers/output/python/test_pytest.py index 3dc72af..8fc10d7 100644 --- a/tests/unit/handler/parsers/output/python/test_pytest.py +++ b/tests/unit/handler/parsers/output/python/test_pytest.py @@ -82,6 +82,14 @@ def test_parse_file(self): output=None, line=23, ), + ParseResult( + name="test_parametrize[5]", + namespaces=[], + file="test_a.py", + message=None, + output=None, + line=None, + ), ], ) @@ -336,3 +344,52 @@ def warn(msg, *args, **kwargs): line=35, ), ) + + def test_parse_collection_error(self): + raw = """================================================= test session starts ================================================== +platform linux -- Python 3.8.12, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 +rootdir: /home/ronan/Dev/repos/hypothesis, configfile: pytest.ini +plugins: xdist-2.4.0, forked-1.3.0, hypothesis-6.24.0 +collected 1 item + +examples/example_hypothesis_entrypoint/test_entrypoint.py F [100%] + +======================================================= FAILURES ======================================================= +___________________________________________ test_registered_from_entrypoint ____________________________________________ +Traceback (most recent call last): + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/examples/example_hypothesis_entrypoint/test_entrypoint.py", line 22, in test_registered_from_entrypoint + def test_registered_from_entrypoint(x): + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/src/hypothesis/core.py", line 1199, in wrapped_test + raise the_error_hypothesis_found + File "/home/ronan/Dev/repos/hypothesis/hypothesis-python/examples/example_hypothesis_entrypoint/example_hypothesis_entrypoint.py", line 25, in __init__ + assert x >= 0, f"got {x}, but only positive numbers are allowed" +AssertionError: got -1, but only positive numbers are allowed +================================================= slowest 20 durations ================================================= +0.07s call hypothesis-python/examples/example_hypothesis_entrypoint/test_entrypoint.py::test_registered_from_entrypoint + +(2 durations < 0.005s hidden. Use -vv to show these durations.) +=============================================== short test summary info ================================================ +FAILED examples/example_hypothesis_entrypoint/test_entrypoint.py::test_registered_from_entrypoint - AssertionError: g... +================================================== 1 failed in 0.28s =================================================== + +""" + result = OutputParser([]).parse_failed( + "python#pytest", + raw, + cwd="/home/ronan/Dev/repos/hypothesis/hypothesis-python", + ) + self.assertEqual( + [ + ParseResult( + name="test_registered_from_entrypoint", + namespaces=[], + file="/home/ronan/Dev/repos/hypothesis/hypothesis-python/examples/example_hypothesis_entrypoint/test_entrypoint.py", + message=[ + "AssertionError: got -1, but only positive numbers are allowed" + ], + output=None, + line=22, + ) + ], + result, + )