diff --git a/pyproject.toml b/pyproject.toml index 3d8f7d0..172b5ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ build-backend = "setuptools.build_meta" requires = ["setuptools >= 61"] [tool.setuptools] -packages = ["yamllint", "yamllint.conf", "yamllint.rules"] +packages = ["yamllint", "yamllint.conf", "yamllint.formatters", "yamllint.rules"] [tool.setuptools.package-data] yamllint = ["conf/*.yaml"] diff --git a/tests/common.py b/tests/common.py index 65af63b..9546c77 100644 --- a/tests/common.py +++ b/tests/common.py @@ -40,10 +40,7 @@ class RuleTestCase(unittest.TestCase): for key in kwargs: assert key.startswith('problem') if len(kwargs[key]) > 2: - if kwargs[key][2] == 'syntax': - rule_id = None - else: - rule_id = kwargs[key][2] + rule_id = kwargs[key][2] else: rule_id = self.rule_id expected_problems.append(linter.LintProblem( diff --git a/tests/test_cli.py b/tests/test_cli.py index 419af92..26d828d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -582,6 +582,94 @@ class CommandLineTestCase(unittest.TestCase): self.assertEqual( (ctx.returncode, ctx.stdout, ctx.stderr), (0, expected_out, '')) + def test_run_format_sarif(self): + path = os.path.join(self.wd, 'a.yaml') + + with RunContext(self) as ctx: + cli.run((path, '--format', 'sarif')) + expected_out = ( + '{"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif' + '-spec/master/Schemata/sarif-schema-2.1.0.json", "version": ' + '"2.1.0", "runs": [{"tool": {"driver": {"name": "yamllint", ' + '"version": "1.32.0", "informationUri": ' + '"https://yamllint.readthedocs.io", "rules": [{"id": ' + '"trailing-spaces", "name": "TrailingSpaces", ' + '"defaultConfiguration": {"level": "error"}, "properties": {' + '"description": "trailing spaces", "tags": [], "queryUri": ' + '"https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.trailing_spaces"}, "shortDescription": {"text": ' + '"trailing spaces"}, "fullDescription": {"text": "trailing ' + 'spaces"}, "helpUri": ' + '"https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.trailing_spaces", "help": {"text": "More info: ' + 'https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.trailing_spaces", "markdown": "[More info](' + 'https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.trailing_spaces)"}}, {"id": ' + '"new-line-at-end-of-file", "name": "NewLineAtEndOfFile", ' + '"defaultConfiguration": {"level": "error"}, "properties": {' + '"description": "no new line character at the end of file", ' + '"tags": [], "queryUri": ' + '"https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.new_line_at_end_of_file"}, "shortDescription": ' + '{"text": "no new line character at the end of file"}, ' + '"fullDescription": {"text": "no new line character at the end ' + 'of file"}, "helpUri": ' + '"https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.new_line_at_end_of_file", "help": {"text": ' + '"More info: https://yamllint.readthedocs.io/en/v1.32.0/rules' + '.html#module-yamllint.rules.new_line_at_end_of_file", ' + '"markdown": "[More info](' + 'https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.new_line_at_end_of_file)"}}]}}, "results": [{' + '"ruleId": "trailing-spaces", "ruleIndex": 0, "message": {' + '"text": "trailing spaces (trailing-spaces)"}, "locations": [{' + '"physicalLocation": {"artifactLocation": {"uri": "%s", ' + '"uriBaseId": "%%SRCROOT%%"}, "region": {"startLine": 2, ' + '"startColumn": 4}}}]}, {"ruleId": "new-line-at-end-of-file", ' + '"ruleIndex": 1, "message": {"text": "no new line character at ' + 'the end of file (new-line-at-end-of-file)"}, "locations": [{' + '"physicalLocation": {"artifactLocation": {"uri": "%s", ' + '"uriBaseId": "%%SRCROOT%%"}, "region": {"startLine": 3, ' + '"startColumn": 4}}}]}]}]}\n' + % (path, path)) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) + + def test_run_format_sarif_warning(self): + path = os.path.join(self.wd, 'warn.yaml') + + with RunContext(self) as ctx: + cli.run((path, '--format', 'sarif')) + expected_out = ( + '{"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif' + '-spec/master/Schemata/sarif-schema-2.1.0.json", "version": ' + '"2.1.0", "runs": [{"tool": {"driver": {"name": "yamllint", ' + '"version": "1.32.0", "informationUri": ' + '"https://yamllint.readthedocs.io", "rules": [{"id": ' + '"document-start", "name": "DocumentStart", ' + '"defaultConfiguration": {"level": "warning"}, "properties": {' + '"description": "missing document start \\"---\\"", "tags": [], ' + '"queryUri": "https://yamllint.readthedocs.io/en/v1.32.0/rules' + '.html#module-yamllint.rules.document_start"}, ' + '"shortDescription": {"text": "missing document start ' + '\\"---\\""}, "fullDescription": {"text": "missing document ' + 'start \\"---\\""}, "helpUri": ' + '"https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.document_start", "help": {"text": "More info: ' + 'https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.document_start", "markdown": "[More info](' + 'https://yamllint.readthedocs.io/en/v1.32.0/rules.html#module' + '-yamllint.rules.document_start)"}}]}}, "results": [{"ruleId": ' + '"document-start", "ruleIndex": 0, "message": {"text": "missing ' + 'document start \\"---\\" (document-start)"}, "locations": [{' + '"physicalLocation": {"artifactLocation": {"uri": "%s", ' + '"uriBaseId": "%%SRCROOT%%"}, "region": {"startLine": 1, ' + '"startColumn": 1}}}]}]}]}\n' + % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (0, expected_out, '')) + def test_run_format_github(self): path = os.path.join(self.wd, 'a.yaml') diff --git a/tests/test_syntax_errors.py b/tests/test_syntax_errors.py index 507ab5a..ece4474 100644 --- a/tests/test_syntax_errors.py +++ b/tests/test_syntax_errors.py @@ -17,7 +17,7 @@ from tests.common import RuleTestCase class YamlLintTestCase(RuleTestCase): - rule_id = None # syntax error + rule_id = 'syntax' # syntax error def test_syntax_errors(self): self.check('---\n' diff --git a/yamllint/cli.py b/yamllint/cli.py index d7fa156..0882a6e 100644 --- a/yamllint/cli.py +++ b/yamllint/cli.py @@ -22,6 +22,7 @@ import sys from yamllint import APP_DESCRIPTION, APP_NAME, APP_VERSION from yamllint import linter from yamllint.config import YamlLintConfig, YamlLintConfigError +from yamllint.formatters import colored, github, parsable, sarif, standard from yamllint.linter import PROBLEM_LEVELS @@ -46,63 +47,7 @@ def supports_color(): hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()) -class Format: - @staticmethod - def parsable(problem, filename): - return ('%(file)s:%(line)s:%(column)s: [%(level)s] %(message)s' % - {'file': filename, - 'line': problem.line, - 'column': problem.column, - 'level': problem.level, - 'message': problem.message}) - - @staticmethod - def standard(problem, filename): - line = ' %d:%d' % (problem.line, problem.column) - line += max(12 - len(line), 0) * ' ' - line += problem.level - line += max(21 - len(line), 0) * ' ' - line += problem.desc - if problem.rule: - line += ' (%s)' % problem.rule - return line - - @staticmethod - def standard_color(problem, filename): - line = ' \033[2m%d:%d\033[0m' % (problem.line, problem.column) - line += max(20 - len(line), 0) * ' ' - if problem.level == 'warning': - line += '\033[33m%s\033[0m' % problem.level - else: - line += '\033[31m%s\033[0m' % problem.level - line += max(38 - len(line), 0) * ' ' - line += problem.desc - if problem.rule: - line += ' \033[2m(%s)\033[0m' % problem.rule - return line - - @staticmethod - def github(problem, filename): - line = '::' - line += problem.level - line += ' file=' + filename + ',' - line += 'line=' + format(problem.line) + ',' - line += 'col=' + format(problem.column) - line += '::' - line += format(problem.line) - line += ':' - line += format(problem.column) - line += ' ' - if problem.rule: - line += '[' + problem.rule + '] ' - line += problem.desc - return line - - -def show_problems(problems, file, args_format, no_warn): - max_level = 0 - first = True - +def show_results(results, args_format, no_warn): if args_format == 'auto': if ('GITHUB_ACTIONS' in os.environ and 'GITHUB_WORKFLOW' in os.environ): @@ -110,35 +55,16 @@ def show_problems(problems, file, args_format, no_warn): elif supports_color(): args_format = 'colored' - for problem in problems: - max_level = max(max_level, PROBLEM_LEVELS[problem.level]) - if no_warn and (problem.level != 'error'): - continue - if args_format == 'parsable': - print(Format.parsable(problem, file)) - elif args_format == 'github': - if first: - print('::group::%s' % file) - first = False - print(Format.github(problem, file)) - elif args_format == 'colored': - if first: - print('\033[4m%s\033[0m' % file) - first = False - print(Format.standard_color(problem, file)) - else: - if first: - print(file) - first = False - print(Format.standard(problem, file)) - - if not first and args_format == 'github': - print('::endgroup::') - - if not first and args_format != 'parsable': - print('') - - return max_level + if args_format == 'parsable': + return parsable.format_results(results, no_warn) + elif args_format == 'github': + return github.format_results(results, no_warn) + elif args_format == 'sarif': + return sarif.format_results(results, no_warn) + elif args_format == 'colored': + return colored.format_results(results, no_warn) + else: + return standard.format_results(results, no_warn) def find_project_config_filepath(path='.'): @@ -174,7 +100,7 @@ def run(argv=None): help='list files to lint and exit') parser.add_argument('-f', '--format', choices=('parsable', 'standard', 'colored', 'github', - 'auto'), + 'sarif', 'auto'), default='auto', help='format for parsing output') parser.add_argument('-s', '--strict', action='store_true', @@ -225,8 +151,7 @@ def run(argv=None): print(file) sys.exit(0) - max_level = 0 - + results = {} for file in find_files_recursively(args.files, conf): filepath = file[2:] if file.startswith('./') else file try: @@ -235,9 +160,7 @@ def run(argv=None): except OSError as e: print(e, file=sys.stderr) sys.exit(-1) - prob_level = show_problems(problems, file, args_format=args.format, - no_warn=args.no_warnings) - max_level = max(max_level, prob_level) + results[filepath] = problems # read yaml from stdin if args.stdin: @@ -246,9 +169,10 @@ def run(argv=None): except OSError as e: print(e, file=sys.stderr) sys.exit(-1) - prob_level = show_problems(problems, 'stdin', args_format=args.format, - no_warn=args.no_warnings) - max_level = max(max_level, prob_level) + results['stdin'] = problems + + max_level = show_results(results, args_format=args.format, + no_warn=args.no_warnings) if max_level == PROBLEM_LEVELS['error']: return_code = 1 diff --git a/yamllint/formatters/colored.py b/yamllint/formatters/colored.py new file mode 100644 index 0000000..e3e5777 --- /dev/null +++ b/yamllint/formatters/colored.py @@ -0,0 +1,48 @@ +# Copyright (C) 2016 Adrien Vergé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from yamllint.linter import PROBLEM_LEVELS + + +def format_results(results, no_warn): + max_level = 0 + + for file in results: + print('\033[4m%s\033[0m' % file) + + for problem in results[file]: + max_level = max(max_level, PROBLEM_LEVELS[problem.level]) + if no_warn and (problem.level != 'error'): + continue + + print(format_problem(problem)) + + print('') + + return max_level + + +def format_problem(problem): + line = ' \033[2m%d:%d\033[0m' % (problem.line, problem.column) + line += max(20 - len(line), 0) * ' ' + if problem.level == 'warning': + line += '\033[33m%s\033[0m' % problem.level + else: + line += '\033[31m%s\033[0m' % problem.level + line += max(38 - len(line), 0) * ' ' + line += problem.desc + if problem.rule: + line += ' \033[2m(%s)\033[0m' % problem.rule + return line diff --git a/yamllint/formatters/github.py b/yamllint/formatters/github.py new file mode 100644 index 0000000..f2dbe3c --- /dev/null +++ b/yamllint/formatters/github.py @@ -0,0 +1,52 @@ +# Copyright (C) 2016 Adrien Vergé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from yamllint.linter import PROBLEM_LEVELS + + +def format_results(results, no_warn): + max_level = 0 + + for file in results: + print('::group::%s' % file) + + for problem in results[file]: + max_level = max(max_level, PROBLEM_LEVELS[problem.level]) + if no_warn and (problem.level != 'error'): + continue + + print(format_problem(problem, file)) + + print('::endgroup::') + print('') + + return max_level + + +def format_problem(problem, filename): + line = '::' + line += problem.level + line += ' file=' + filename + ',' + line += 'line=' + format(problem.line) + ',' + line += 'col=' + format(problem.column) + line += '::' + line += format(problem.line) + line += ':' + line += format(problem.column) + line += ' ' + if problem.rule: + line += '[' + problem.rule + '] ' + line += problem.desc + return line diff --git a/yamllint/formatters/parsable.py b/yamllint/formatters/parsable.py new file mode 100644 index 0000000..eb32462 --- /dev/null +++ b/yamllint/formatters/parsable.py @@ -0,0 +1,39 @@ +# Copyright (C) 2016 Adrien Vergé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from yamllint.linter import PROBLEM_LEVELS + + +def format_results(results, no_warn): + max_level = 0 + + for file in results: + for problem in results[file]: + max_level = max(max_level, PROBLEM_LEVELS[problem.level]) + if no_warn and (problem.level != 'error'): + continue + + print(format_problem(problem, file)) + + return max_level + + +def format_problem(problem, filename): + return ('%(file)s:%(line)s:%(column)s: [%(level)s] %(message)s' % + {'file': filename, + 'line': problem.line, + 'column': problem.column, + 'level': problem.level, + 'message': problem.message}) diff --git a/yamllint/formatters/sarif.py b/yamllint/formatters/sarif.py new file mode 100644 index 0000000..9980be4 --- /dev/null +++ b/yamllint/formatters/sarif.py @@ -0,0 +1,120 @@ +# Copyright (C) 2016 Adrien Vergé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json + +from yamllint import APP_VERSION +from yamllint.linter import PROBLEM_LEVELS + + +def format_results(results, no_warn): + max_level = 0 + + sarif = { + '$schema': 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec' + '/master/Schemata/sarif-schema-2.1.0.json', + 'version': '2.1.0', + 'runs': [ + { + 'tool': { + 'driver': { + 'name': 'yamllint', + 'version': APP_VERSION, + 'informationUri': 'https://yamllint.readthedocs.io', + 'rules': [] + }, + }, + 'results': [] + } + ] + } + + rules = {} + max_rule_index = 0 + + for file in results: + for problem in results[file]: + max_level = max(max_level, PROBLEM_LEVELS[problem.level]) + + if problem.rule in rules: + rule_index = rules[problem.rule] + else: + rule_index = max_rule_index + rules[problem.rule] = max_rule_index + sarif['runs'][0]['tool']['driver']['rules'].append(format_rule( + problem)) + max_rule_index += 1 + + sarif['runs'][0]['results'].append(format_result(rule_index, + problem, file)) + + print(json.dumps(sarif)) + + return max_level + + +def format_rule(problem): + uri = 'https://yamllint.readthedocs.io/en/v%s/rules.html#module-yamllint' \ + '.rules.%s' % (APP_VERSION, problem.rule.replace('-', '_')) + + name = ''.join([word.capitalize() for word in problem.rule.split('-')]) + + return { + 'id': problem.rule, + 'name': name, + 'defaultConfiguration': { + 'level': problem.level + }, + 'properties': { + 'description': problem.desc, + 'tags': [], + 'queryUri': uri, + }, + 'shortDescription': { + 'text': problem.desc + }, + 'fullDescription': { + 'text': problem.desc + }, + 'helpUri': uri, + 'help': { + 'text': 'More info: {}'.format(uri), + 'markdown': '[More info]({})'.format(uri) + } + } + + +def format_result(rule_index, problem, filename): + return { + 'ruleId': problem.rule, + 'ruleIndex': rule_index, + 'message': { + 'text': problem.message + }, + 'locations': [ + { + 'physicalLocation': { + 'artifactLocation': { + 'uri': filename, + 'uriBaseId': '%SRCROOT%' + }, + 'region': { + 'startLine': problem.line, + 'startColumn': problem.column + } + } + } + ] + } diff --git a/yamllint/formatters/standard.py b/yamllint/formatters/standard.py new file mode 100644 index 0000000..7b256eb --- /dev/null +++ b/yamllint/formatters/standard.py @@ -0,0 +1,45 @@ +# Copyright (C) 2016 Adrien Vergé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from yamllint.linter import PROBLEM_LEVELS + + +def format_results(results, no_warn): + max_level = 0 + + for file in results: + print(file) + + for problem in results[file]: + max_level = max(max_level, PROBLEM_LEVELS[problem.level]) + if no_warn and (problem.level != 'error'): + continue + + print(format_problem(problem)) + + print('') + + return max_level + + +def format_problem(problem): + line = ' %d:%d' % (problem.line, problem.column) + line += max(12 - len(line), 0) * ' ' + line += problem.level + line += max(21 - len(line), 0) * ' ' + line += problem.desc + if problem.rule: + line += ' (%s)' % problem.rule + return line diff --git a/yamllint/linter.py b/yamllint/linter.py index 5501bb5..3dc7888 100644 --- a/yamllint/linter.py +++ b/yamllint/linter.py @@ -180,7 +180,8 @@ def get_syntax_error(buffer): except yaml.error.MarkedYAMLError as e: problem = LintProblem(e.problem_mark.line + 1, e.problem_mark.column + 1, - 'syntax error: ' + e.problem + ' (syntax)') + 'syntax error: ' + e.problem, + 'syntax') problem.level = 'error' return problem