Skip to content

Commit f018506

Browse files
committed
feat: add support for compilation databases in clang-tidy
1 parent b29757a commit f018506

3 files changed

Lines changed: 216 additions & 3 deletions

File tree

README.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A pre-commit hook that automatically formats and lints your C/C++ code using `cl
1313
- [Quick Start](#quick-start)
1414
- [Custom Configuration Files](#custom-configuration-files)
1515
- [Custom Clang Tool Version](#custom-clang-tool-version)
16+
- [Compilation Database (CMake/Meson Projects)](#compilation-database-cmakemeson-projects)
1617
- [Output](#output)
1718
- [clang-format Output](#clang-format-output)
1819
- [clang-tidy Output](#clang-tidy-output)
@@ -72,6 +73,52 @@ repos:
7273
args: [--checks=.clang-tidy, --version=21] # Specifies version
7374
```
7475

76+
### Compilation Database (CMake/Meson Projects)
77+
78+
For CMake or Meson projects, clang-tidy works best with a `compile_commands.json`
79+
file that records the exact compiler flags used for each file. Without it, clang-tidy
80+
may report false positives from missing include paths or wrong compiler flags.
81+
82+
The hook auto-detects `compile_commands.json` in common build directories (`build/`,
83+
`out/`, `cmake-build-debug/`, `_build/`) and passes `-p <dir>` to clang-tidy
84+
automatically — no configuration needed for most projects:
85+
86+
```yaml
87+
repos:
88+
- repo: https://github.com/cpp-linter/cpp-linter-hooks
89+
rev: v1.2.0
90+
hooks:
91+
- id: clang-tidy
92+
args: [--checks=.clang-tidy]
93+
# Auto-detects ./build/compile_commands.json if present
94+
```
95+
96+
To specify the build directory explicitly:
97+
98+
```yaml
99+
- id: clang-tidy
100+
args: [--compile-commands=build, --checks=.clang-tidy]
101+
```
102+
103+
To disable auto-detection (e.g. in a monorepo where auto-detect might pick the wrong database):
104+
105+
```yaml
106+
- id: clang-tidy
107+
args: [--no-compile-commands, --checks=.clang-tidy]
108+
```
109+
110+
To see which `compile_commands.json` the hook is using, add `-v`:
111+
112+
```yaml
113+
- id: clang-tidy
114+
args: [--compile-commands=build, -v, --checks=.clang-tidy]
115+
```
116+
117+
> [!NOTE]
118+
> Generate `compile_commands.json` with CMake using `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -Bbuild .`
119+
> or add `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)` to your `CMakeLists.txt`.
120+
> `--compile-commands` takes the **directory** containing `compile_commands.json`, not the file path itself.
121+
75122
## Output
76123

77124
### clang-format Output
@@ -172,15 +219,19 @@ This approach ensures that only modified files are checked, further speeding up
172219
### Verbose Output
173220

174221
> [!NOTE]
175-
> Use `-v` or `--verbose` in `args` of `clang-format` to show the list of processed files e.g.:
222+
> Use `-v` or `--verbose` in `args` to enable verbose output.
223+
> For `clang-format`, it shows the list of processed files.
224+
> For `clang-tidy`, it prints which `compile_commands.json` is being used (when auto-detected or explicitly set).
176225

177226
```yaml
178227
repos:
179228
- repo: https://github.com/cpp-linter/cpp-linter-hooks
180229
rev: v1.2.0
181230
hooks:
182231
- id: clang-format
183-
args: [--style=file, --version=21, --verbose] # Add -v or --verbose for detailed output
232+
args: [--style=file, --version=21, --verbose] # Shows processed files
233+
- id: clang-tidy
234+
args: [--checks=.clang-tidy, --verbose] # Shows which compile_commands.json is used
184235
```
185236

186237
## FAQ
@@ -196,6 +247,7 @@ repos:
196247
| Supports passing format style string | ✅ via `--style` | ❌ |
197248
| Verbose output | ✅ via `--verbose` | ❌ |
198249
| Dry-run mode | ✅ via `--dry-run` | ❌ |
250+
| Compilation database support | ✅ auto-detect or `--compile-commands` | ❌ |
199251

200252

201253
<!-- > [!TIP]

cpp_linter_hooks/clang_tidy.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,59 @@
11
import subprocess
2+
import sys
23
from argparse import ArgumentParser
3-
from typing import Tuple
4+
from pathlib import Path
5+
from typing import Optional, Tuple
46

57
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION
68

9+
COMPILE_DB_SEARCH_DIRS = ["build", "out", "cmake-build-debug", "_build"]
710

811
parser = ArgumentParser()
912
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
13+
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
14+
parser.add_argument("--no-compile-commands", action="store_true", dest="no_compile_commands")
15+
parser.add_argument("-v", "--verbose", action="store_true")
16+
17+
18+
def _find_compile_commands() -> Optional[str]:
19+
for d in COMPILE_DB_SEARCH_DIRS:
20+
if (Path(d) / "compile_commands.json").exists():
21+
return d
22+
return None
1023

1124

1225
def run_clang_tidy(args=None) -> Tuple[int, str]:
1326
hook_args, other_args = parser.parse_known_args(args)
1427
if hook_args.version:
1528
resolve_install("clang-tidy", hook_args.version)
29+
30+
# Covers both "-p ./build" (two tokens) and "-p=./build" (one token)
31+
has_p = any(a == "-p" or a.startswith("-p=") for a in other_args)
32+
33+
compile_db_path = None
34+
if not hook_args.no_compile_commands:
35+
if hook_args.compile_commands:
36+
if has_p:
37+
print(
38+
"Warning: --compile-commands ignored; -p already in args",
39+
file=sys.stderr,
40+
)
41+
else:
42+
p = Path(hook_args.compile_commands)
43+
if not p.is_dir() or not (p / "compile_commands.json").exists():
44+
return 1, (
45+
f"--compile-commands: no compile_commands.json"
46+
f" in '{hook_args.compile_commands}'"
47+
)
48+
compile_db_path = hook_args.compile_commands
49+
elif not has_p:
50+
compile_db_path = _find_compile_commands()
51+
52+
if compile_db_path:
53+
if hook_args.verbose:
54+
print(f"Using compile_commands.json from: {compile_db_path}", file=sys.stderr)
55+
other_args = ["-p", compile_db_path] + other_args
56+
1657
command = ["clang-tidy"] + other_args
1758

1859
retval = 0

tests/test_clang_tidy.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import subprocess
33
from pathlib import Path
4+
from unittest.mock import patch, MagicMock
45

56
from cpp_linter_hooks.clang_tidy import run_clang_tidy
67

@@ -53,3 +54,122 @@ def test_run_clang_tidy_invalid(args, expected_retval, tmp_path):
5354

5455
ret, _ = run_clang_tidy(args + [str(test_file)])
5556
assert ret == expected_retval
57+
58+
59+
# --- compile_commands tests (all mock subprocess.run and resolve_install) ---
60+
61+
_MOCK_RUN = MagicMock(returncode=0, stdout="", stderr="")
62+
63+
64+
def _patch():
65+
return (
66+
patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN),
67+
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
68+
)
69+
70+
71+
def test_compile_commands_explicit(tmp_path):
72+
db_dir = tmp_path / "build"
73+
db_dir.mkdir()
74+
(db_dir / "compile_commands.json").write_text("[]")
75+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
76+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
77+
run_clang_tidy([f"--compile-commands={db_dir}", "dummy.cpp"])
78+
cmd = mock_run.call_args[0][0]
79+
assert "-p" in cmd
80+
assert cmd[cmd.index("-p") + 1] == str(db_dir)
81+
82+
83+
def test_compile_commands_auto_detect(tmp_path, monkeypatch):
84+
monkeypatch.chdir(tmp_path)
85+
build_dir = tmp_path / "build"
86+
build_dir.mkdir()
87+
(build_dir / "compile_commands.json").write_text("[]")
88+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
89+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
90+
run_clang_tidy(["dummy.cpp"])
91+
cmd = mock_run.call_args[0][0]
92+
assert "-p" in cmd
93+
assert cmd[cmd.index("-p") + 1] == "build"
94+
95+
96+
def test_compile_commands_auto_detect_fallback(tmp_path, monkeypatch):
97+
# Only ./out has compile_commands.json, not ./build
98+
monkeypatch.chdir(tmp_path)
99+
out_dir = tmp_path / "out"
100+
out_dir.mkdir()
101+
(out_dir / "compile_commands.json").write_text("[]")
102+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
103+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
104+
run_clang_tidy(["dummy.cpp"])
105+
cmd = mock_run.call_args[0][0]
106+
assert "-p" in cmd
107+
assert cmd[cmd.index("-p") + 1] == "out"
108+
109+
110+
def test_compile_commands_none(tmp_path, monkeypatch):
111+
monkeypatch.chdir(tmp_path)
112+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
113+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
114+
run_clang_tidy(["dummy.cpp"])
115+
cmd = mock_run.call_args[0][0]
116+
assert "-p" not in cmd
117+
118+
119+
def test_compile_commands_conflict_guard(tmp_path, monkeypatch):
120+
# -p already in args: auto-detect should NOT fire even if build/ exists
121+
monkeypatch.chdir(tmp_path)
122+
build_dir = tmp_path / "build"
123+
build_dir.mkdir()
124+
(build_dir / "compile_commands.json").write_text("[]")
125+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
126+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
127+
run_clang_tidy(["-p", "./custom", "dummy.cpp"])
128+
cmd = mock_run.call_args[0][0]
129+
assert cmd.count("-p") == 1
130+
assert "./custom" in cmd
131+
132+
133+
def test_compile_commands_no_flag(tmp_path, monkeypatch):
134+
# --no-compile-commands disables auto-detect even when build/ exists
135+
monkeypatch.chdir(tmp_path)
136+
build_dir = tmp_path / "build"
137+
build_dir.mkdir()
138+
(build_dir / "compile_commands.json").write_text("[]")
139+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
140+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
141+
run_clang_tidy(["--no-compile-commands", "dummy.cpp"])
142+
cmd = mock_run.call_args[0][0]
143+
assert "-p" not in cmd
144+
145+
146+
def test_compile_commands_invalid_path(tmp_path):
147+
# Case 1: directory does not exist
148+
fake_dir = tmp_path / "nonexistent"
149+
with patch("cpp_linter_hooks.clang_tidy.resolve_install"):
150+
ret, output = run_clang_tidy([f"--compile-commands={fake_dir}", "dummy.cpp"])
151+
assert ret == 1
152+
assert "nonexistent" in output
153+
154+
# Case 2: directory exists but has no compile_commands.json
155+
empty_dir = tmp_path / "empty_build"
156+
empty_dir.mkdir()
157+
with patch("cpp_linter_hooks.clang_tidy.resolve_install"):
158+
ret, output = run_clang_tidy([f"--compile-commands={empty_dir}", "dummy.cpp"])
159+
assert ret == 1
160+
assert "empty_build" in output
161+
162+
163+
def test_compile_commands_explicit_with_p_conflict(tmp_path, capsys):
164+
# --compile-commands + -p in args: warning printed, only the user's -p used
165+
db_dir = tmp_path / "build"
166+
db_dir.mkdir()
167+
(db_dir / "compile_commands.json").write_text("[]")
168+
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
169+
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
170+
run_clang_tidy([f"--compile-commands={db_dir}", "-p", "./other", "dummy.cpp"])
171+
captured = capsys.readouterr()
172+
assert "Warning" in captured.err
173+
cmd = mock_run.call_args[0][0]
174+
assert cmd.count("-p") == 1
175+
assert "./other" in cmd

0 commit comments

Comments
 (0)