Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A pre-commit hook that automatically formats and lints your C/C++ code using `cl
- [Quick Start](#quick-start)
- [Custom Configuration Files](#custom-configuration-files)
- [Custom Clang Tool Version](#custom-clang-tool-version)
- [Compilation Database (CMake/Meson Projects)](#compilation-database-cmakemeson-projects)
- [Output](#output)
- [clang-format Output](#clang-format-output)
- [clang-tidy Output](#clang-tidy-output)
Expand Down Expand Up @@ -72,6 +73,52 @@ repos:
args: [--checks=.clang-tidy, --version=21] # Specifies version
```

### Compilation Database (CMake/Meson Projects)

For CMake or Meson projects, clang-tidy works best with a `compile_commands.json`
file that records the exact compiler flags used for each file. Without it, clang-tidy
may report false positives from missing include paths or wrong compiler flags.

The hook auto-detects `compile_commands.json` in common build directories (`build/`,
`out/`, `cmake-build-debug/`, `_build/`) and passes `-p <dir>` to clang-tidy
automatically — no configuration needed for most projects:

```yaml
repos:
- repo: https://github.com/cpp-linter/cpp-linter-hooks
rev: v1.2.0
hooks:
- id: clang-tidy
args: [--checks=.clang-tidy]
# Auto-detects ./build/compile_commands.json if present
```

To specify the build directory explicitly:

```yaml
- id: clang-tidy
args: [--compile-commands=build, --checks=.clang-tidy]
```

To disable auto-detection (e.g. in a monorepo where auto-detect might pick the wrong database):

```yaml
- id: clang-tidy
args: [--no-compile-commands, --checks=.clang-tidy]
```

To see which `compile_commands.json` the hook is using, add `-v`:

```yaml
- id: clang-tidy
args: [--compile-commands=build, -v, --checks=.clang-tidy]
```

> [!NOTE]
> Generate `compile_commands.json` with CMake using `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -Bbuild .`
> or add `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)` to your `CMakeLists.txt`.
> `--compile-commands` takes the **directory** containing `compile_commands.json`, not the file path itself.

## Output

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

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

```yaml
repos:
- repo: https://github.com/cpp-linter/cpp-linter-hooks
rev: v1.2.0
hooks:
- id: clang-format
args: [--style=file, --version=21, --verbose] # Add -v or --verbose for detailed output
args: [--style=file, --version=21, --verbose] # Shows processed files
- id: clang-tidy
args: [--checks=.clang-tidy, --verbose] # Shows which compile_commands.json is used
```

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


<!-- > [!TIP]
Expand Down
43 changes: 42 additions & 1 deletion cpp_linter_hooks/clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,59 @@
import subprocess
import sys
from argparse import ArgumentParser
from typing import Tuple
from pathlib import Path
from typing import Optional, Tuple
Comment thread
coderabbitai[bot] marked this conversation as resolved.

from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION

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

parser = ArgumentParser()
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
parser.add_argument("--no-compile-commands", action="store_true", dest="no_compile_commands")
parser.add_argument("-v", "--verbose", action="store_true")


def _find_compile_commands() -> Optional[str]:
for d in COMPILE_DB_SEARCH_DIRS:
if (Path(d) / "compile_commands.json").exists():
return d
return None


def run_clang_tidy(args=None) -> Tuple[int, str]:

Check failure on line 25 in cpp_linter_hooks/clang_tidy.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=cpp-linter_cpp-linter-hooks&issues=AZ1BV6F1kWhB4ytKIWDG&open=AZ1BV6F1kWhB4ytKIWDG&pullRequest=201
hook_args, other_args = parser.parse_known_args(args)
if hook_args.version:
resolve_install("clang-tidy", hook_args.version)

# Covers both "-p ./build" (two tokens) and "-p=./build" (one token)
has_p = any(a == "-p" or a.startswith("-p=") for a in other_args)

compile_db_path = None
if not hook_args.no_compile_commands:
if hook_args.compile_commands:
if has_p:
print(
"Warning: --compile-commands ignored; -p already in args",
file=sys.stderr,
)
else:
p = Path(hook_args.compile_commands)
if not p.is_dir() or not (p / "compile_commands.json").exists():
return 1, (
f"--compile-commands: no compile_commands.json"
f" in '{hook_args.compile_commands}'"
)
compile_db_path = hook_args.compile_commands
elif not has_p:
compile_db_path = _find_compile_commands()

if compile_db_path:
if hook_args.verbose:
print(f"Using compile_commands.json from: {compile_db_path}", file=sys.stderr)
other_args = ["-p", compile_db_path] + other_args

command = ["clang-tidy"] + other_args

retval = 0
Expand Down
120 changes: 120 additions & 0 deletions tests/test_clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock

from cpp_linter_hooks.clang_tidy import run_clang_tidy

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

ret, _ = run_clang_tidy(args + [str(test_file)])
assert ret == expected_retval


# --- compile_commands tests (all mock subprocess.run and resolve_install) ---

_MOCK_RUN = MagicMock(returncode=0, stdout="", stderr="")


def _patch():
return (
patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN),
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
)


def test_compile_commands_explicit(tmp_path):
db_dir = tmp_path / "build"
db_dir.mkdir()
(db_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy([f"--compile-commands={db_dir}", "dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert "-p" in cmd
assert cmd[cmd.index("-p") + 1] == str(db_dir)


def test_compile_commands_auto_detect(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy(["dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert "-p" in cmd
assert cmd[cmd.index("-p") + 1] == "build"


def test_compile_commands_auto_detect_fallback(tmp_path, monkeypatch):
# Only ./out has compile_commands.json, not ./build
monkeypatch.chdir(tmp_path)
out_dir = tmp_path / "out"
out_dir.mkdir()
(out_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy(["dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert "-p" in cmd
assert cmd[cmd.index("-p") + 1] == "out"


def test_compile_commands_none(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy(["dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert "-p" not in cmd


def test_compile_commands_conflict_guard(tmp_path, monkeypatch):
# -p already in args: auto-detect should NOT fire even if build/ exists
monkeypatch.chdir(tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy(["-p", "./custom", "dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert cmd.count("-p") == 1
assert "./custom" in cmd


def test_compile_commands_no_flag(tmp_path, monkeypatch):
# --no-compile-commands disables auto-detect even when build/ exists
monkeypatch.chdir(tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy(["--no-compile-commands", "dummy.cpp"])
cmd = mock_run.call_args[0][0]
assert "-p" not in cmd


def test_compile_commands_invalid_path(tmp_path):
# Case 1: directory does not exist
fake_dir = tmp_path / "nonexistent"
with patch("cpp_linter_hooks.clang_tidy.resolve_install"):
ret, output = run_clang_tidy([f"--compile-commands={fake_dir}", "dummy.cpp"])
assert ret == 1
assert "nonexistent" in output

# Case 2: directory exists but has no compile_commands.json
empty_dir = tmp_path / "empty_build"
empty_dir.mkdir()
with patch("cpp_linter_hooks.clang_tidy.resolve_install"):
ret, output = run_clang_tidy([f"--compile-commands={empty_dir}", "dummy.cpp"])
assert ret == 1
assert "empty_build" in output


def test_compile_commands_explicit_with_p_conflict(tmp_path, capsys):
# --compile-commands + -p in args: warning printed, only the user's -p used
db_dir = tmp_path / "build"
db_dir.mkdir()
(db_dir / "compile_commands.json").write_text("[]")
with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN) as mock_run, \
patch("cpp_linter_hooks.clang_tidy.resolve_install"):
run_clang_tidy([f"--compile-commands={db_dir}", "-p", "./other", "dummy.cpp"])
captured = capsys.readouterr()
assert "Warning" in captured.err
cmd = mock_run.call_args[0][0]
assert cmd.count("-p") == 1
assert "./other" in cmd
Loading