Skip to content

Commit fbc6270

Browse files
committed
Improve clang hook error diagnostics
1 parent e333f20 commit fbc6270

6 files changed

Lines changed: 374 additions & 50 deletions

File tree

cpp_linter_hooks/clang_format.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
from argparse import ArgumentParser
44
from typing import Tuple
55

6-
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_FORMAT_VERSION
6+
from cpp_linter_hooks.util import resolve_install_with_diagnostics
77

88

99
parser = ArgumentParser()
10-
parser.add_argument("--version", default=DEFAULT_CLANG_FORMAT_VERSION)
10+
parser.add_argument("--version", default=None)
1111
parser.add_argument(
1212
"-v", "--verbose", action="store_true", help="Enable verbose output"
1313
)
1414

1515

1616
def run_clang_format(args=None) -> Tuple[int, str]:
1717
hook_args, other_args = parser.parse_known_args(args)
18-
if hook_args.version:
19-
resolve_install("clang-format", hook_args.version)
18+
_, version_error = resolve_install_with_diagnostics(
19+
"clang-format", hook_args.version, hook_args.verbose
20+
)
21+
if version_error is not None:
22+
return 1, version_error
2023
command = ["clang-format", "-i"]
2124

2225
# Add verbose flag if requested

cpp_linter_hooks/clang_tidy.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from typing import List, Optional, Tuple
77

8-
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION
8+
from cpp_linter_hooks.util import resolve_install_with_diagnostics
99

1010
COMPILE_DB_SEARCH_DIRS = ["build", "out", "cmake-build-debug", "_build"]
1111
SOURCE_FILE_SUFFIXES = {
@@ -28,6 +28,18 @@
2828
".tpp",
2929
".txx",
3030
}
31+
COMPILE_COMMANDS_HINT = """\
32+
Generate compile_commands.json with one of:
33+
CMake: cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
34+
Meson: meson setup builddir
35+
Then run clang-tidy with --compile-commands=build or --compile-commands=builddir."""
36+
37+
MSVC_HINT = """\
38+
Windows/MSVC clang-tidy hints:
39+
- Run from a Visual Studio Developer Command Prompt, or call vcvars64.bat first.
40+
- Make sure the Windows SDK and MSVC include paths are visible in that shell.
41+
- For MSVC-style compile databases, try --extra-arg-before=--driver-mode=cl.
42+
- If using CMake, generate compile_commands.json from the same toolchain."""
3143

3244

3345
def _positive_int(value: str) -> int:
@@ -38,7 +50,7 @@ def _positive_int(value: str) -> int:
3850

3951

4052
parser = ArgumentParser()
41-
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
53+
parser.add_argument("--version", default=None)
4254
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
4355
parser.add_argument(
4456
"--no-compile-commands", action="store_true", dest="no_compile_commands"
@@ -55,6 +67,17 @@ def _find_compile_commands() -> Optional[str]:
5567
return None
5668

5769

70+
def _compile_commands_not_found_message(path: Optional[str] = None) -> str:
71+
if path is None:
72+
return "No compile_commands.json was found in common build directories.\n\n" + (
73+
COMPILE_COMMANDS_HINT
74+
)
75+
return (
76+
f"--compile-commands: no compile_commands.json in '{path}'.\n\n"
77+
f"{COMPILE_COMMANDS_HINT}"
78+
)
79+
80+
5881
def _resolve_compile_db(
5982
hook_args, other_args
6083
) -> Tuple[Optional[str], Optional[Tuple[int, str]]]:
@@ -79,8 +102,7 @@ def _resolve_compile_db(
79102
if not p.is_dir() or not (p / "compile_commands.json").exists():
80103
return None, (
81104
1,
82-
f"--compile-commands: no compile_commands.json"
83-
f" in '{hook_args.compile_commands}'",
105+
_compile_commands_not_found_message(hook_args.compile_commands),
84106
)
85107
return hook_args.compile_commands, None
86108

@@ -90,13 +112,68 @@ def _resolve_compile_db(
90112
return None, None
91113

92114

115+
def _looks_like_compile_db_error(output: str) -> bool:
116+
lower_output = output.lower()
117+
compile_db_error = "compile_commands.json" in lower_output and any(
118+
pattern in lower_output
119+
for pattern in (
120+
"not found",
121+
"no such file",
122+
"missing",
123+
"error",
124+
"could not",
125+
)
126+
)
127+
return compile_db_error or any(
128+
pattern in lower_output
129+
for pattern in (
130+
"error while trying to load a compilation database",
131+
"could not auto-detect compilation database",
132+
"no compilation database found",
133+
)
134+
)
135+
136+
137+
def _looks_like_msvc_error(output: str) -> bool:
138+
lower_output = output.lower()
139+
cl_driver_error = "cl.exe" in lower_output and any(
140+
pattern in lower_output
141+
for pattern in ("not found", "doesn't exist", "unable to execute")
142+
)
143+
msvc_patterns = (
144+
"unable to find a visual studio installation",
145+
"visual studio installation",
146+
"vcruntime.h",
147+
"windows.h' file not found",
148+
"sal.h' file not found",
149+
"msvc",
150+
"unknown argument: '/",
151+
"unsupported option '/",
152+
"argument unused during compilation: '/",
153+
)
154+
return cl_driver_error or any(pattern in lower_output for pattern in msvc_patterns)
155+
156+
157+
def _append_guidance(output: str) -> str:
158+
hints: List[str] = []
159+
if _looks_like_compile_db_error(output) and COMPILE_COMMANDS_HINT not in output:
160+
hints.append(COMPILE_COMMANDS_HINT)
161+
if _looks_like_msvc_error(output) and MSVC_HINT not in output:
162+
hints.append(MSVC_HINT)
163+
if not hints:
164+
return output
165+
separator = "\n\n" if output.rstrip("\n") else ""
166+
return output.rstrip("\n") + separator + "\n\n".join(hints)
167+
168+
93169
def _exec_clang_tidy(command) -> Tuple[int, str]:
94170
"""Run clang-tidy and return (retval, output)."""
95171
try:
96172
sp = subprocess.run(
97173
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
98174
)
99175
output = (sp.stdout or "") + (sp.stderr or "")
176+
output = _append_guidance(output)
100177
retval = (
101178
1 if sp.returncode != 0 or "warning:" in output or "error:" in output else 0
102179
)
@@ -139,8 +216,11 @@ def run_file(source_file: str) -> Tuple[int, str]:
139216

140217
def run_clang_tidy(args=None) -> Tuple[int, str]:
141218
hook_args, other_args = parser.parse_known_args(args)
142-
if hook_args.version:
143-
resolve_install("clang-tidy", hook_args.version)
219+
_, version_error = resolve_install_with_diagnostics(
220+
"clang-tidy", hook_args.version, hook_args.verbose
221+
)
222+
if version_error is not None:
223+
return 1, version_error
144224

145225
compile_db_path, error = _resolve_compile_db(hook_args, other_args)
146226
if error is not None:
@@ -152,6 +232,10 @@ def run_clang_tidy(args=None) -> Tuple[int, str]:
152232
f"Using compile_commands.json from: {compile_db_path}", file=sys.stderr
153233
)
154234
other_args = ["-p", compile_db_path] + other_args
235+
elif hook_args.verbose and not hook_args.no_compile_commands:
236+
has_p = any(a == "-p" or a.startswith("-p=") for a in other_args)
237+
if not has_p:
238+
print(_compile_commands_not_found_message(), file=sys.stderr)
155239

156240
clang_tidy_args, source_files = _split_source_files(other_args)
157241

cpp_linter_hooks/util.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import subprocess
44
from pathlib import Path
55
import logging
6-
from typing import Optional, List
6+
from typing import Optional, List, Tuple
77

88
if sys.version_info >= (3, 11):
99
import tomllib
@@ -34,6 +34,23 @@ def get_version_from_dependency(tool: str) -> Optional[str]:
3434
DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py
3535

3636

37+
def _versions_for_tool(tool: str) -> List[str]:
38+
return CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS
39+
40+
41+
def _default_version_for_tool(tool: str) -> Optional[str]:
42+
return (
43+
DEFAULT_CLANG_FORMAT_VERSION
44+
if tool == "clang-format"
45+
else DEFAULT_CLANG_TIDY_VERSION
46+
)
47+
48+
49+
def _supported_versions_message(tool: str) -> str:
50+
versions = ", ".join(_versions_for_tool(tool))
51+
return f"Supported {tool} wheel versions: {versions}"
52+
53+
3754
def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]:
3855
"""Resolve the latest matching version based on user input and available versions."""
3956
if user_input is None:
@@ -59,6 +76,23 @@ def parse_version(v: str):
5976
return None
6077

6178

79+
def resolve_tool_version(
80+
tool: str, version: Optional[str]
81+
) -> Tuple[Optional[str], Optional[str]]:
82+
"""Resolve a requested tool version or return a user-facing error message."""
83+
if version is None:
84+
return _default_version_for_tool(tool), None
85+
86+
resolved = _resolve_version(_versions_for_tool(tool), version)
87+
if resolved is None:
88+
return (
89+
None,
90+
f"Unsupported {tool} version '{version}'.\n"
91+
f"{_supported_versions_message(tool)}",
92+
)
93+
return resolved, None
94+
95+
6296
def _is_version_installed(tool: str, version: str) -> Optional[Path]:
6397
"""Return the tool path if the installed version matches, otherwise None."""
6498
existing = shutil.which(tool)
@@ -85,19 +119,39 @@ def _install_tool(tool: str, version: str) -> Optional[Path]:
85119
return None
86120

87121

88-
def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
89-
"""Resolve the installation of a tool, checking for version and installing if necessary."""
90-
user_version = _resolve_version(
91-
CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS,
92-
version,
122+
def resolve_install_with_diagnostics(
123+
tool: str, version: Optional[str], verbose: bool = False
124+
) -> Tuple[Optional[Path], Optional[str]]:
125+
"""Resolve/install a tool, returning a user-facing error for bad versions."""
126+
user_version, error = resolve_tool_version(tool, version)
127+
if error is not None:
128+
return None, error
129+
130+
if verbose:
131+
if version is None:
132+
print(
133+
f"Using default {tool} Python wheel version {user_version}",
134+
file=sys.stderr,
135+
)
136+
elif version == user_version:
137+
print(f"Using {tool} Python wheel version {user_version}", file=sys.stderr)
138+
else:
139+
print(
140+
f"Resolved {tool} --version={version} to Python wheel version "
141+
f"{user_version}",
142+
file=sys.stderr,
143+
)
144+
145+
return (
146+
_is_version_installed(tool, user_version)
147+
or _install_tool(tool, user_version),
148+
None,
93149
)
94-
if user_version is None:
95-
user_version = (
96-
DEFAULT_CLANG_FORMAT_VERSION
97-
if tool == "clang-format"
98-
else DEFAULT_CLANG_TIDY_VERSION
99-
)
100150

101-
return _is_version_installed(tool, user_version) or _install_tool(
102-
tool, user_version
103-
)
151+
152+
def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
153+
"""Resolve/install a tool, logging bad-version diagnostics."""
154+
path, error = resolve_install_with_diagnostics(tool, version)
155+
if error is not None:
156+
LOG.error(error)
157+
return path

tests/test_clang_format.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from pathlib import Path
3+
from unittest.mock import patch
34

45
from cpp_linter_hooks.clang_format import run_clang_format
56

@@ -103,3 +104,35 @@ def test_run_clang_format_verbose_error(tmp_path):
103104
assert ret != 0
104105
# Should have error message in output
105106
assert "Invalid value for -style" in output
107+
108+
109+
def test_run_clang_format_invalid_version_returns_supported_versions():
110+
with patch(
111+
"cpp_linter_hooks.clang_format.resolve_install_with_diagnostics",
112+
return_value=(
113+
None,
114+
"Unsupported clang-format version '99'.\nSupported versions",
115+
),
116+
):
117+
ret, output = run_clang_format(["--version=99", "dummy.cpp"])
118+
119+
assert ret == 1
120+
assert "Unsupported clang-format version '99'" in output
121+
assert "Supported versions" in output
122+
123+
124+
def test_run_clang_format_verbose_passes_version_diagnostics():
125+
with (
126+
patch(
127+
"cpp_linter_hooks.clang_format.resolve_install_with_diagnostics",
128+
return_value=(None, None),
129+
) as mock_resolve,
130+
patch("cpp_linter_hooks.clang_format.subprocess.run") as mock_run,
131+
):
132+
mock_run.return_value.returncode = 0
133+
mock_run.return_value.stdout = ""
134+
mock_run.return_value.stderr = ""
135+
ret, output = run_clang_format(["--verbose", "--version=21", "dummy.cpp"])
136+
137+
assert (ret, output) == (0, "")
138+
mock_resolve.assert_called_once_with("clang-format", "21", True)

0 commit comments

Comments
 (0)