xref: /aosp_15_r20/external/pigweed/pw_toolchain/py/pw_toolchain/clang_tidy.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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
33
34import pw_cli.env
35
36_LOG = logging.getLogger(__name__)
37
38
39def _parse_args() -> argparse.Namespace:
40    """Parses arguments for this script, splitting out the command to run."""
41
42    parser = argparse.ArgumentParser()
43    parser.add_argument(
44        '-v',
45        '--verbose',
46        action='store_true',
47        help='Run clang_tidy with extra debug output.',
48    )
49
50    parser.add_argument(
51        '--clang-tidy',
52        default='clang-tidy',
53        help='Path to clang-tidy executable.',
54    )
55
56    parser.add_argument(
57        '--source-file',
58        required=True,
59        type=Path,
60        help='Path to the source file to analyze with clang-tidy.',
61    )
62    parser.add_argument(
63        '--source-root',
64        required=True,
65        type=Path,
66        help=(
67            'Path to the root source directory.'
68            ' The relative path from the root directory is matched'
69            ' against source filter rather than the absolute path.'
70        ),
71    )
72    parser.add_argument(
73        '--export-fixes',
74        required=False,
75        type=Path,
76        help=(
77            'YAML file to store suggested fixes in. The '
78            'stored fixes can be applied to the input source '
79            'code with clang-apply-replacements.'
80        ),
81    )
82
83    parser.add_argument(
84        '--source-exclude',
85        default=[],
86        action='append',
87        type=str,
88        help=(
89            'Regular expressions matching the paths of'
90            ' source files to be excluded from the'
91            ' analysis.'
92        ),
93    )
94
95    parser.add_argument(
96        '--skip-include-path',
97        default=[],
98        action='append',
99        type=str,
100        help=(
101            'Exclude include paths ending in these paths from clang-tidy. '
102            'These paths are switched from -I to -isystem so clang-tidy '
103            'ignores them.'
104        ),
105    )
106
107    # Add a silent placeholder arg for everything that was left over.
108    parser.add_argument(
109        'extra_args', nargs=argparse.REMAINDER, help=argparse.SUPPRESS
110    )
111
112    parsed_args = parser.parse_args()
113
114    if parsed_args.extra_args[0] != '--':
115        parser.error('arguments not correctly split')
116    parsed_args.extra_args = parsed_args.extra_args[1:]
117    return parsed_args
118
119
120def _filter_include_paths(
121    args: Iterable[str], skip_include_paths: Iterable[str]
122) -> Iterable[str]:
123    filters = [f.rstrip('/') for f in skip_include_paths]
124
125    for arg in args:
126        if arg.startswith('-I'):
127            path = Path(arg[2:]).as_posix()
128            if any(path.endswith(f) or re.match(f, str(path)) for f in filters):
129                yield '-isystem' + arg[2:]
130                continue
131        if arg.startswith('--sysroot'):
132            path = Path(arg[9:]).as_posix()
133            if any(path.endswith(f) or re.match(f, str(path)) for f in filters):
134                yield '-isysroot' + arg[9:]
135                continue
136
137        yield arg
138
139
140def run_clang_tidy(
141    clang_tidy: str,
142    verbose: bool,
143    source_file: Path,
144    export_fixes: Path | None,
145    skip_include_path: list[str],
146    extra_args: list[str],
147) -> int:
148    """Executes clang_tidy via subprocess. Returns true if no failures."""
149    command: list[str | Path] = [clang_tidy, source_file]
150
151    if pw_cli.env.pigweed_environment().PW_USE_COLOR:
152        command.append('--use-color')
153
154    if not verbose:
155        command.append('--quiet')
156
157    if export_fixes is not None:
158        command.extend(['--export-fixes', export_fixes])
159
160    # Append extra compilation flags.  Extra args up to
161    # END_OF_INVOKER are skipped.
162    command.append('--')
163    end_of_invoker = extra_args.index('END_OF_INVOKER')
164    command.extend(
165        _filter_include_paths(
166            extra_args[end_of_invoker + 1 :], skip_include_path
167        )
168    )
169
170    if "-c" in command:
171        command.remove("-c")
172
173    process = subprocess.run(
174        command,
175        stdout=subprocess.PIPE,
176        # clang-tidy prints regular information on
177        # stderr, even with the option --quiet.
178        stderr=subprocess.PIPE,
179    )
180    if process.returncode != 0:
181        _LOG.warning('%s', ' '.join(shlex.quote(str(arg)) for arg in command))
182
183    if process.stdout:
184        _LOG.warning(process.stdout.decode().strip())
185
186    if process.stderr and process.returncode != 0:
187        _LOG.error(process.stderr.decode().strip())
188
189        if export_fixes:
190            suggested_fix_command = (
191                '   pw clang-tidy-fix' f' {export_fixes.parent}'
192            )
193            _LOG.warning(
194                "To apply clang-tidy suggested fixes, run:\n\n%s\n",
195                suggested_fix_command,
196            )
197
198    return process.returncode
199
200
201def main(
202    verbose: bool,
203    clang_tidy: str,
204    source_file: Path,
205    source_root: Path,
206    export_fixes: Path | None,
207    source_exclude: list[str],
208    skip_include_path: list[str],
209    extra_args: list[str],
210) -> int:
211    # Rebase the source file path on source_root.
212    # If source_file is not relative to source_root (which may be the case for
213    # generated files) stick with the original source_file.
214    try:
215        relative_source_file = source_file.relative_to(source_root)
216    except ValueError:
217        relative_source_file = source_file
218
219    for pattern in source_exclude:
220        if re.match(pattern, str(relative_source_file)):
221            return 0
222
223    source_file_path = source_file.resolve()
224    export_fixes_path = (
225        export_fixes.resolve() if export_fixes is not None else None
226    )
227    return run_clang_tidy(
228        clang_tidy,
229        verbose,
230        source_file_path,
231        export_fixes_path,
232        skip_include_path,
233        extra_args,
234    )
235
236
237if __name__ == '__main__':
238    sys.exit(main(**vars(_parse_args())))
239