├── requirements.txt ├── clang_tidy_converter ├── __init__.py ├── parser │ ├── __init__.py │ └── clang_tidy_parser.py ├── formatter │ ├── __init__.py │ ├── sarif_formatter.py │ ├── sonarqube_formatter.py │ ├── code_climate_formatter.py │ └── html_report_formatter.py └── __main__.py ├── setup.py ├── LICENSE ├── README.md ├── .gitignore └── tests ├── test_clang_tidy_parser.py └── test_code_climate_formatting.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=3.0.0 2 | -------------------------------------------------------------------------------- /clang_tidy_converter/__init__.py: -------------------------------------------------------------------------------- 1 | from .formatter import * 2 | from .parser import * 3 | -------------------------------------------------------------------------------- /clang_tidy_converter/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .clang_tidy_parser import ClangTidyParser, ClangMessage 2 | -------------------------------------------------------------------------------- /clang_tidy_converter/formatter/__init__.py: -------------------------------------------------------------------------------- 1 | from .code_climate_formatter import CodeClimateFormatter 2 | from .html_report_formatter import HTMLReportFormatter 3 | from .sonarqube_formatter import SonarQubeFormatter 4 | from .sarif_formatter import SarifFormatter 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | "Setup script" 2 | from setuptools import setup, find_packages 3 | 4 | 5 | def _requirements(): 6 | with open("requirements.txt") as req_file: 7 | return req_file.read().splitlines() 8 | 9 | 10 | def _readme(): 11 | with open('README.md') as readme_file: 12 | return readme_file.read() 13 | 14 | 15 | setup( 16 | name="clang_tidy_converter", 17 | url="https://github.com/yuriisk/clang-tidy-converter", 18 | version="1.0.0", 19 | packages=find_packages(), 20 | author="Yurii Skatarenko", 21 | author_email="yurii.skatarenko@gmail.com", 22 | description="Python3 script to convert Clang-Tidy output to different formats.", 23 | long_description=_readme(), 24 | long_description_content_type='text/markdown', 25 | keywords="", 26 | license="MIT", 27 | platforms=["any"], 28 | python_requires='>=3.5', 29 | install_requires=_requirements(), 30 | setup_requires=['pytest-runner', 'wheel'], 31 | tests_require=['pytest'], 32 | classifiers=[], 33 | ) 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 yuriisk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clang-Tidy Converter 2 | 3 | Python3 script to convert Clang-Tidy output to different formats. 4 | Supported formats are [Code Climate JSON](https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#issue) and HTML report similar to `scan-build` utility. 5 | 6 | ## Usage 7 | 8 | `python3 -m clang_tidy_converter [-h] [-r PROJECT_ROOT] FORMAT ...` 9 | 10 | Reads Clang-Tidy output from `STDIN` and prints it in selected format to `STDOUT`. 11 | 12 | ### Arguments 13 | 14 | Optional arguments: 15 | * `-h, --help` - show help message and exit. 16 | * `-r PROJECT_ROOT, --project_root PROJECT_ROOT` - output file paths relative to `PROJECT_ROOT`. 17 | 18 | Output format: 19 | * `cc` - Code Climate JSON. 20 | * `html` - HTML report. 21 | 22 | Optinal arguments for Code Climate format: 23 | * `-h, --help` - show help message and exit. 24 | * `-l, --use_location_lines` - use _line-based_ locations instead of _position-based_ as defined in _Locations_ section of Code Climate specification. 25 | * `-j, --as_json_array` - output as JSON array instead of ending each issue with \0. 26 | 27 | Optional arguments for HTML report format: 28 | * `-h, --help` - show help message and exit. 29 | * `-s SOFTWARE_NAME, --software_name SOFTWARE_NAME` - software name to display in generated report. 30 | 31 | ## Example 32 | 33 | GitLab code quality report is a JSON file that implements a subset of the Code Climate specification, so this script can be used to convert Clang-Tidy output to GitLab code quality report. The following command does it: 34 | 35 | ```bash 36 | clang-tidy /path/to/my/project/file.cpp \ 37 | | python3 -m clang_tidy_converter --project_root /path/to/my/project \ 38 | cc --use_location_lines --as_json_array \ 39 | > gl-code-quality-report.json 40 | ``` 41 | -------------------------------------------------------------------------------- /clang_tidy_converter/formatter/sarif_formatter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | 5 | from ..parser import ClangMessage 6 | 7 | 8 | class SarifFormatter: 9 | """ 10 | Follows the SARIF format as used by SonarQube 11 | https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/importing-external-issues/importing-issues-from-sarif-reports/ 12 | """ 13 | 14 | def format(self, messages, args): 15 | return json.dumps({ 16 | "version": "2.1.0", 17 | "runs": [{ 18 | "tool": {"driver": {"name": "clang-tidy"}}, 19 | "results": [self._format_message(msg, args) for msg in messages] 20 | }] 21 | }, indent=2) 22 | 23 | def _format_message(self, message: ClangMessage, args): 24 | return { 25 | "message": {"text": message.message}, 26 | "ruleId": message.diagnostic_name, 27 | "locations": [self._format_location(msg, args) for msg in [ 28 | message, *message.children]], 29 | "level": self._convert_level(message.level), 30 | } 31 | 32 | def _format_location(self, message, args): 33 | return { 34 | "message": message.message, 35 | "artifactLocation": {"uri": "file://" + message.filepath}, 36 | "region": { 37 | "startLine": message.line, 38 | "startColumn": message.column, 39 | }, 40 | } 41 | 42 | def _convert_level(self, level, default=""): 43 | return { 44 | ClangMessage.Level.NOTE: "none", 45 | ClangMessage.Level.REMARK: "note", 46 | ClangMessage.Level.WARNING: "warning", 47 | ClangMessage.Level.ERROR: "error", 48 | ClangMessage.Level.FATAL: "error", 49 | }.get(level, default) 50 | -------------------------------------------------------------------------------- /clang_tidy_converter/formatter/sonarqube_formatter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | 5 | from ..parser import ClangMessage 6 | 7 | 8 | class SonarQubeFormatter: 9 | """ 10 | The JSON format used to import external issues into SonarQube 11 | https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/importing-external-issues/generic-issue-import-format/ 12 | """ 13 | 14 | def format(self, messages, args): 15 | return json.dumps({"issues": [self._format_message(msg, args) for msg in messages]}, indent=2) 16 | 17 | def _format_message(self, message: ClangMessage, args): 18 | return { 19 | "engineId": "clang-tidy", # String 20 | "ruleId": message.diagnostic_name, # String 21 | "primaryLocation": self._format_location(message, args), # Location object 22 | "type": "CODE_SMELL", # String. One of BUG, VULNERABILITY, CODE_SMELL 23 | "severity": self._level_to_severity(message.level), # String. One of BLOCKER, CRITICAL, MAJOR, MINOR, INFO 24 | # "effortMinutes": "", # Integer, optional. Defaults to 0 25 | "secondaryLocations": [self._format_location(msg, args) for msg in message.children], # Array of Location objects, optional 26 | # "_details": message.details_lines 27 | } 28 | 29 | def _format_location(self, message, args): 30 | range = { 31 | "startLine": message.line, 32 | "endLine": message.line, 33 | } 34 | if message.column > 0: 35 | range["startColumn"] = message.column - 1 36 | range["endColumn"] = message.column 37 | else: 38 | range["startColumn"] = 0 39 | range["endColumn"] = 1 40 | return { 41 | "message": message.message, 42 | "filePath": message.filepath, 43 | "textRange": range, 44 | } 45 | 46 | def _level_to_severity(self, level, default="BLOCKER"): 47 | return { 48 | ClangMessage.Level.NOTE: "INFO", 49 | ClangMessage.Level.REMARK: "MINOR", 50 | ClangMessage.Level.WARNING: "MAJOR", 51 | ClangMessage.Level.ERROR: "CRITICAL", 52 | ClangMessage.Level.FATAL: "BLOCKER", 53 | }.get(level, default) 54 | -------------------------------------------------------------------------------- /clang_tidy_converter/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .formatter import CodeClimateFormatter, HTMLReportFormatter, SonarQubeFormatter, SarifFormatter 4 | from .parser import ClangTidyParser 5 | from argparse import ArgumentParser 6 | import os 7 | import sys 8 | 9 | def create_argparser(): 10 | p = ArgumentParser(description='Reads Clang-Tidy output from STDIN and prints it in selected format to STDOUT.') 11 | p.add_argument('-r', '--project_root', default='', help='output file paths relative to PROJECT_ROOT') 12 | 13 | sub = p.add_subparsers(title="output format", dest='output_format', metavar="FORMAT", required=True) 14 | 15 | cc = sub.add_parser("cc", help="Code Climate JSON") 16 | cc.add_argument('-l', '--use_location_lines', action='store_const', const=True, default=False, 17 | help='use line-based locations instead of position-based as defined in Locations section of Code Climate specification') 18 | cc.add_argument('-j', '--as_json_array', action='store_const', const=True, default=False, 19 | help='output as JSON array instead of ending each issue with \\0') 20 | 21 | html = sub.add_parser("html", help="HTML report") 22 | html.add_argument('-s', '--software_name', default='', help='software name to display in generated report') 23 | 24 | sq = sub.add_parser("sq", help="SonarQube JSON") 25 | sarif = sub.add_parser("sarif", help="SARIF JSON") 26 | 27 | return p 28 | 29 | def main(args): 30 | parser = ClangTidyParser() 31 | messages = parser.parse(sys.stdin.readlines()) 32 | 33 | if len(args.project_root) > 0: 34 | convert_paths_to_relative(messages, args.project_root) 35 | 36 | if args.output_format == 'cc': 37 | formatter = CodeClimateFormatter() 38 | elif args.output_format == 'sarif': 39 | formatter = SarifFormatter() 40 | elif args.output_format == 'sq': 41 | formatter = SonarQubeFormatter() 42 | else: 43 | formatter = HTMLReportFormatter() 44 | 45 | print(formatter.format(messages, args)) 46 | 47 | def convert_paths_to_relative(messages, root_dir): 48 | for message in messages: 49 | message.filepath = os.path.relpath(message.filepath, root_dir) 50 | convert_paths_to_relative(message.children, root_dir) 51 | 52 | if __name__ == "__main__": 53 | main(create_argparser().parse_args()) 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /clang_tidy_converter/parser/clang_tidy_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from enum import Enum 4 | import re 5 | 6 | class ClangMessage: 7 | class Level(Enum): 8 | UNKNOWN = 0 9 | NOTE = 1 10 | REMARK = 2 11 | WARNING = 3 12 | ERROR = 4 13 | FATAL = 5 14 | 15 | def __init__(self, filepath=None, line=-1, column=-1, level=Level.UNKNOWN, message=None, diagnostic_name=None, details_lines=None, children=None): 16 | self.filepath = filepath if filepath is not None else '' 17 | self.line = line 18 | self.column = column 19 | self.level = level 20 | self.message = message if message is not None else '' 21 | self.diagnostic_name = diagnostic_name if diagnostic_name is not None else '' 22 | self.details_lines = details_lines if details_lines is not None else [] 23 | self.children = children if children is not None else [] 24 | 25 | @staticmethod 26 | def levelFromString(levelString): 27 | if levelString == 'note': 28 | return ClangMessage.Level.NOTE 29 | if levelString == 'remark': 30 | return ClangMessage.Level.REMARK 31 | if levelString == 'warning': 32 | return ClangMessage.Level.WARNING 33 | if levelString == 'error': 34 | return ClangMessage.Level.ERROR 35 | if levelString == 'fatal': 36 | return ClangMessage.Level.FATAL 37 | return ClangMessage.Level.UNKNOWN 38 | 39 | class ClangTidyParser: 40 | MESSAGE_REGEX = re.compile(r"^(?P.+):(?P\d+):(?P\d+): (?P\S+): (?P.*?)( \[(?P.*)\])?$") 41 | IGNORE_REGEX = re.compile(r"^error:.*$") 42 | 43 | def __init__(self): 44 | pass 45 | 46 | def parse(self, lines): 47 | messages = [] 48 | for line in lines: 49 | if self._is_ignored(line): 50 | continue 51 | message = self._parse_message(line) 52 | if message is None or message.level == ClangMessage.Level.UNKNOWN: 53 | if messages: 54 | messages[-1].details_lines.append(line) 55 | else: 56 | continue 57 | else: 58 | messages.append(message) 59 | return self._group_messages(messages) 60 | 61 | def _parse_message(self, line): 62 | regex_res = self.MESSAGE_REGEX.match(line) 63 | if regex_res is not None: 64 | return ClangMessage( 65 | filepath=regex_res.group('filepath'), 66 | line=int(regex_res.group('line')), 67 | column=int(regex_res.group('column')), 68 | level=ClangMessage.levelFromString(regex_res.group('level')), 69 | message=regex_res.group('message'), 70 | diagnostic_name=regex_res.group('diagnostic_name') 71 | ) 72 | return None 73 | 74 | def _is_ignored(self, line): 75 | return self.IGNORE_REGEX.match(line) is not None 76 | 77 | def _group_messages(self, messages): 78 | groupped_messages = [] 79 | for msg in messages: 80 | if msg.level == ClangMessage.Level.NOTE: 81 | groupped_messages[-1].children.append(msg) 82 | else: 83 | groupped_messages.append(msg) 84 | return groupped_messages 85 | -------------------------------------------------------------------------------- /tests/test_clang_tidy_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import unittest 4 | 5 | from clang_tidy_converter import ClangTidyParser, ClangMessage 6 | 7 | class ClangTidyParserTest(unittest.TestCase): 8 | def test_warning_message(self): 9 | parser = ClangTidyParser() 10 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: warning: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]']) 11 | self.assertEqual(1, len(messages)) 12 | msg = messages[0] 13 | self.assertEqual('/usr/lib/include/some_include.h', msg.filepath) 14 | self.assertEqual(1039, msg.line) 15 | self.assertEqual(3, msg.column) 16 | self.assertEqual(ClangMessage.Level.WARNING, msg.level) 17 | self.assertEqual('Potential memory leak', msg.message) 18 | self.assertEqual('clang-analyzer-cplusplus.NewDeleteLeaks', msg.diagnostic_name) 19 | self.assertEqual([], msg.details_lines) 20 | self.assertEqual([], msg.children) 21 | 22 | def test_remark_message_level(self): 23 | parser = ClangTidyParser() 24 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: remark: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]']) 25 | msg = messages[0] 26 | self.assertEqual(ClangMessage.Level.REMARK, msg.level) 27 | 28 | def test_error_message_level(self): 29 | parser = ClangTidyParser() 30 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: error: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]']) 31 | msg = messages[0] 32 | self.assertEqual(ClangMessage.Level.ERROR, msg.level) 33 | 34 | def test_fatal_message_level(self): 35 | parser = ClangTidyParser() 36 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: fatal: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]']) 37 | msg = messages[0] 38 | self.assertEqual(ClangMessage.Level.FATAL, msg.level) 39 | 40 | def test_unknown_message_level(self): 41 | parser = ClangTidyParser() 42 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: fatal: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]', 43 | '/usr/lib/include/some_include.h:1039:3: smth: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]']) 44 | self.assertEqual(1, len(messages)) 45 | msg = messages[0] 46 | self.assertEqual(['/usr/lib/include/some_include.h:1039:3: smth: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]'], msg.details_lines) 47 | 48 | def test_multiline_warning_message(self): 49 | parser = ClangTidyParser() 50 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: warning: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]', 51 | ' return new SomeFunction(', 52 | ' ^']) 53 | self.assertEqual(1, len(messages)) 54 | msg = messages[0] 55 | self.assertEqual('/usr/lib/include/some_include.h', msg.filepath) 56 | self.assertEqual(1039, msg.line) 57 | self.assertEqual(3, msg.column) 58 | self.assertEqual(ClangMessage.Level.WARNING, msg.level) 59 | self.assertEqual('Potential memory leak', msg.message) 60 | self.assertEqual('clang-analyzer-cplusplus.NewDeleteLeaks', msg.diagnostic_name) 61 | self.assertEqual([' return new SomeFunction(', 62 | ' ^'], msg.details_lines) 63 | self.assertEqual([], msg.children) 64 | 65 | def test_warning_message_children(self): 66 | parser = ClangTidyParser() 67 | messages = parser.parse(['/usr/lib/include/some_include.h:1039:3: warning: Potential memory leak [clang-analyzer-cplusplus.NewDeleteLeaks]', 68 | ' return new SomeFunction(', 69 | ' ^', 70 | '/home/user/some_source.cpp:267:15: note: Calling \'OtherFunction\'', 71 | ' auto sf = OtherFunction( a, b, c );', 72 | ' ^']) 73 | self.assertEqual(1, len(messages)) 74 | msg = messages[0] 75 | self.assertEqual('/usr/lib/include/some_include.h', msg.filepath) 76 | self.assertEqual(1039, msg.line) 77 | self.assertEqual(3, msg.column) 78 | self.assertEqual(ClangMessage.Level.WARNING, msg.level) 79 | self.assertEqual('Potential memory leak', msg.message) 80 | self.assertEqual('clang-analyzer-cplusplus.NewDeleteLeaks', msg.diagnostic_name) 81 | self.assertEqual([' return new SomeFunction(', 82 | ' ^'], msg.details_lines) 83 | self.assertEqual(1, len(msg.children)) 84 | child = msg.children[0] 85 | self.assertEqual('/home/user/some_source.cpp', child.filepath) 86 | self.assertEqual(267, child.line) 87 | self.assertEqual(15, child.column) 88 | self.assertEqual(ClangMessage.Level.NOTE, child.level) 89 | self.assertEqual('Calling \'OtherFunction\'', child.message) 90 | self.assertEqual('', child.diagnostic_name) 91 | self.assertEqual([' auto sf = OtherFunction( a, b, c );', 92 | ' ^'], child.details_lines) 93 | self.assertEqual([], child.children) 94 | 95 | def test_ignorance_of_generic_errors(self): 96 | parser = ClangTidyParser() 97 | messages = parser.parse(['error: -mapcs-frame not supported']) 98 | self.assertEqual([], messages) 99 | 100 | if __name__ == '__main__': 101 | unittest.main() 102 | -------------------------------------------------------------------------------- /clang_tidy_converter/formatter/code_climate_formatter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import hashlib 5 | 6 | from ..parser import ClangMessage 7 | 8 | def remove_duplicates(l): 9 | return list(set(l)) 10 | 11 | class CodeClimateFormatter: 12 | def __init__(self): 13 | pass 14 | 15 | def format(self, messages, args): 16 | if args.as_json_array: 17 | return json.dumps([self._format_message(msg, args) for msg in messages], indent=2) 18 | else: 19 | return ''.join(json.dumps(self._format_message(msg, args), indent=2) + '\0\n' for msg in messages) 20 | 21 | def _format_message(self, message, args): 22 | return { 23 | 'type': 'issue', 24 | 'check_name': message.diagnostic_name, 25 | 'description': message.message, 26 | 'content': self._extract_content(message, args), 27 | 'categories': self._extract_categories(message, args), 28 | 'location': self._extract_location(message, args), 29 | 'trace': self._extract_trace(message, args), 30 | 'severity': self._extract_severity(message, args), 31 | 'fingerprint': self._generate_fingerprint(message) 32 | } 33 | 34 | def _extract_content(self, message, args): 35 | return { 36 | 'body': '\n'.join(['```'] + message.details_lines + self._messages_to_text(message.children) + ['```']) 37 | } 38 | 39 | def _messages_to_text(self, messages): 40 | text_lines = [] 41 | for message in messages: 42 | text_lines.append(f'{message.filepath}:{message.line}:{message.column}: {message.message}') 43 | text_lines.extend(message.details_lines) 44 | text_lines.extend(self._messages_to_text(message.children)) 45 | return text_lines 46 | 47 | def _extract_categories(self, message, args): 48 | BUGRISC_CATEGORY='Bug Risk' 49 | CLARITY_CATEGORY='Clarity' 50 | COMPATIBILITY_CATEGORY='Compatibility' 51 | COMPLEXITY_CATEGORY='Complexity' 52 | DUPLICATION_CATEGORY='Duplication' 53 | PERFORMANCE_CATEGORY='Performance' 54 | SECURITY_CATEGORY='Security' 55 | STYLE_CATEGORY='Style' 56 | 57 | categories = [] 58 | if 'bugprone' in message.diagnostic_name: 59 | categories.append(BUGRISC_CATEGORY) 60 | if 'modernize' in message.diagnostic_name: 61 | categories.append(COMPATIBILITY_CATEGORY) 62 | if 'portability' in message.diagnostic_name: 63 | categories.append(COMPATIBILITY_CATEGORY) 64 | if 'performance' in message.diagnostic_name: 65 | categories.append(PERFORMANCE_CATEGORY) 66 | if 'readability' in message.diagnostic_name: 67 | categories.append(CLARITY_CATEGORY) 68 | if 'cloexec' in message.diagnostic_name: 69 | categories.append(SECURITY_CATEGORY) 70 | if 'security' in message.diagnostic_name: 71 | categories.append(SECURITY_CATEGORY) 72 | if 'naming' in message.diagnostic_name: 73 | categories.append(STYLE_CATEGORY) 74 | if 'misc' in message.diagnostic_name: 75 | categories.append(STYLE_CATEGORY) 76 | if 'cppcoreguidelines' in message.diagnostic_name: 77 | categories.append(STYLE_CATEGORY) 78 | if 'hicpp' in message.diagnostic_name: 79 | categories.append(STYLE_CATEGORY) 80 | if 'simplify' in message.diagnostic_name: 81 | categories.append(COMPLEXITY_CATEGORY) 82 | if 'redundant' in message.diagnostic_name: 83 | categories.append(DUPLICATION_CATEGORY) 84 | if message.diagnostic_name.startswith('boost-use-to-string'): 85 | categories.append(COMPATIBILITY_CATEGORY) 86 | if len(categories) == 0: 87 | categories.append(BUGRISC_CATEGORY) 88 | return remove_duplicates(categories) 89 | 90 | def _extract_trace(self, message, args): 91 | return { 92 | 'locations': self._extract_other_locations(message, args) 93 | } 94 | 95 | def _extract_other_locations(self, message, args): 96 | locations_list = [] 97 | for child in message.children: 98 | locations_list.append(self._extract_location(child, args)) 99 | locations_list.extend(self._extract_other_locations(child, args)) 100 | return locations_list 101 | 102 | def _extract_location(self, message, args): 103 | location = { 104 | 'path': message.filepath, 105 | } 106 | if args.use_location_lines: 107 | location['lines'] = { 108 | 'begin': message.line 109 | } 110 | else: 111 | location['positions'] = { 112 | 'begin': { 113 | 'line': message.line, 114 | 'column': message.column 115 | } 116 | } 117 | return location 118 | 119 | def _extract_severity(self, message, args): 120 | if message.level == ClangMessage.Level.NOTE: 121 | return 'info' 122 | if message.level == ClangMessage.Level.REMARK: 123 | return 'minor' 124 | if message.level == ClangMessage.Level.WARNING: 125 | return 'major' 126 | if message.level == ClangMessage.Level.ERROR: 127 | return 'critical' 128 | if message.level == ClangMessage.Level.FATAL: 129 | return 'blocker' 130 | 131 | def _generate_fingerprint(self, message): 132 | h = hashlib.md5() 133 | h.update(message.filepath.encode('utf8')) 134 | h.update(str(message.line).encode('utf8')) 135 | h.update(str(message.column).encode('utf8')) 136 | h.update(message.message.encode('utf8')) 137 | h.update(message.diagnostic_name.encode('utf8')) 138 | for child in message.children: 139 | h.update(self._generate_fingerprint(child).encode('utf-8')) 140 | return h.hexdigest() 141 | -------------------------------------------------------------------------------- /clang_tidy_converter/formatter/html_report_formatter.py: -------------------------------------------------------------------------------- 1 | from ..parser import ClangMessage 2 | 3 | from collections import defaultdict 4 | from datetime import date 5 | import html 6 | import re 7 | 8 | 9 | NEWLINE = '\n'; 10 | 11 | 12 | class HTMLReportFormatter: 13 | def __init__(self): 14 | pass 15 | 16 | def format(self, messages, args): 17 | by_level = _group_messages(messages) 18 | 19 | title = "Static Analysis Results" 20 | if len(args.software_name) > 0: 21 | title = f"{args.software_name} - {title}" 22 | 23 | return f""" 24 | 25 | {_style()} 26 | {_script()} 27 | {title} 28 | 29 | 30 |

{title}

31 | 32 | 33 | 34 | 35 |
Date:{date.today()}
36 | 37 |

Bug Summary

38 | 39 | 40 | 41 | {NEWLINE.join(_format_level_group(level, msgs) for level, msgs in by_level.items())} 42 | 43 |
Diagnostic NameQuantityDisplay?
44 | 45 |

Reports

46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {NEWLINE.join(_format_message(msg) for msg in messages)} 58 | 59 |
Bug SeverityDiagnostic NameBug DescriptionFileLineColumnNotes
60 | 61 | 62 | """ 63 | 64 | 65 | def _group_messages(messages): 66 | groupped = _group_messages_by_level(messages) 67 | return {k: _group_messages_by_diagnostic_name(m) for k, m in groupped.items()} 68 | 69 | 70 | def _group_messages_by_level(messages): 71 | groupped = defaultdict(list) 72 | for m in messages: 73 | groupped[m.level].append(m) 74 | return groupped 75 | 76 | 77 | def _group_messages_by_diagnostic_name(messages): 78 | groupped = defaultdict(list) 79 | for m in messages: 80 | groupped[m.diagnostic_name].append(m) 81 | return groupped 82 | 83 | 84 | def _format_level_group(level, messages): 85 | return f""" 86 | {_level_name(level)} 87 | {sum(len(msgs) for msgs in messages.values())} 88 |
89 | 90 | {NEWLINE.join(_format_diagnostic_group(level, name, msgs) for name, msgs in messages.items())}""" 91 | 92 | 93 | def _level_name(level): 94 | if level == ClangMessage.Level.NOTE: 95 | return "Note" 96 | elif level == ClangMessage.Level.REMARK: 97 | return "Remark" 98 | elif level == ClangMessage.Level.WARNING: 99 | return "Warning" 100 | elif level == ClangMessage.Level.ERROR: 101 | return "Error" 102 | elif level == ClangMessage.Level.FATAL: 103 | return "Fatal" 104 | else: 105 | return "Unknown" 106 | 107 | 108 | def _format_diagnostic_group(level, diagnostic_name, messages): 109 | return f""" 110 | {diagnostic_name} 111 | {len(messages)} 112 |
113 | """ 114 | 115 | 116 | def _mangle_group(level, diagnostic_name): 117 | diagnostic_name = re.sub(r"[,.;@#?!&$]+\ *", " ", diagnostic_name) 118 | diagnostic_name = re.sub(r"\s+", "_", diagnostic_name) 119 | return f'bt_{_level_name(level)}_{diagnostic_name}'.lower() 120 | 121 | 122 | def _format_message(message): 123 | return f""" 124 | {_level_name(message.level)} 125 | {message.diagnostic_name} 126 | {html.escape(message.message, quote=True)}{message.filepath} 127 | {message.line}{message.column} 128 | 129 | """ 130 | 131 | 132 | def _style(): 133 | return """""" 183 | 184 | 185 | def _script(): 186 | return """""" 260 | -------------------------------------------------------------------------------- /tests/test_code_climate_formatting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import unittest 3 | import unittest.mock 4 | import json 5 | 6 | from clang_tidy_converter import CodeClimateFormatter, ClangMessage 7 | 8 | class CodeClimateFormatterTest(unittest.TestCase): 9 | def test_format(self): 10 | child1 = ClangMessage('/some/file/path1.cpp', 8, 10, ClangMessage.Level.NOTE, 'Allocated here', '', ['return new A;', ' ^']) 11 | msg = ClangMessage('/some/file/path.cpp', 100, 2, ClangMessage.Level.WARNING, 'Memory leak', 'bugprone-undefined-memory-manipulation.SomethingWrong', 12 | ['void a(int)', ' ^'], [child1]) 13 | formatter = CodeClimateFormatter() 14 | args = unittest.mock.Mock() 15 | args.use_location_lines = True 16 | self.assertEqual( 17 | """{ 18 | "type": "issue", 19 | "check_name": "bugprone-undefined-memory-manipulation.SomethingWrong", 20 | "description": "Memory leak", 21 | "content": { 22 | "body": "```\\nvoid a(int)\\n ^\\n/some/file/path1.cpp:8:10: Allocated here\\nreturn new A;\\n ^\\n```" 23 | }, 24 | "categories": [ 25 | "Bug Risk" 26 | ], 27 | "location": { 28 | "path": "/some/file/path.cpp", 29 | "lines": { 30 | "begin": 100 31 | } 32 | }, 33 | "trace": { 34 | "locations": [ 35 | { 36 | "path": "/some/file/path1.cpp", 37 | "lines": { 38 | "begin": 8 39 | } 40 | } 41 | ] 42 | }, 43 | "severity": "major", 44 | "fingerprint": "f2f6ccb970f2259d10e525b4b5805a5c" 45 | }\0 46 | """, formatter.format([msg], args)) 47 | 48 | def test_extract_content(self): 49 | child1 = ClangMessage('/some/file/path1.cpp', 8, 10, ClangMessage.Level.NOTE, 'Allocated here', '', ['return new A;', ' ^']) 50 | msg = ClangMessage('/some/file/path.cpp', 100, 2, ClangMessage.Level.WARNING, 'Memory leak', 'bugprone-undefined-memory-manipulation.SomethingWrong', 51 | ['void a(int)', ' ^'], [child1]) 52 | formatter = CodeClimateFormatter() 53 | self.assertEqual({ 54 | 'body': '\n'.join([ 55 | '```', 56 | 'void a(int)', 57 | ' ^', 58 | '/some/file/path1.cpp:8:10: Allocated here', 59 | 'return new A;', 60 | ' ^', 61 | '```']) 62 | }, formatter._extract_content(msg, object())) 63 | 64 | def test_extract_bug_risk_category(self): 65 | self._test_diagnostic_category('bugprone-use-after-move', 'Bug Risk') 66 | 67 | def test_extract_compatibility_category_1(self): 68 | self._test_diagnostic_category('modernize-replace-auto-ptr', 'Compatibility') 69 | 70 | def test_extract_compatibility_category_2(self): 71 | self._test_diagnostic_category('portability-restrict-system-includes', 'Compatibility') 72 | 73 | def test_extract_compatibility_category_3(self): 74 | self._test_diagnostic_category('boost-use-to-string', 'Compatibility') 75 | 76 | def test_extract_performance_category(self): 77 | self._test_diagnostic_category('performance-inefficient-algorithm', 'Performance') 78 | 79 | def test_extract_clarity_category_1(self): 80 | self._test_diagnostic_category('google-readability-avoid-underscore-in-googletest-name', 'Clarity') 81 | 82 | def test_extract_clarity_category_2(self): 83 | self._test_diagnostic_category('readability-misplaced-array-index', 'Clarity') 84 | 85 | def test_extract_security_category_1(self): 86 | self._test_diagnostic_category('android-cloexec-open', 'Security') 87 | 88 | def test_extract_security_category_2(self): 89 | self._test_diagnostic_category('clang-analyzer-security.insecureAPI.bcmp', 'Security') 90 | 91 | def test_extract_style_category_1(self): 92 | self._test_diagnostic_category('readability-identifier-naming', 'Style') 93 | 94 | def test_extract_style_category_2(self): 95 | self._test_diagnostic_category('cppcoreguidelines-avoid-goto', 'Style') 96 | 97 | def test_extract_style_category_3(self): 98 | self._test_diagnostic_category('hicpp-no-assembler', 'Style') 99 | 100 | def test_extract_complexity_category(self): 101 | self._test_diagnostic_category('readability-simplify-boolean-expr', 'Complexity') 102 | 103 | def test_extract_duplication_category(self): 104 | self._test_diagnostic_category('misc-redundant-expression', 'Duplication') 105 | 106 | def test_extract_default_category(self): 107 | self._test_diagnostic_category('cert-dcl16-c', 'Bug Risk') 108 | 109 | def _test_diagnostic_category(self, diagnostic, category): 110 | msg = ClangMessage(diagnostic_name=diagnostic) 111 | formatter = CodeClimateFormatter() 112 | self.assertIn(category, formatter._extract_categories(msg, object())) 113 | 114 | def test_extract_duplicated_categories(self): 115 | msg = ClangMessage(diagnostic_name='cppcoreguidelines-readability-avoid-goto') 116 | formatter = CodeClimateFormatter() 117 | categories = formatter._extract_categories(msg, object()) 118 | self.assertEqual(2, len(categories)) 119 | self.assertIn('Style', categories) 120 | self.assertIn('Clarity', categories) 121 | 122 | def test_extract_trace_lines(self): 123 | child1 = ClangMessage('/some/file/path1.cpp', 8, 10) 124 | msg = ClangMessage('/some/file/path.cpp', 100, 2, children=[child1]) 125 | formatter = CodeClimateFormatter() 126 | args = unittest.mock.Mock() 127 | args.use_location_lines = True 128 | self.assertEqual({ 129 | 'locations': [ 130 | { 131 | 'path': '/some/file/path1.cpp', 132 | 'lines': { 133 | 'begin': 8 134 | } 135 | } 136 | ] 137 | }, formatter._extract_trace(msg, args)) 138 | 139 | def test_extract_trace_positions(self): 140 | child1 = ClangMessage('/some/file/path1.cpp', 8, 10) 141 | msg = ClangMessage('/some/file/path.cpp', 100, 2, children=[child1]) 142 | formatter = CodeClimateFormatter() 143 | args = unittest.mock.Mock() 144 | args.use_location_lines = False 145 | self.assertEqual({ 146 | 'locations': [ 147 | { 148 | 'path': '/some/file/path1.cpp', 149 | 'positions': { 150 | 'begin': { 151 | 'line': 8, 152 | 'column': 10 153 | } 154 | } 155 | } 156 | ] 157 | }, formatter._extract_trace(msg, args)) 158 | 159 | def test_extract_location_lines(self): 160 | msg = ClangMessage('/some/file/path.cpp', 100, 2) 161 | formatter = CodeClimateFormatter() 162 | args = unittest.mock.Mock() 163 | args.use_location_lines = True 164 | self.assertEqual({ 165 | 'path': '/some/file/path.cpp', 166 | 'lines': { 167 | 'begin': 100 168 | } 169 | }, formatter._extract_location(msg, args)) 170 | 171 | def test_extract_location_positions(self): 172 | msg = ClangMessage('/some/file/path.cpp', 100, 2) 173 | formatter = CodeClimateFormatter() 174 | args = unittest.mock.Mock() 175 | args.use_location_lines = False 176 | self.assertEqual({ 177 | 'path': '/some/file/path.cpp', 178 | 'positions': { 179 | 'begin': { 180 | 'line': 100, 181 | 'column': 2 182 | } 183 | } 184 | }, formatter._extract_location(msg, args)) 185 | 186 | def test_extracting_note_severity(self): 187 | self._test_extracting_severity(ClangMessage.Level.NOTE, 'info') 188 | 189 | def test_extracting_remark_severity(self): 190 | self._test_extracting_severity(ClangMessage.Level.REMARK, 'minor') 191 | 192 | def test_extracting_warning_severity(self): 193 | self._test_extracting_severity(ClangMessage.Level.WARNING, 'major') 194 | 195 | def test_extracting_error_severity(self): 196 | self._test_extracting_severity(ClangMessage.Level.ERROR, 'critical') 197 | 198 | def test_extracting_fatal_severity(self): 199 | self._test_extracting_severity(ClangMessage.Level.FATAL, 'blocker') 200 | 201 | def _test_extracting_severity(self, level, severity_str): 202 | msg = ClangMessage(level=level) 203 | formatter = CodeClimateFormatter() 204 | self.assertEqual(severity_str, formatter._extract_severity(msg, object())) 205 | 206 | def test_generate_fingerprint_reproducibility(self): 207 | msg1 = ClangMessage('path1', line=1) 208 | msg2 = ClangMessage('path1', line=1) 209 | formatter = CodeClimateFormatter() 210 | self.assertEqual(formatter._generate_fingerprint(msg1), formatter._generate_fingerprint(msg2)) 211 | 212 | def test_generate_fingerprint_uses_filepath(self): 213 | self._test_fingerprints_different(ClangMessage('/path/to/file1.cpp'), ClangMessage('/path/to/file2.cpp')) 214 | 215 | def test_generate_fingerprint_uses_line(self): 216 | self._test_fingerprints_different(ClangMessage(line=1), ClangMessage(line=2)) 217 | 218 | def test_generate_fingerprint_uses_column(self): 219 | self._test_fingerprints_different(ClangMessage(column=1), ClangMessage(column=2)) 220 | 221 | def test_generate_fingerprint_uses_message(self): 222 | self._test_fingerprints_different(ClangMessage(message='A'), ClangMessage(message='B')) 223 | 224 | def test_generate_fingerprint_uses_diagnostic_name(self): 225 | self._test_fingerprints_different(ClangMessage(diagnostic_name='A'), ClangMessage(diagnostic_name='B')) 226 | 227 | def test_generate_fingerprint_uses_children(self): 228 | child1 = ClangMessage(line=1) 229 | child2 = ClangMessage(line=2) 230 | self._test_fingerprints_different(ClangMessage(children=[child1]), ClangMessage(children=[child2])) 231 | 232 | def _test_fingerprints_different(self, msg1, msg2): 233 | formatter = CodeClimateFormatter() 234 | self.assertNotEqual(formatter._generate_fingerprint(msg1), formatter._generate_fingerprint(msg2)) 235 | --------------------------------------------------------------------------------