diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d1a662c3..4b8864d5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,9 +15,7 @@ Please use markdown to send the code here. **Additional infos** - - OS: - - python3 --version: - - norminette -v: +Copy and paste the output of `norminette --version` command here. **Additional context** Add any other context about the problem here. diff --git a/norminette/__init__.py b/norminette/__init__.py index 24965a25..5251e95f 100644 --- a/norminette/__init__.py +++ b/norminette/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.3.54" +__version__ = "3.3.55" __name__ = "norminette" __author__ = "42" __author__email__ = "pedago@42.fr" diff --git a/norminette/__main__.py b/norminette/__main__.py index 8e0fedec..2b6509e4 100644 --- a/norminette/__main__.py +++ b/norminette/__main__.py @@ -1,9 +1,11 @@ import glob import sys import pathlib +import platform 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 @@ -13,6 +15,10 @@ import subprocess +version_text = "norminette" + version("norminette") +version_text += f", Python {platform.python_version()}" +version_text += f", {platform.platform()}" + def main(): parser = argparse.ArgumentParser() @@ -39,7 +45,7 @@ def main(): "-v", "--version", action="version", - version="norminette " + version("norminette"), + version=version_text, ) parser.add_argument( "--cfile", @@ -61,10 +67,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 +135,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 00000000..dbe436d4 --- /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 5d12be25..ac44a115 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 9d1ac250..3d9b5e17 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/norminette/rules/check_func_declaration.py b/norminette/rules/check_func_declaration.py index f744c748..1fa62083 100644 --- a/norminette/rules/check_func_declaration.py +++ b/norminette/rules/check_func_declaration.py @@ -48,6 +48,7 @@ def run(self, context): i += 1 if context.check_token(i, "LPARENTHESIS") is False: context.new_error("EXP_PARENTHESIS", context.peek_token(i)) + i = context.skip_ws(i) i += 1 deep = 1 while deep > 0 and context.peek_token(i) is not None: diff --git a/norminette/rules/is_preprocessor_statement.py b/norminette/rules/is_preprocessor_statement.py index 12eb34c1..29ffad12 100644 --- a/norminette/rules/is_preprocessor_statement.py +++ b/norminette/rules/is_preprocessor_statement.py @@ -2,6 +2,7 @@ import contextlib from norminette.rules import Rule, Primary +from norminette.lexer.dictionary import keywords from norminette.exceptions import CParsingError from norminette.context import Macro @@ -45,6 +46,8 @@ "DOT", "SPACE", "TAB", + # TODO Remove all keyword tokens and add to just use 'IDENTIFIER' instead + *keywords.values(), # https://github.com/42School/norminette/issues/470 ) diff --git a/tests/rules/rules_generator_test.py b/tests/rules/rules_generator_test.py index 703720f2..f2049add 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/rules/samples/check_preprocessor_include.c b/tests/rules/samples/check_preprocessor_include.c index 63bba19c..d6dd5197 100644 --- a/tests/rules/samples/check_preprocessor_include.c +++ b/tests/rules/samples/check_preprocessor_include.c @@ -9,4 +9,14 @@ void main(void); #if 1 # include "ok but not ok.h" -#endif \ No newline at end of file +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/tests/rules/samples/check_preprocessor_include.out b/tests/rules/samples/check_preprocessor_include.out index f723e71a..3962a0d2 100644 --- a/tests/rules/samples/check_preprocessor_include.out +++ b/tests/rules/samples/check_preprocessor_include.out @@ -21,7 +21,27 @@ check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 11": check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 12": - + +check_preprocessor_include.c - IsEmptyLine In "GlobalScope" from "None" line 13": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 14": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 15": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 16": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 17": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 18": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 19": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 20": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 21": + +check_preprocessor_include.c - IsPreprocessorStatement In "GlobalScope" from "None" line 22": + check_preprocessor_include.c: Error! Error: INVALID_HEADER (line: 1, col: 1): Missing or invalid 42 header Error: INCLUDE_HEADER_ONLY (line: 2, col: 10): .c file includes are forbidden @@ -29,3 +49,12 @@ Error: INCLUDE_HEADER_ONLY (line: 3, col: 10): .c file includes are forbidde Error: CONSECUTIVE_NEWLINES (line: 5, col: 1): Consecutive newlines Error: INCLUDE_START_FILE (line: 8, col: 1): Include must be at the start of file Error: INCLUDE_START_FILE (line: 11, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 14, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 15, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 16, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 17, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 18, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 19, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 20, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 21, col: 1): Include must be at the start of file +Error: INCLUDE_START_FILE (line: 22, col: 1): Include must be at the start of file diff --git a/tests/rules/samples/ko_func_name2.c b/tests/rules/samples/ko_func_name2.c index 7f758e3d..c0610acb 100644 --- a/tests/rules/samples/ko_func_name2.c +++ b/tests/rules/samples/ko_func_name2.c @@ -7,3 +7,15 @@ int main (void) { return (21); } + +int main (void) +{ + int array[] = {1, 2, 3, 4, 5}; + + return (0); +} + +int _1 a b c(void) +{ + return ; +} diff --git a/tests/rules/samples/ko_func_name2.out b/tests/rules/samples/ko_func_name2.out index 89fdd2b0..3bbe9b8c 100644 --- a/tests/rules/samples/ko_func_name2.out +++ b/tests/rules/samples/ko_func_name2.out @@ -16,7 +16,40 @@ ko_func_name2.c - IsBlockEnd In "Function" from "GlobalScope" line 9": +ko_func_name2.c - IsEmptyLine In "GlobalScope" from "None" line 10": + +ko_func_name2.c - IsFuncDeclaration In "GlobalScope" from "None" line 11": + +ko_func_name2.c - IsBlockStart In "Function" from "GlobalScope" line 12": + +ko_func_name2.c - IsVarDeclaration In "Function" from "GlobalScope" line 13": + +ko_func_name2.c - IsEmptyLine In "Function" from "GlobalScope" line 14": + +ko_func_name2.c - IsExpressionStatement In "Function" from "GlobalScope" line 15": + +ko_func_name2.c - IsBlockEnd In "Function" from "GlobalScope" line 16": + +ko_func_name2.c - IsEmptyLine In "GlobalScope" from "None" line 17": + +ko_func_name2.c - IsFuncDeclaration In "GlobalScope" from "None" line 18": + +ko_func_name2.c - IsBlockStart In "Function" from "GlobalScope" line 19": + +ko_func_name2.c - IsExpressionStatement In "Function" from "GlobalScope" line 20": + +ko_func_name2.c - IsBlockEnd In "Function" from "GlobalScope" line 21": + ko_func_name2.c: Error! Error: INVALID_HEADER (line: 1, col: 1): Missing or invalid 42 header Error: EXP_PARENTHESIS (line: 1, col: 9): Expected parenthesis Error: EXP_PARENTHESIS (line: 6, col: 9): Expected parenthesis +Error: SPACE_BEFORE_FUNC (line: 11, col: 4): space before function name +Error: EXP_PARENTHESIS (line: 11, col: 9): Expected parenthesis +Error: TOO_FEW_TAB (line: 13, col: 1): Missing tabs for indent level +Error: SPACE_REPLACE_TAB (line: 13, col: 5): Found space when expecting tab +Error: SPACE_REPLACE_TAB (line: 13, col: 8): Found space when expecting tab +Error: DECL_ASSIGN_LINE (line: 13, col: 17): Declaration and assignation on a single line +Error: TOO_FEW_TAB (line: 15, col: 1): Missing tabs for indent level +Error: SPACE_REPLACE_TAB (line: 15, col: 5): Found space when expecting tab +Error: SPACE_BEFORE_FUNC (line: 18, col: 11): space before function name diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..9f61515a --- /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=",:")