diff --git a/norminette/__main__.py b/norminette/__main__.py index 8e0fede..632025d 100644 --- a/norminette/__main__.py +++ b/norminette/__main__.py @@ -4,6 +4,7 @@ from importlib.metadata import version import argparse +from norminette.errors import formatters from norminette.file import File from norminette.lexer import Lexer, TokenError from norminette.exceptions import CParsingError @@ -61,10 +62,18 @@ def main(): action="store_true", help="Parse only source files not match to .gitignore", ) + parser.add_argument( + "-f", + "--format", + choices=list(formatter.name for formatter in formatters), + help="formatting style for errors", + default="humanized", + ) parser.add_argument("-R", nargs=1, help="compatibility for norminette 2") args = parser.parse_args() registry = Registry() + format = next(filter(lambda it: it.name == args.format, formatters)) files = [] debug = args.debug if args.cfile or args.hfile: @@ -121,6 +130,8 @@ def main(): sys.exit(1) except KeyboardInterrupt: sys.exit(1) + errors = format(files) + print(errors) sys.exit(1 if len(file.errors) else 0) diff --git a/norminette/errors.py b/norminette/errors.py new file mode 100644 index 0000000..dbe436d --- /dev/null +++ b/norminette/errors.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +import json +from dataclasses import dataclass, field, asdict +from functools import cmp_to_key +from typing import TYPE_CHECKING, Sequence, Union, Literal, Optional, List + +from norminette.norm_error import NormError, NormWarning, errors as errors_dict + +if TYPE_CHECKING: + from norminette.file import File + + +def sort_errs(a: Error, b: Error): + # TODO Add to Error and Highlight dataclasses be sortable to remove this fn + ah: Highlight = a.highlights[0] + bh: Highlight = b.highlights[0] + if ah.column == bh.column and ah.lineno == bh.lineno: + return 1 if a.name > b.name else -1 + return ah.column - bh.column if ah.lineno == bh.lineno else ah.lineno - bh.lineno + + +@dataclass +class Highlight: + lineno: int + column: int + length: Optional[int] = field(default=None) + hint: Optional[str] = field(default=None) + + +@dataclass +class Error: + name: str + text: str + level: Literal["Error", "Notice"] + highlights: List[Highlight] + + +class Errors: + __slots__ = "_inner" + + def __init__(self) -> None: + self._inner = [] + + def __len__(self) -> int: + return len(self._inner) + + def __iter__(self): + self._inner.sort(key=cmp_to_key(sort_errs)) + return iter(self._inner) + + # TODO Add `add(...)` method to allow creating `Highlight`s and `Error`s easily + + @property + def status(self) -> Literal["OK", "Error"]: + return "OK" if all(it.level == "Notice" for it in self._inner) else "Error" + + def append(self, value: Union[NormError, NormWarning]) -> None: + # TODO Remove NormError and NormWarning since it does not provide `length` data + assert isinstance(value, (NormError, NormWarning)) + level = "Error" if isinstance(value, NormError) else "Notice" + value = Error(value.errno, value.error_msg, level, highlights=[ + Highlight(value.line, value.col, None), + ]) + self._inner.append(value) + + +class _formatter: + def __init__(self, files: Union[File, Sequence[File]]) -> None: + if not isinstance(files, list): + files = [files] + self.files = files + + def __init_subclass__(cls) -> None: + cls.name = cls.__name__.rstrip("ErrorsFormatter").lower() + + +class HumanizedErrorsFormatter(_formatter): + def __str__(self) -> str: + output = '' + for file in self.files: + output += f"{file.basename}: {file.errors.status}!" + for error in file.errors: + brief = errors_dict.get(error.name, "Error not found") + highlight = error.highlights[0] + output += f"\n{error.level}: {error.name:<20} " + output += f"(line: {highlight.lineno:>3}, col: {highlight.column:>3}):\t{brief}" + return output + + +class JSONErrorsFormatter(_formatter): + def __str__(self): + files = [] + for file in self.files: + files.append({ + "path": os.path.abspath(file.path), + "status": file.errors.status, + "errors": tuple(map(asdict, file.errors)), + }) + output = { + "files": files, + } + return json.dumps(output, separators=",:") + + +formatters = ( + JSONErrorsFormatter, + HumanizedErrorsFormatter, +) diff --git a/norminette/file.py b/norminette/file.py index 5d12be2..ac44a11 100644 --- a/norminette/file.py +++ b/norminette/file.py @@ -1,40 +1,7 @@ import os -from functools import cmp_to_key -from typing import Optional, Union, Literal +from typing import Optional -from norminette.norm_error import NormError, NormWarning - - -def sort_errs(a, b): - if a.col == b.col and a.line == b.line: - return 1 if a.errno > b.errno else -1 - return a.col - b.col if a.line == b.line else a.line - b.line - - -class Errors: - __slots__ = "_inner" - - def __init__(self) -> None: - self._inner = [] - - def __len__(self) -> int: - return len(self._inner) - - def __iter__(self): - self._inner.sort(key=cmp_to_key(sort_errs)) - return iter(self._inner) - - @property - def status(self) -> Literal["OK", "Error"]: - if not self: - return "OK" - if all(isinstance(it, NormWarning) for it in self): - return "OK" - return "Error" - - def append(self, value: Union[NormError, NormWarning]) -> None: - assert isinstance(value, (NormError, NormWarning)) - self._inner.append(value) +from norminette.errors import Errors class File: diff --git a/norminette/registry.py b/norminette/registry.py index 9d1ac25..3d9b5e1 100644 --- a/norminette/registry.py +++ b/norminette/registry.py @@ -78,6 +78,3 @@ def run(self, context): print(context.debug) if context.debug > 0: print("uncaught ->", unrecognized_tkns) - print(f"{context.file.basename}: {context.file.errors.status}!") - for error in context.file.errors: - print(error) diff --git a/tests/rules/rules_generator_test.py b/tests/rules/rules_generator_test.py index 703720f..f2049ad 100644 --- a/tests/rules/rules_generator_test.py +++ b/tests/rules/rules_generator_test.py @@ -5,6 +5,7 @@ from norminette.lexer import Lexer from norminette.context import Context from norminette.registry import Registry +from norminette.errors import HumanizedErrorsFormatter registry = Registry() @@ -23,6 +24,8 @@ def test_rule_for_file(file, capsys): lexer = Lexer(file) context = Context(file, lexer.get_tokens(), debug=2) registry.run(context) + errors = HumanizedErrorsFormatter(file) + print(errors) captured = capsys.readouterr() assert captured.out == out_content diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..9f61515 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,47 @@ +import json + +import pytest + +from norminette.file import File +from norminette.lexer import Lexer +from norminette.context import Context +from norminette.registry import Registry +from norminette.errors import JSONErrorsFormatter + +tests = [ + { + "file": File("/nium/test.c", "int\tmain()\n{\n\treturn ;\n}\n"), + "test": { + "files": [ + { + "path": "/nium/test.c", + "status": "Error", + "errors": [ + { + "name": "INVALID_HEADER", + "text": "Missing or invalid 42 header", + "level": "Error", + "highlights": [{"lineno": 1, "column": 1, "length": None, "hint": None}], + }, + { + "name": "NO_ARGS_VOID", + "text": "Empty function argument requires void", + "level": "Error", + "highlights": [{"lineno": 1, "column": 10, "length": None, "hint": None}], + }, + ], + }, + ], + }, + }, +] + + +@pytest.mark.parametrize("file,test", [it.values() for it in tests]) +def test_json_formatter_errored_file(file, test): + lexer = Lexer(file) + context = Context(file, lexer.get_tokens()) + Registry().run(context) + + formatter = JSONErrorsFormatter(file) + assert str(formatter) == json.dumps(test, separators=",:")