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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@ jobs:
verbose: true # optional (default = false)
- name: Test cpp-linter-hooks
run: bash testing/run.sh
- name: Test cpp-linter-hooks with prek
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
run: bash testing/run.sh
env:
RUNNER: prek
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
[![part of cpp-linter](https://img.shields.io/badge/part%20of-cpp--linter-ffc20a?labelColor=454a63)](https://cpp-linter.github.io/)

[pre-commit](https://pre-commit.com/) hooks that pip-install the clang-format and clang-tidy
version you pin, on every developer's machine.
version you pin, on every developer's machine. They also run with
[prek](https://prek.j178.dev/), which the tests cover too.

[Website](https://cpp-linter.github.io/) ·
[Get started](https://cpp-linter.github.io/getting-started/#before-every-commit) ·
Expand Down Expand Up @@ -43,7 +44,7 @@ This uses the newest clang-format and clang-tidy. To choose a version, add `--ve

### Clang tool version

The `rev` tag (e.g. `v1.8.1`) is the version of these hooks, not of clang-format or clang-tidy.
The `rev` tag is the version of these hooks, not of clang-format or clang-tidy.
Choose the clang tool version with `--version`:

```yaml
Expand Down Expand Up @@ -142,6 +143,24 @@ int main() {for (;;) break; printf("Hello world!\n");return 0;}
^
```

#### Format only changed lines

By default the hook formats whole files. In a code base that was never formatted, that turns a
one-line change into a large diff. Add `--lines-changed-only` to format only the lines that the
commit adds or changes:

```yaml
- id: clang-format
args: [--style=file, --lines-changed-only]
```

The hook reads the changes with `git diff`, in the same way as the clang-tidy hook (see
[Only changed lines](https://github.com/cpp-linter/cpp-linter-hooks#only-changed-lines)), and
passes them to clang-format's `--lines`. clang-format can still touch a nearby line, because it
formats whole statements. The hook formats every line when it cannot tell which lines changed,
for example with `pre-commit run --all-files` on a clean working tree, and when `args` already
has `--lines`.

### clang-tidy

clang-tidy 22 and newer enable no checks by themselves. Without a `.clang-tidy` file or
Expand Down Expand Up @@ -369,6 +388,7 @@ mirror of the clang-format wheel.
| Supports passing format style string | via `--style` | via `--style` |
| Verbose output | via `--verbose` | via `--verbose` |
| Dry-run mode | via `--dry-run` | via `--dry-run --Werror` |
| Format only changed lines | via `--lines-changed-only` | No |
| Auto-fix mode | via `--fix` (clang-tidy only) | No |
| Compilation database support | auto-detect or `--compile-commands` | No |

Expand Down
92 changes: 69 additions & 23 deletions cpp_linter_hooks/clang_format.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Pre-commit hook wrapper for clang-format."""

import os
import subprocess
import sys
from argparse import ArgumentParser

from cpp_linter_hooks.changed_lines import changed_lines
from cpp_linter_hooks.util import file_argument, resolve_install_with_diagnostics

parser = ArgumentParser()
# No abbreviations: clang-format's own --lines must not be read as a short
# form of --lines-changed-only.
parser = ArgumentParser(allow_abbrev=False)
parser.add_argument(
"--version",
default=None,
Expand All @@ -18,6 +22,48 @@
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
parser.add_argument(
"--lines-changed-only",
action="store_true",
help=(
"Format only the lines that the commit or push changes; every line "
"is formatted when the changed lines are unknown, for example with "
"--all-files"
),
)


def _split_files(args: list[str]) -> tuple[list[str], list[str]]:
"""Split clang-format options from the files that pre-commit appends.

pre-commit passes only existing files, so the files are the existing files
at the end. An option that takes its value as the next argument, such as
``--style Google``, ends them because the value is not a file.
"""
split = len(args)
while split > 0 and os.path.isfile(args[split - 1]):
split -= 1
return args[:split], args[split:]


def _changed_lines_commands(command: list[str], args: list[str]) -> list[list[str]]:
"""Return one clang-format command per file, limited to its changed lines.

clang-format takes ``--lines`` for one file at a time. A file with no
added lines needs no run. When the changed lines are unknown, or the
arguments already name lines, the one command for all files is returned.
"""
options, files = _split_files(args)
if any(a.lstrip("-").split("=")[0] == "lines" for a in options):
return [[*command, *args]]
lines = changed_lines(files)
if lines is None:
return [[*command, *args]]
return [
[*command, *options, *(f"--lines={a}:{b}" for a, b in lines[f]), f]
for f in files
if lines[f]
]


def run_clang_format(args=None) -> tuple[int, str]:
Expand All @@ -43,32 +89,32 @@
):
command.append("--Werror")

command.extend(other_args)
commands = (
_changed_lines_commands(command, other_args)
if hook_args.lines_changed_only
else [[*command, *other_args]]
)

retval, output = 0, ""
try:
# Run the clang-format command with captured output
sp = subprocess.run(
command,
capture_output=True,
encoding="utf-8",
# clang-format prints source lines, which may not be UTF-8.
errors="replace",
check=False,
)

# Combine stdout and stderr for complete output
output = (sp.stdout or "") + (sp.stderr or "")

retval = sp.returncode

# Print verbose information if requested
if hook_args.verbose:
_print_verbose_info(command, retval, output)

return retval, output

for command in commands:
sp = subprocess.run(
command,
capture_output=True,
encoding="utf-8",
# clang-format prints source lines, which may not be UTF-8.
errors="replace",
check=False,
)

Check failure on line 108 in cpp_linter_hooks/clang_format.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Command Injection via faulty LLM-supplied CLI arguments in clang_format.run_clang_format()

See more on https://sonarcloud.io/project/issues?id=cpp-linter_cpp-linter-hooks&issues=AaHFiKnN_wxgQOln4JIW&open=AaHFiKnN_wxgQOln4JIW&pullRequest=327

Check failure on line 108 in cpp_linter_hooks/clang_format.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Command Argument Injection via faulty LLM-supplied CLI arguments in clang_format.run_clang_format()

See more on https://sonarcloud.io/project/issues?id=cpp-linter_cpp-linter-hooks&issues=AaHFiKnN_wxgQOln4JIX&open=AaHFiKnN_wxgQOln4JIX&pullRequest=327
Comment thread
shenxianpeng marked this conversation as resolved.
Resolved
Comment thread
shenxianpeng marked this conversation as resolved.
Resolved
# Combine stdout and stderr for complete output
command_output = (sp.stdout or "") + (sp.stderr or "")
if hook_args.verbose:
_print_verbose_info(command, sp.returncode, command_output)
retval = retval or sp.returncode
output += command_output
except FileNotFoundError as e:
return 1, str(e)
return retval, output


def _print_verbose_info(command: list, retval: int, output: str) -> None:
Expand Down
22 changes: 19 additions & 3 deletions testing/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

set -euo pipefail

# The tool that runs the hooks: pre-commit, or prek with RUNNER=prek.
RUNNER="${RUNNER:-pre-commit}"

PASS=0
FAIL=0

Expand All @@ -27,16 +30,29 @@ run_test() {
local files="$3"
local expect_nonzero="$4"

echo "---- $description ----"
uvx pre-commit clean
echo "---- $description ($RUNNER) ----"
uvx "$RUNNER" clean

# prek reads "repo: ." relative to the config file and pre-commit relative
# to the current directory, so the config runs from the repository root.
local root_config
root_config="$(mktemp ./.run-config.XXXXXX)"
cp "$config" "$root_config"

local output
# shellcheck disable=SC2086 # word-splitting of $files is intentional
if output=$(uvx pre-commit run -c "$config" --files $files 2>&1); then
if output=$(uvx "$RUNNER" run -c "$root_config" --files $files 2>&1); then
local got_nonzero=false
else
local got_nonzero=true
fi
rm -f "$root_config"

# A config or install error exits non-zero too, so a run counts only when
# a hook reports Passed or Failed.
if ! grep -qE '\.(Passed|Failed)$' <<<"$output"; then
got_nonzero="no hook ran"
fi

git restore testing/main.c 2>/dev/null || true

Expand Down
158 changes: 158 additions & 0 deletions tests/test_clang_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest

from cpp_linter_hooks import clang_format
from cpp_linter_hooks.clang_format import main, parser, run_clang_format


Expand Down Expand Up @@ -361,3 +362,160 @@ def test_run_clang_format_uses_the_resolved_executable(mock_clang_format):
run_clang_format(["a.cpp"])

assert mock_run.call_args.args[0] == [str(tool), "-i", "a.cpp"]


# --- --lines-changed-only ---


@pytest.fixture
def source_files(tmp_path, monkeypatch):
"""Run in a directory with the files that pre-commit would pass."""
monkeypatch.chdir(tmp_path)
for name in ("a.cpp", "b.cpp", "c.cpp", "a.proto"):
(tmp_path / name).write_text("")


def test_lines_changed_only_runs_once_per_file_with_its_lines(
mock_clang_format, monkeypatch, source_files
):
_, mock_run = mock_clang_format
monkeypatch.setattr(
clang_format,
"changed_lines",
lambda files: {"a.cpp": [[3, 3], [7, 8]], "b.cpp": [[1, 2]], "c.cpp": []},
)

assert run_clang_format(
["--lines-changed-only", "--style=Google", "a.cpp", "b.cpp", "c.cpp"]
) == (0, "")

# c.cpp has no added lines, so it is not formatted.
assert [call.args[0] for call in mock_run.call_args_list] == [
["clang-format", "-i", "--style=Google", "--lines=3:3", "--lines=7:8", "a.cpp"],
["clang-format", "-i", "--style=Google", "--lines=1:2", "b.cpp"],
]


def test_lines_changed_only_formats_every_line_when_the_lines_are_unknown(
mock_clang_format, monkeypatch, source_files
):
_, mock_run = mock_clang_format
monkeypatch.setattr(clang_format, "changed_lines", lambda files: None)

run_clang_format(["--lines-changed-only", "--style=Google", "a.cpp", "b.cpp"])

mock_run.assert_called_once()
assert mock_run.call_args.args[0] == [
"clang-format",
"-i",
"--style=Google",
"a.cpp",
"b.cpp",
]


@pytest.mark.parametrize("lines", (["--lines=1:2"], ["-lines=1:2"], ["--lines", "1:2"]))
def test_lines_changed_only_keeps_lines_given_in_args(
mock_clang_format, monkeypatch, lines, source_files
):
_, mock_run = mock_clang_format
monkeypatch.setattr(
clang_format, "changed_lines", lambda files: {"a.cpp": [[5, 5]]}
)

run_clang_format(["--lines-changed-only", *lines, "a.cpp"])

mock_run.assert_called_once()
assert mock_run.call_args.args[0] == ["clang-format", "-i", *lines, "a.cpp"]


def test_lines_changed_only_runs_nothing_without_added_lines(
mock_clang_format, monkeypatch, source_files
):
_, mock_run = mock_clang_format
monkeypatch.setattr(clang_format, "changed_lines", lambda files: {"a.cpp": []})

assert run_clang_format(["--lines-changed-only", "a.cpp"]) == (0, "")

mock_run.assert_not_called()


def test_lines_changed_only_reports_every_failing_file(
mock_clang_format, monkeypatch, source_files
):
_, mock_run = mock_clang_format
monkeypatch.setattr(
clang_format,
"changed_lines",
lambda files: {name: [[1, 1]] for name in files},
)
mock_run.side_effect = [
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="b\n"),
subprocess.CompletedProcess(args=[], returncode=2, stdout="", stderr="c\n"),
]

assert run_clang_format(
["--lines-changed-only", "--dry-run", "a.cpp", "b.cpp", "c.cpp"]
) == (1, "b\nc\n")

assert mock_run.call_args.args[0] == [
"clang-format",
"-i",
"--Werror",
"--dry-run",
"--lines=1:1",
"c.cpp",
]


def test_lines_changed_only_runs_a_first_file_of_another_type_alone(
mock_clang_format, monkeypatch, source_files
):
# types_or can add files such as a.proto; one that comes right after a
# flag must still be a file, or two files get one --lines.
_, mock_run = mock_clang_format
monkeypatch.setattr(
clang_format, "changed_lines", lambda files: {f: [[1, 1]] for f in files}
)

run_clang_format(["--lines-changed-only", "--dry-run", "a.proto", "b.cpp"])

assert [call.args[0][-2:] for call in mock_run.call_args_list] == [
["--lines=1:1", "a.proto"],
["--lines=1:1", "b.cpp"],
]


def test_lines_changed_only_formats_only_the_staged_lines(tmp_path, monkeypatch):
"""The real clang-format leaves the unformatted lines of older commits alone."""

def git(*args):
subprocess.run(
[
"git",
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
*args,
],
cwd=tmp_path,
check=True,
capture_output=True,
)

source = tmp_path / "a.c"
source.write_text("int old = 1;\n\nint other = 2;\n")
git("init", "-q")
git("add", "a.c")
git("commit", "-q", "-m", "init")
source.write_text("int old = 1;\n\nint added = 3;\n")
git("add", "a.c")
monkeypatch.chdir(tmp_path)

assert run_clang_format(["--lines-changed-only", "--style=LLVM", "a.c"]) == (0, "")

assert source.read_text() == "int old = 1;\n\nint added = 3;\n"
Loading