Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ A pre-commit hook repo that auto-installs and runs `clang-format` and `clang-tid
| File | Purpose |
|------|---------|
| `pyproject.toml` | Build config, deps (`pip>=26.1`), dev deps, entry points |
| `cpp_linter_hooks/util.py` | PyPI version resolution + pip install (prefix matching, offline fallback) |
| `cpp_linter_hooks/util.py` | Installed-wheel check, PyPI version resolution + pip install (prefix matching, offline fallback) |
| `cpp_linter_hooks/clang_format.py` | clang-format wrapper (`--verbose`, `--dry-run`) |
| `cpp_linter_hooks/clang_tidy.py` | clang-tidy wrapper (`--compile-commands`, `--jobs`, `--fix`, error hints) |
| `cpp_linter_hooks/clang_tidy.py` | clang-tidy wrapper (`--compile-commands`, `--jobs`, `--fix`, `-quiet` + status-line filter, `--checks` repair, error hints) |
| `.pre-commit-hooks.yaml` | Hook metadata for pre-commit framework |
| `testing/` | Test fixtures (`main.c`, `good.c`, `.clang-format`, `.clang-tidy`, CMakeLists) |
| `examples/` | Example configs (CMake, Meson, large-project scoping) |
Expand All @@ -42,10 +42,12 @@ bash testing/run.sh # Integration tests (full pre-commit pipeline)

## Core Patterns

- **Version resolution**: Dynamic from PyPI at runtime. No hardcoded version lists. Prefix match supported (`--version=20` → `20.1.8`).
- **Version resolution**: A wheel already installed in the hook environment (found by its `<name>-<version>.dist-info` directory in site-packages, e.g. from `additional_dependencies`, with its executable in the environment's scripts directory) is used without network access when it matches `--version` (exact or prefix) or when no `--version` is given. Otherwise the version is resolved dynamically from PyPI at runtime. No hardcoded version lists. Prefix match supported (`--version=20` → `20.1.8`).
- **Return values**: All hooks return `tuple[int, str]` — `(retval, output)`, where `retval` is the tool's exit code (`0` on success, non-zero on failure; `--dry-run` auto-adds `--Werror` so formatting issues fail).
- **clang-tidy compile DB**: Auto-detected from `build/`, `out/`, `cmake-build-debug/`, `_build/`. Override with `--compile-commands=<dir>`, disable with `--no-compile-commands`.
- **clang-tidy parallel**: `--jobs=N` runs per-file in parallel. Forced serial when `--export-fixes`, `-fix`, or `-fix-errors` is used.
- **clang-tidy output**: Without `--verbose`, the hook adds `-quiet` (unless the user set it) and strips status lines (`N warnings generated.`, `Suppressed N warnings ...`, `Use -header-filter=...`, `[1/N] Processing file ...`). With `--verbose`, output is unchanged.
- **clang-tidy check lists**: `--checks=` / `--warnings-as-errors=` lists that YAML split at commas are joined again, quotes inside the value are removed, and `--checks=<file>` (e.g. `.clang-tidy`) gets a note. Notes are appended to the output, which pre-commit shows when the hook fails.

## CI

Expand Down
47 changes: 36 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,39 @@ leave it out if you only run clang-tidy in CI, for example with

> [!TIP]
> The `rev` tag (e.g. `v1.6.1`) is the **project** version, not the clang tool version. Without
> `--version`, each hook installs the newest clang-format or clang-tidy wheel on PyPI at the time it
> runs, so the tool version can change without any change to your configuration, and the two hooks
> can run different LLVM versions. For production use, always pin the tool version explicitly
> with `--version`.
> `--version`, each hook installs the newest clang-format or clang-tidy wheel on PyPI the first time
> it runs and keeps it until pre-commit rebuilds the hook environment, so two machines can run
> different LLVM versions with the same configuration. For production use, always pin the tool
> version explicitly with `--version`.

- `--version=21` installs the newest 21.x wheel, and `--version=21.1.8` pins an exact release.
clang-tidy wheels are released separately from clang-format wheels and skip some releases, so
give clang-tidy the major version.
- clang-format wheels cover LLVM 6 to 23 and clang-tidy wheels LLVM 13 to 23. For a version without
a wheel, the hook fails and lists some of the versions that exist.
- The hook looks the version up on pypi.org every time it runs. Without network access it fails
when `--version` is set, and otherwise uses the clang-format or clang-tidy already installed.
- When the hook environment already has a wheel that matches `--version` (or any wheel, without
`--version`), the hook uses it and does not contact pypi.org. Otherwise it looks the version up
on pypi.org and installs it, so that run needs network access.

### Offline and pre-commit.ci

To install the clang tool together with the hook, list the exact wheel in
`additional_dependencies`:

```yaml
- id: clang-format
args: [--style=file, --version=21]
additional_dependencies: [clang-format==21.1.8]
- id: clang-tidy
args: [--version=21]
additional_dependencies: [clang-tidy==21.1.6]
```

pre-commit installs `additional_dependencies` when it creates the hook environment (for example
with `pre-commit install-hooks`), so the hooks need no network access when they run. Use this on
[pre-commit.ci](https://pre-commit.ci/), which runs hooks without network access, and behind a
firewall. The pinned wheel must match `--version`; otherwise the hook installs the `--version` one
at run time.

### clang-format

Expand Down Expand Up @@ -115,7 +136,9 @@ int main() {for (;;) break; printf("Hello world!\n");return 0;}
### clang-tidy

To set the checks in `args` instead of your `.clang-tidy` file, quote the whole option: inside
`[...]`, YAML splits an unquoted value at each comma.
`[...]`, YAML splits an unquoted value at each comma. When that happens, the hook joins the pieces
again and adds a note on how to fix the configuration. `--checks` takes check names, not a file:
clang-tidy reads the nearest `.clang-tidy` file by itself, so `--checks=.clang-tidy` does nothing.

```yaml
- id: clang-tidy
Expand All @@ -129,16 +152,17 @@ clang-tidy...............................................................Failed
- hook id: clang-tidy
- exit code: 1

522 warnings generated.
Suppressed 521 warnings (521 in non-user code).
Use -header-filter=.* to display errors from all non-system headers. Use -system-headers to display errors from system headers as well.
/home/runner/work/cpp-linter-hooks/cpp-linter-hooks/testing/main.c:4:13: warning: statement should be inside braces [readability-braces-around-statements]
for (;;)
^
{

```

The hook passes `-quiet` to clang-tidy and drops status lines such as `522 warnings generated.`,
which count warnings in system headers that clang-tidy does not show. Add `--verbose` to `args` to
keep clang-tidy's full output.

> [!NOTE]
> Add `--fix` to `args` to automatically apply clang-tidy fixes in place (equivalent to
> passing `-fix` to clang-tidy directly). This is **opt-in** and **not the default** because
Expand Down Expand Up @@ -253,7 +277,8 @@ This approach ensures that only modified files are checked, further speeding up
> [!NOTE]
> 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).
> For `clang-tidy`, it prints which `compile_commands.json` is being used (when auto-detected or explicitly set)
> and keeps clang-tidy's full output, without `-quiet`.
> pre-commit shows this output only when the hook fails; add `verbose: true` to the hook to see it on every run.

```yaml
Expand Down
5 changes: 3 additions & 2 deletions cpp_linter_hooks/clang_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@
def run_clang_format(args=None) -> tuple[int, str]:
"""Run clang-format with hook-specific arguments removed."""
hook_args, other_args = parser.parse_known_args(args)
_, version_error = resolve_install_with_diagnostics(
tool_path, version_error = resolve_install_with_diagnostics(
"clang-format", hook_args.version, hook_args.verbose
)
if version_error is not None:
return 1, version_error
command = ["clang-format", "-i"]
# Run the executable that was resolved, not whatever comes first on PATH.
command = [str(tool_path) if tool_path else "clang-format", "-i"]

# Add verbose flag if requested
if hook_args.verbose:
Expand Down
173 changes: 169 additions & 4 deletions cpp_linter_hooks/clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Pre-commit hook wrapper for clang-tidy."""

import re
import subprocess
import sys
from argparse import ArgumentParser, ArgumentTypeError
Expand Down Expand Up @@ -43,6 +44,64 @@
- If using CMake, generate compile_commands.json from the same toolchain."""


# Status lines clang-tidy prints around the diagnostics. They say nothing about
# the files being checked (e.g. "537 warnings generated." comes from system
# headers), so the hook drops them unless --verbose is given. clang-tidy 18 and
# older print the "generated" lines even with -quiet.
NOISE_LINE_PATTERNS = (
re.compile(r"^\d+ (?:warning|error)s?(?: and \d+ errors?)? generated\.$"),
re.compile(r"^Suppressed \d+ warnings? \(.*\)\.$"),
re.compile(
r"^Use -header-filter=.* to display errors from all non-system headers\."
),
re.compile(r"^\[\d+/\d+\] Processing file .*\.$"),
)

# Options whose value is a comma-separated list of check globs.
CHECK_GLOB_OPTIONS = (
"--checks=",
"-checks=",
"--warnings-as-errors=",
"-warnings-as-errors=",
)
CHECK_MODULES = (
"abseil",
"altera",
"android",
"boost",
"bugprone",
"cert",
"clang-analyzer",
"clang-diagnostic",
"concurrency",
"cppcoreguidelines",
"darwin",
"fuchsia",
"google",
"hicpp",
"linuxkernel",
"llvm",
"llvmlibc",
"misc",
"modernize",
"mpi",
"objc",
"openmp",
"performance",
"portability",
"readability",
"zircon",
)
# A single check glob such as "bugprone-*", "-modernize-use-trailing-return-type"
# or "*", optionally carrying a stray YAML quote.
CHECK_GLOB_FRAGMENT = re.compile(
r"""^['"]?-?(?:\*[\w.*-]*|(?:"""
+ "|".join(re.escape(module) for module in CHECK_MODULES)
+ r""")-[\w.*-]*)['"]?$"""
)
QUOTES = ("'", '"')


def _positive_int(value: str) -> int:
"""Parse a positive integer for the --jobs option."""
jobs = int(value)
Expand Down Expand Up @@ -210,6 +269,100 @@
return 1, str(e)


def _strip_noise(output: str) -> str:
"""Drop clang-tidy status lines that do not point at a problem."""
lines = output.splitlines()
kept = [
line
for line in lines
if not any(pattern.match(line) for pattern in NOISE_LINE_PATTERNS)
]
if len(kept) == len(lines):
return output
return "\n".join(kept)


def _has_quiet_option(args: list[str]) -> bool:
"""Return whether the user already set clang-tidy's -quiet option."""
return any(
arg in ("-quiet", "--quiet") or arg.startswith(("-quiet=", "--quiet="))
for arg in args
)


def _unquote(value: str) -> str:
"""Remove one pair of matching quotes around a value."""
if len(value) >= 2 and value[0] == value[-1] and value[0] in QUOTES:
return value[1:-1]
return value


def _looks_like_config_file(value: str) -> bool:
"""Return whether a --checks value names a file instead of check globs."""
name = Path(value).name
return name.startswith(".") or name.endswith((".yaml", ".yml")) or "/" in value


def _repair_check_args(args: list[str]) -> tuple[list[str], list[str]]:
"""Fix check lists that YAML mangled in .pre-commit-config.yaml.

Returns (args, notes). Inside ``args: [...]`` YAML splits an unquoted
``--checks=-*,bugprone-*`` at each comma, and clang-tidy would then treat
``bugprone-*`` as a source file. Quotes written inside the value reach
clang-tidy as part of the glob. Both are repaired here; the notes tell the
user how to fix the configuration.
"""
repaired: list[str] = []
notes: list[str] = []
idx = 0
while idx < len(args):
arg = args[idx]
idx += 1
option = next((o for o in CHECK_GLOB_OPTIONS if arg.startswith(o)), None)
if option is None:
repaired.append(arg)
continue

fragments = [arg[len(option) :]]
while idx < len(args) and CHECK_GLOB_FRAGMENT.match(args[idx]):
fragments.append(args[idx])
idx += 1
value = ",".join(fragments)
unquoted = _unquote(value)
fixed = f"{option}{unquoted}"
repaired.append(fixed)

if len(fragments) > 1:
notes.append(
f"Note: YAML split {option}{value} at the commas; the hook joined "
"the pieces again. Quote the whole option in "
f'.pre-commit-config.yaml: args: ["{fixed}"]'
)
elif unquoted != value:
notes.append(
f"Note: removed the quotes inside {arg}; clang-tidy would read them "
"as part of the check names. Quote the whole option instead: "
f'args: ["{fixed}"]'
)

if option.endswith("checks=") and _looks_like_config_file(unquoted):
notes.append(
f"Note: {fixed} has no effect: --checks takes check names, not a "
"file. clang-tidy already reads the nearest .clang-tidy file for "
"each source file, so remove this option, or use "
"--config-file=<path> to pick a specific file."
)
return repaired, notes


def _append_notes(output: str, notes: list[str]) -> str:
"""Append configuration notes after clang-tidy's output."""
if not notes:
return output
separator = "\n\n" if output.rstrip("\n") else ""
return output.rstrip("\n") + separator + "\n".join(notes)


def _looks_like_source_file(path: str) -> bool:
"""Return whether a path has a recognized C or C++ source suffix."""
return Path(path).suffix.lower() in SOURCE_FILE_SUFFIXES
Expand Down Expand Up @@ -248,14 +401,16 @@
return retval, _combine_outputs(results)


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

Check failure on line 404 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 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=cpp-linter_cpp-linter-hooks&issues=AaFfTvDjJHFFdzEgBFUL&open=AaFfTvDjJHFFdzEgBFUL&pullRequest=302
"""Run clang-tidy with hook-specific argument handling."""
hook_args, other_args = parser.parse_known_args(args)
_, version_error = resolve_install_with_diagnostics(
tool_path, version_error = resolve_install_with_diagnostics(
"clang-tidy", hook_args.version, hook_args.verbose
)
if version_error is not None:
return 1, version_error
# Run the executable that was resolved, not whatever comes first on PATH.
executable = str(tool_path) if tool_path else "clang-tidy"

compile_db_path, error = _resolve_compile_db(hook_args, other_args)
if error is not None:
Expand All @@ -273,6 +428,12 @@
print(_compile_commands_not_found_message(), file=sys.stderr)

clang_tidy_args, source_files = _split_source_files(other_args)
clang_tidy_args, notes = _repair_check_args(clang_tidy_args)

# -quiet hides clang-tidy's statistics about suppressed warnings; --verbose
# keeps clang-tidy's full output.
if not hook_args.verbose and not _has_quiet_option(clang_tidy_args):
clang_tidy_args.insert(0, "-quiet")

if (
hook_args.fix
Expand All @@ -293,11 +454,15 @@
)

if hook_args.jobs > 1 and len(source_files) > 1 and not unsafe_parallel:
return _exec_parallel_clang_tidy(
["clang-tidy"] + clang_tidy_args, source_files, hook_args.jobs
retval, output = _exec_parallel_clang_tidy(
[executable] + clang_tidy_args, source_files, hook_args.jobs
)
else:
retval, output = _exec_clang_tidy([executable] + clang_tidy_args + source_files)

return _exec_clang_tidy(["clang-tidy"] + clang_tidy_args + source_files)
if not hook_args.verbose:
output = _strip_noise(output)
return retval, _append_notes(output, notes)


def main() -> int:
Expand Down
Loading
Loading