diff --git a/pyproject.toml b/pyproject.toml index bc863f0..d665334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,10 @@ classifiers = [ "Topic :: Software Development :: Testing", ] requires-python = ">=3.8" -dependencies = ["PyYAML"] +dependencies = [ + "PyYAML", + "rich", +] [project.optional-dependencies] test = [ diff --git a/relint/__main__.py b/relint/__main__.py index f19892e..739a754 100644 --- a/relint/__main__.py +++ b/relint/__main__.py @@ -3,7 +3,8 @@ import subprocess # nosec import sys import warnings -from itertools import chain + +from rich.progress import track from relint.config import load_config from relint.parse import lint_file, match_with_diff_changes, parse_diff, print_culprits @@ -48,12 +49,18 @@ def parse_args(args=None): help="Do not output warnings. Could be useful when using relint in CI.", ) parser.add_argument( - "--msg-template", - metavar="MSG_TEMPLATE", - type=str, - default="{filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}", - help="Template used to display messages. " - r"Default: {filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}", + "--summarize", + action="store_true", + help="Summarize the output by grouping matches by test.", + ), + parser.add_argument( + "--code-padding", + type=int, + default=2, + help=( + "Lines of padding to show around the matching code snippet. Default: 2\n" + "Set to -1 disable code snippet output." + ), ) return parser.parse_args(args=args) @@ -73,7 +80,9 @@ def main(args=None): tests = list(load_config(args.config, args.fail_warnings, args.ignore_warnings)) - matches = chain.from_iterable(lint_file(path, tests) for path in paths) + matches = [] + for path in track(paths, description="Linting files..."): + matches.extend(lint_file(path, tests)) output = "" if args.diff: @@ -87,7 +96,7 @@ def main(args=None): changed_content = parse_diff(output) matches = match_with_diff_changes(changed_content, matches) - exit_code = print_culprits(matches, args.msg_template) + exit_code = print_culprits(matches, args) exit(exit_code) diff --git a/relint/parse.py b/relint/parse.py index ba13af3..6a23165 100644 --- a/relint/parse.py +++ b/relint/parse.py @@ -1,7 +1,14 @@ from __future__ import annotations +import collections import re +from rich import print as rprint +from rich.console import Group +from rich.markdown import Markdown +from rich.panel import Panel +from rich.syntax import Syntax + GIT_DIFF_LINE_NUMBERS_PATTERN = re.compile(r"@ -\d+(,\d+)? \+(\d+)(,)?(\d+)? @") GIT_DIFF_FILENAME_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/(.*)(?:\n|$)") GIT_DIFF_SPLIT_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/.*(?:\n|$)") @@ -82,37 +89,77 @@ def split_diff_content_by_filename(output: str) -> {str: str}: return content_by_filename -def print_culprits(matches, msg_template): +def print_culprits(matches, args): exit_code = 0 - _filename = "" - lines = [] + messages = [] + match_groups = collections.defaultdict(list) for filename, test, match, _ in matches: exit_code = test.error if exit_code == 0 else exit_code - if filename != _filename: - _filename = filename - lines = match.string.splitlines() + start_line_no = match.string[: match.start()].count("\n") + 1 + end_line_no = match.string[: match.end()].count("\n") + 1 - start_line_no = match.string[: match.start()].count("\n") - end_line_no = match.string[: match.end()].count("\n") - match_lines = ( - "{line_no}> {code_line}".format( - line_no=no + start_line_no + 1, - code_line=line.lstrip(), + if args.summarize: + match_groups[test].append(f"{filename}:{start_line_no}") + else: + hint = Panel( + Markdown(test.hint, justify="left"), + title="Hint:", + title_align="left", + padding=(0, 2), ) - for no, line in enumerate(lines[start_line_no : end_line_no + 1]) - ) - # special characters from shell are escaped - msg_template = msg_template.replace("\\n", "\n") - print( - msg_template.format( - filename=filename, - line_no=start_line_no + 1, - test=test, - match=chr(10).join(match_lines), + + if args.code_padding == -1: + message = hint + else: + lexer = Syntax.guess_lexer(filename) + syntax = Syntax( + match.string, + lexer=lexer, + line_numbers=True, + line_range=( + start_line_no - args.code_padding, + end_line_no + args.code_padding, + ), + highlight_lines=range(start_line_no, end_line_no + 1), + ) + message = Group(syntax, hint) + + messages.append( + Panel( + message, + title=f"{'Error' if test.error else 'Warning'}: {test.name}", + title_align="left", + subtitle=f"{filename}:{start_line_no}", + subtitle_align="left", + border_style="bold red" if test.error else "yellow", + padding=(0, 2), + ) ) - ) + + if args.summarize: + for test, filenames in match_groups.items(): + hint = Panel( + Markdown(test.hint, justify="left"), + title="Hint:", + title_align="left", + padding=(0, 2), + ) + group = Group(Group(*filenames), hint) + messages.append( + Panel( + group, + title=f"{'Error' if test.error else 'Warning'}: {test.name}", + title_align="left", + subtitle=f"{len(filenames)} occurrence(s)", + subtitle_align="left", + border_style="bold red" if test.error else "yellow", + padding=(0, 2), + ) + ) + + rprint(*messages, sep="\n") return exit_code diff --git a/tests/fixtures/.relint.yml b/tests/fixtures/.relint.yml index 13098c7..a1a3e8d 100644 --- a/tests/fixtures/.relint.yml +++ b/tests/fixtures/.relint.yml @@ -6,6 +6,28 @@ - name: No fixme (warning) pattern: '[fF][iI][xX][mM][eE]' - hint: Fix it right away! + hint: | + ### This is a multiline hint + Fix it right away! + + You can use code blocks too, like Python: + + ```python + print('hello world') + ``` + + Or JavaScript: + + ```javascript + console.log('hello world') + ``` + + And even Git diffs: + + ```diff + - print('hello world') + + console.log('hello world') + ``` + filePattern: ^(?!.*test_).*\.(py|js)$ error: true diff --git a/tests/test_main.py b/tests/test_main.py index e7c09b7..e99429a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -37,50 +37,65 @@ def test_main_execution_with_error(self, capsys, tmpdir, fixture_dir): with pytest.raises(SystemExit) as exc_info: main(["relint.py", "dummy.py"]) - expected_message = "dummy.py:1 No fixme (warning)\nHint: Fix it right away!\n1> # FIXME do something\n" - out, _ = capsys.readouterr() - assert expected_message == out + assert "dummy.py:1" in out + assert "No fixme (warning)" in out + assert "Fix it right away!" in out + assert "❱ 1 # FIXME do something" in out assert exc_info.value.code == 1 - def test_main_execution_with_custom_template(self, capsys, tmpdir, fixture_dir): + def test_raise_for_warnings(self, tmpdir, fixture_dir): with (fixture_dir / ".relint.yml").open() as fs: config = fs.read() tmpdir.join(".relint.yml").write(config) tmpdir.join("dummy.py").write("# TODO do something") - with tmpdir.as_cwd(): with pytest.raises(SystemExit) as exc_info: - template = r"😵{filename}:{line_no} | {test.name} \n {match}" - main(["relint.py", "dummy.py", "--msg-template", template]) + main(["relint.py", "dummy.py", "-W"]) - expected_message = "😵dummy.py:1 | No ToDo \n" " 1> # TODO do something\n" + assert exc_info.value.code == 1 + + def test_ignore_warnings(self, tmpdir, fixture_dir): + with (fixture_dir / ".relint.yml").open() as fs: + config = fs.read() + tmpdir.join(".relint.yml").write(config) + tmpdir.join("dummy.py").write("# TODO do something") + with tmpdir.as_cwd(): + with pytest.raises(SystemExit) as exc_info: + main(["relint.py", "dummy.py", "--ignore-warnings"]) - out, _ = capsys.readouterr() - assert expected_message == out assert exc_info.value.code == 0 - def test_raise_for_warnings(self, tmpdir, fixture_dir): + def test_summarize(self, tmpdir, fixture_dir, capsys): with (fixture_dir / ".relint.yml").open() as fs: config = fs.read() tmpdir.join(".relint.yml").write(config) - tmpdir.join("dummy.py").write("# TODO do something") + tmpdir.join("dummy.py").write("# FIXME do something") with tmpdir.as_cwd(): with pytest.raises(SystemExit) as exc_info: - main(["relint.py", "dummy.py", "-W"]) + main(["relint.py", "dummy.py", "--summarize"]) + out, _ = capsys.readouterr() + assert "dummy.py:1" in out + assert "No fixme (warning)" in out + assert "Fix it right away!" in out + assert "1 occurrence(s)" in out assert exc_info.value.code == 1 - def test_ignore_warnings(self, tmpdir, fixture_dir): + def test_code_padding_disabled(self, tmpdir, fixture_dir, capsys): with (fixture_dir / ".relint.yml").open() as fs: config = fs.read() tmpdir.join(".relint.yml").write(config) - tmpdir.join("dummy.py").write("# TODO do something") + tmpdir.join("dummy.py").write("# FIXME do something") with tmpdir.as_cwd(): with pytest.raises(SystemExit) as exc_info: - main(["relint.py", "dummy.py", "--ignore-warnings"]) + main(["relint.py", "dummy.py", "--code-padding=-1"]) - assert exc_info.value.code == 0 + out, _ = capsys.readouterr() + assert "dummy.py:1" in out + assert "No fixme (warning)" in out + assert "Fix it right away!" in out + assert exc_info.value.code == 1 def test_main_execution_with_diff(self, capsys, mocker, tmpdir, fixture_dir): with (fixture_dir / ".relint.yml").open() as fs: @@ -99,8 +114,6 @@ def test_main_execution_with_diff(self, capsys, mocker, tmpdir, fixture_dir): with pytest.raises(SystemExit) as exc_info: main(["relint.py", "dummy.py", "--diff"]) - expected_message = "Hint: Get it done right away!" - out, _ = capsys.readouterr() - assert expected_message in out + assert "Get it done right away!" in out assert exc_info.value.code == 0