Skip to content

Commit c1c3116

Browse files
authored
feat: Add parallel execution support for clang-tidy (#203)
1 parent 634d6b7 commit c1c3116

5 files changed

Lines changed: 226 additions & 2 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,22 @@ 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+
225+
> [!WARNING]
226+
> When using `--jobs`/`-j`, avoid sharing options that write to a single output file
227+
> (for example `--export-fixes=fixes.yaml`) across parallel `clang-tidy` invocations.
228+
> If you need `--export-fixes`, ensure each job writes to a unique file path to avoid
229+
> corrupted or overwritten outputs.
214230
Alternatively, if you want to run the hooks manually on only the changed files, you can use the following command:
215231

216232
```bash

cpp_linter_hooks/clang_tidy.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,49 @@
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
38+
1039

1140
parser = ArgumentParser()
1241
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
1342
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
1443
parser.add_argument(
1544
"--no-compile-commands", action="store_true", dest="no_compile_commands"
1645
)
46+
parser.add_argument("-j", "--jobs", type=_positive_int, default=1)
1747
parser.add_argument("-v", "--verbose", action="store_true")
1848

1949

@@ -74,6 +104,38 @@ def _exec_clang_tidy(command) -> Tuple[int, str]:
74104
return 1, str(e)
75105

76106

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

155+
clang_tidy_args, source_files = _split_source_files(other_args)
156+
157+
# Parallel execution is unsafe when arguments include flags that write to a
158+
# shared output path (e.g., --export-fixes fixes.yaml). In that case, force
159+
# serial execution to avoid concurrent writes/overwrites.
160+
unsafe_parallel = any(
161+
arg == "--export-fixes" or arg.startswith("--export-fixes=")
162+
for arg in clang_tidy_args
163+
)
164+
165+
if hook_args.jobs > 1 and len(source_files) > 1 and not unsafe_parallel:
166+
return _exec_parallel_clang_tidy(
167+
["clang-tidy"] + clang_tidy_args, source_files, hook_args.jobs
168+
)
169+
93170
return _exec_clang_tidy(["clang-tidy"] + other_args)
94171

95172

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
repos:
2+
- repo: .
3+
rev: HEAD
4+
hooks:
5+
- id: clang-tidy
6+
args: [--checks=.clang-tidy, --jobs=2]

testing/run.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,32 @@ else
4040
exit_code=1
4141
fi
4242

43+
# Test parallel execution with multiple source files
44+
echo "===================================="
45+
echo "Test pre-commit-config-parallel.yaml"
46+
echo "===================================="
47+
uvx pre-commit clean
48+
uvx pre-commit run -c testing/pre-commit-config-parallel.yaml \
49+
--files testing/main.c testing/good.c | tee -a parallel_result.txt || true
50+
git restore testing/main.c
51+
52+
parallel_failed=$(grep -c "Failed" parallel_result.txt 2>/dev/null || echo "0")
53+
echo "$parallel_failed parallel cases failed."
54+
55+
if [[ $parallel_failed -ge 1 ]]; then
56+
echo "========================================================"
57+
echo "Parallel test passed (expected failures detected: $parallel_failed)."
58+
echo "========================================================"
59+
parallel_result="success"
60+
rm parallel_result.txt
61+
else
62+
echo "==========================================="
63+
echo "Parallel test failed (no failures detected)."
64+
echo "==========================================="
65+
parallel_result="failure"
66+
exit_code=1
67+
fi
68+
4369
# Add result to GitHub summary if running in GitHub Actions
4470
if [[ -n "$GITHUB_STEP_SUMMARY" ]]; then
4571
{
@@ -48,6 +74,8 @@ if [[ -n "$GITHUB_STEP_SUMMARY" ]]; then
4874
echo "**Result:** $result"
4975
echo ""
5076
echo "**Failed cases:** $failed_cases"
77+
echo ""
78+
echo "**Parallel test:** $parallel_result"
5179
} >> "$GITHUB_STEP_SUMMARY"
5280
fi
5381

tests/test_clang_tidy.py

Lines changed: 97 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,99 @@ 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(["clang-tidy", "-p", "./build", "a.cpp", "b.cpp"])
243+
244+
245+
def test_jobs_parallelizes_source_files_and_preserves_output_order():
246+
def fake_exec(command):
247+
source_file = command[-1]
248+
if source_file == "a.cpp":
249+
time.sleep(0.05)
250+
return 0, "a.cpp output"
251+
return 1, "b.cpp output"
252+
253+
with (
254+
patch("cpp_linter_hooks.clang_tidy._exec_clang_tidy", side_effect=fake_exec),
255+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
256+
):
257+
ret, output = run_clang_tidy(
258+
[
259+
"--jobs=4",
260+
"-p",
261+
"./build",
262+
"--header-filter=.*",
263+
"a.cpp",
264+
"b.cpp",
265+
]
266+
)
267+
268+
assert ret == 1
269+
assert output == "a.cpp output\nb.cpp output"
270+
271+
272+
def test_jobs_parallelizes_only_trailing_source_files():
273+
with (
274+
patch(
275+
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
276+
) as mock_exec,
277+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
278+
):
279+
run_clang_tidy(
280+
[
281+
"--jobs=2",
282+
"-p",
283+
"./build",
284+
"--header-filter=.*",
285+
"a.cpp",
286+
"b.hpp",
287+
]
288+
)
289+
290+
commands = {tuple(call.args[0]) for call in mock_exec.call_args_list}
291+
assert commands == {
292+
("clang-tidy", "-p", "./build", "--header-filter=.*", "a.cpp"),
293+
("clang-tidy", "-p", "./build", "--header-filter=.*", "b.hpp"),
294+
}
295+
296+
297+
def test_jobs_with_export_fixes_forces_serial_execution():
298+
with (
299+
patch(
300+
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
301+
) as mock_exec,
302+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
303+
):
304+
run_clang_tidy(
305+
[
306+
"--jobs=4",
307+
"-p",
308+
"./build",
309+
"--export-fixes",
310+
"fixes.yaml",
311+
"a.cpp",
312+
"b.cpp",
313+
]
314+
)
315+
316+
mock_exec.assert_called_once_with(
317+
[
318+
"clang-tidy",
319+
"-p",
320+
"./build",
321+
"--export-fixes",
322+
"fixes.yaml",
323+
"a.cpp",
324+
"b.cpp",
325+
]
326+
)

0 commit comments

Comments
 (0)