Skip to content

Commit d01022f

Browse files
committed
Add parallel jobs support to clang-tidy hook
1 parent 634d6b7 commit d01022f

3 files changed

Lines changed: 149 additions & 2 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ Use -header-filter=.* to display errors from all non-system headers. Use -system
211211
files: ^(src|include)/.*\.(cpp|cc|cxx|h|hpp)$
212212
```
213213

214+
For `clang-tidy`, you can also process multiple files in parallel by adding `--jobs`
215+
or `-j`:
216+
217+
```yaml
218+
- repo: https://github.com/cpp-linter/cpp-linter-hooks
219+
rev: v1.2.0
220+
hooks:
221+
- id: clang-tidy
222+
args: [--checks=.clang-tidy, --version=21, --jobs=4]
223+
```
224+
214225
Alternatively, if you want to run the hooks manually on only the changed files, you can use the following command:
215226

216227
```bash

cpp_linter_hooks/clang_tidy.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,48 @@
1+
from concurrent.futures import ThreadPoolExecutor
12
import subprocess
23
import sys
3-
from argparse import ArgumentParser
4+
from argparse import ArgumentParser, ArgumentTypeError
45
from pathlib import Path
5-
from typing import Optional, Tuple
6+
from typing import List, Optional, Tuple
67

78
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION
89

910
COMPILE_DB_SEARCH_DIRS = ["build", "out", "cmake-build-debug", "_build"]
11+
SOURCE_FILE_SUFFIXES = {
12+
".c",
13+
".cc",
14+
".cp",
15+
".cpp",
16+
".cxx",
17+
".c++",
18+
".cu",
19+
".cuh",
20+
".h",
21+
".hh",
22+
".hpp",
23+
".hxx",
24+
".h++",
25+
".ipp",
26+
".inl",
27+
".ixx",
28+
".tpp",
29+
".txx",
30+
}
31+
32+
33+
def _positive_int(value: str) -> int:
34+
jobs = int(value)
35+
if jobs < 1:
36+
raise ArgumentTypeError("--jobs must be greater than 0")
37+
return jobs
1038

1139
parser = ArgumentParser()
1240
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
1341
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
1442
parser.add_argument(
1543
"--no-compile-commands", action="store_true", dest="no_compile_commands"
1644
)
45+
parser.add_argument("-j", "--jobs", type=_positive_int, default=1)
1746
parser.add_argument("-v", "--verbose", action="store_true")
1847

1948

@@ -74,6 +103,38 @@ def _exec_clang_tidy(command) -> Tuple[int, str]:
74103
return 1, str(e)
75104

76105

106+
def _looks_like_source_file(path: str) -> bool:
107+
return any(suffix.lower() in SOURCE_FILE_SUFFIXES for suffix in Path(path).suffixes)
108+
109+
110+
def _split_source_files(args: List[str]) -> Tuple[List[str], List[str]]:
111+
split_idx = len(args)
112+
source_files: List[str] = []
113+
for idx in range(len(args) - 1, -1, -1):
114+
if not _looks_like_source_file(args[idx]):
115+
break
116+
source_files.append(args[idx])
117+
split_idx = idx
118+
return args[:split_idx], list(reversed(source_files))
119+
120+
121+
def _combine_outputs(results: List[Tuple[int, str]]) -> str:
122+
return "\n".join(output.rstrip("\n") for _, output in results if output)
123+
124+
125+
def _exec_parallel_clang_tidy(
126+
command_prefix: List[str], source_files: List[str], jobs: int
127+
) -> Tuple[int, str]:
128+
def run_file(source_file: str) -> Tuple[int, str]:
129+
return _exec_clang_tidy(command_prefix + [source_file])
130+
131+
with ThreadPoolExecutor(max_workers=min(jobs, len(source_files))) as executor:
132+
results = list(executor.map(run_file, source_files))
133+
134+
retval = 1 if any(retval != 0 for retval, _ in results) else 0
135+
return retval, _combine_outputs(results)
136+
137+
77138
def run_clang_tidy(args=None) -> Tuple[int, str]:
78139
hook_args, other_args = parser.parse_known_args(args)
79140
if hook_args.version:
@@ -90,6 +151,12 @@ def run_clang_tidy(args=None) -> Tuple[int, str]:
90151
)
91152
other_args = ["-p", compile_db_path] + other_args
92153

154+
clang_tidy_args, source_files = _split_source_files(other_args)
155+
if hook_args.jobs > 1 and len(source_files) > 1:
156+
return _exec_parallel_clang_tidy(
157+
["clang-tidy"] + clang_tidy_args, source_files, hook_args.jobs
158+
)
159+
93160
return _exec_clang_tidy(["clang-tidy"] + other_args)
94161

95162

tests/test_clang_tidy.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import subprocess
3+
import time
34
from pathlib import Path
45
from unittest.mock import patch, MagicMock
56

@@ -227,3 +228,71 @@ def test_no_verbose_no_extra_stderr(tmp_path, monkeypatch, capsys):
227228
):
228229
run_clang_tidy(["dummy.cpp"])
229230
assert capsys.readouterr().err == ""
231+
232+
233+
def test_jobs_one_keeps_single_invocation():
234+
with (
235+
patch(
236+
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
237+
) as mock_exec,
238+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
239+
):
240+
run_clang_tidy(["--jobs=1", "-p", "./build", "a.cpp", "b.cpp"])
241+
242+
mock_exec.assert_called_once_with(
243+
["clang-tidy", "-p", "./build", "a.cpp", "b.cpp"]
244+
)
245+
246+
247+
def test_jobs_parallelizes_source_files_and_preserves_output_order():
248+
def fake_exec(command):
249+
source_file = command[-1]
250+
if source_file == "a.cpp":
251+
time.sleep(0.05)
252+
return 0, "a.cpp output"
253+
return 1, "b.cpp output"
254+
255+
with (
256+
patch("cpp_linter_hooks.clang_tidy._exec_clang_tidy", side_effect=fake_exec),
257+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
258+
):
259+
ret, output = run_clang_tidy(
260+
[
261+
"--jobs=4",
262+
"-p",
263+
"./build",
264+
"--export-fixes",
265+
"fixes.yaml",
266+
"a.cpp",
267+
"b.cpp",
268+
]
269+
)
270+
271+
assert ret == 1
272+
assert output == "a.cpp output\nb.cpp output"
273+
274+
275+
def test_jobs_parallelizes_only_trailing_source_files():
276+
with (
277+
patch(
278+
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
279+
) as mock_exec,
280+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
281+
):
282+
run_clang_tidy(
283+
[
284+
"--jobs=2",
285+
"-p",
286+
"./build",
287+
"--export-fixes",
288+
"fixes.yaml",
289+
"a.cpp",
290+
"b.hpp",
291+
]
292+
)
293+
294+
commands = {tuple(call.args[0]) for call in mock_exec.call_args_list}
295+
assert commands == {
296+
("clang-tidy", "-p", "./build", "--export-fixes", "fixes.yaml", "a.cpp"),
297+
("clang-tidy", "-p", "./build", "--export-fixes", "fixes.yaml", "b.hpp"),
298+
}

0 commit comments

Comments
 (0)