1#!/usr/bin/env python3 2# Copyright 2021 The Pigweed Authors 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may not 5# use this file except in compliance with the License. You may obtain a copy of 6# the License at 7# 8# https://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations under 14# the License. 15"""Invoke clang-tidy. 16 17Implements additional features compared to directly calling 18clang-tidy: 19 - add option `--source-exclude` to exclude matching sources from the 20 clang-tidy analysis. 21 - inputs the full compile command, with the cc binary name 22 - TODO(henrichataing): infer platform options from the full compile command 23""" 24 25import argparse 26import logging 27from pathlib import Path 28import re 29import shlex 30import subprocess 31import sys 32from typing import Iterable, List, Optional, Union 33 34_LOG = logging.getLogger(__name__) 35 36 37def _parse_args() -> argparse.Namespace: 38 """Parses arguments for this script, splitting out the command to run.""" 39 40 parser = argparse.ArgumentParser() 41 parser.add_argument( 42 '-v', 43 '--verbose', 44 action='store_true', 45 help='Run clang_tidy with extra debug output.', 46 ) 47 48 parser.add_argument( 49 '--clang-tidy', 50 default='clang-tidy', 51 help='Path to clang-tidy executable.', 52 ) 53 54 parser.add_argument( 55 '--source-file', 56 required=True, 57 type=Path, 58 help='Path to the source file to analyze with clang-tidy.', 59 ) 60 parser.add_argument( 61 '--source-root', 62 required=True, 63 type=Path, 64 help=( 65 'Path to the root source directory.' 66 ' The relative path from the root directory is matched' 67 ' against source filter rather than the absolute path.' 68 ), 69 ) 70 parser.add_argument( 71 '--export-fixes', 72 required=False, 73 type=Path, 74 help=( 75 'YAML file to store suggested fixes in. The ' 76 'stored fixes can be applied to the input source ' 77 'code with clang-apply-replacements.' 78 ), 79 ) 80 81 parser.add_argument( 82 '--source-exclude', 83 default=[], 84 action='append', 85 type=str, 86 help=( 87 'Regular expressions matching the paths of' 88 ' source files to be excluded from the' 89 ' analysis.' 90 ), 91 ) 92 93 parser.add_argument( 94 '--skip-include-path', 95 default=[], 96 action='append', 97 type=str, 98 help=( 99 'Exclude include paths ending in these paths from clang-tidy. ' 100 'These paths are switched from -I to -isystem so clang-tidy ' 101 'ignores them.' 102 ), 103 ) 104 105 # Add a silent placeholder arg for everything that was left over. 106 parser.add_argument( 107 'extra_args', nargs=argparse.REMAINDER, help=argparse.SUPPRESS 108 ) 109 110 parsed_args = parser.parse_args() 111 112 if parsed_args.extra_args[0] != '--': 113 parser.error('arguments not correctly split') 114 parsed_args.extra_args = parsed_args.extra_args[1:] 115 return parsed_args 116 117 118def _filter_include_paths( 119 args: Iterable[str], skip_include_paths: Iterable[str] 120) -> Iterable[str]: 121 filters = [f.rstrip('/') for f in skip_include_paths] 122 123 for arg in args: 124 if arg.startswith('-I'): 125 path = Path(arg[2:]).as_posix() 126 if any(path.endswith(f) or re.match(f, str(path)) for f in filters): 127 yield '-isystem' + arg[2:] 128 continue 129 if arg.startswith('--sysroot'): 130 path = Path(arg[9:]).as_posix() 131 if any(path.endswith(f) or re.match(f, str(path)) for f in filters): 132 yield '-isysroot' + arg[9:] 133 continue 134 135 yield arg 136 137 138def run_clang_tidy( 139 clang_tidy: str, 140 verbose: bool, 141 source_file: Path, 142 export_fixes: Optional[Path], 143 skip_include_path: List[str], 144 extra_args: List[str], 145) -> int: 146 """Executes clang_tidy via subprocess. Returns true if no failures.""" 147 command: List[Union[str, Path]] = [clang_tidy, source_file, '--use-color'] 148 149 if not verbose: 150 command.append('--quiet') 151 152 if export_fixes is not None: 153 command.extend(['--export-fixes', export_fixes]) 154 155 # Append extra compilation flags. Extra args up to 156 # END_OF_INVOKER are skipped. 157 command.append('--') 158 end_of_invoker = extra_args.index('END_OF_INVOKER') 159 command.extend( 160 _filter_include_paths( 161 extra_args[end_of_invoker + 1 :], skip_include_path 162 ) 163 ) 164 165 process = subprocess.run( 166 command, 167 stdout=subprocess.PIPE, 168 # clang-tidy prints regular information on 169 # stderr, even with the option --quiet. 170 stderr=subprocess.PIPE, 171 ) 172 if process.returncode != 0: 173 _LOG.warning('%s', ' '.join(shlex.quote(str(arg)) for arg in command)) 174 175 if process.stdout: 176 _LOG.warning(process.stdout.decode().strip()) 177 178 if process.stderr and process.returncode != 0: 179 _LOG.error(process.stderr.decode().strip()) 180 181 return process.returncode 182 183 184def main( 185 verbose: bool, 186 clang_tidy: str, 187 source_file: Path, 188 source_root: Path, 189 export_fixes: Optional[Path], 190 source_exclude: List[str], 191 skip_include_path: List[str], 192 extra_args: List[str], 193) -> int: 194 # Rebase the source file path on source_root. 195 # If source_file is not relative to source_root (which may be the case for 196 # generated files) stick with the original source_file. 197 try: 198 relative_source_file = source_file.relative_to(source_root) 199 except ValueError: 200 relative_source_file = source_file 201 202 for pattern in source_exclude: 203 if re.match(pattern, str(relative_source_file)): 204 return 0 205 206 source_file_path = source_file.resolve() 207 export_fixes_path = ( 208 export_fixes.resolve() if export_fixes is not None else None 209 ) 210 return run_clang_tidy( 211 clang_tidy, 212 verbose, 213 source_file_path, 214 export_fixes_path, 215 skip_include_path, 216 extra_args, 217 ) 218 219 220if __name__ == '__main__': 221 sys.exit(main(**vars(_parse_args()))) 222