• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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