diff --git a/cpp_linter_hooks/clang_format.py b/cpp_linter_hooks/clang_format.py index da891e3..bc99d4b 100644 --- a/cpp_linter_hooks/clang_format.py +++ b/cpp_linter_hooks/clang_format.py @@ -3,11 +3,11 @@ from argparse import ArgumentParser from typing import Tuple -from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_FORMAT_VERSION +from cpp_linter_hooks.util import resolve_install_with_diagnostics parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_FORMAT_VERSION) +parser.add_argument("--version", default=None) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose output" ) @@ -15,8 +15,11 @@ def run_clang_format(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - if hook_args.version: - resolve_install("clang-format", hook_args.version) + _, 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"] # Add verbose flag if requested diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index 64676b8..5f1efa9 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import List, Optional, Tuple -from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION +from cpp_linter_hooks.util import resolve_install_with_diagnostics COMPILE_DB_SEARCH_DIRS = ["build", "out", "cmake-build-debug", "_build"] SOURCE_FILE_SUFFIXES = { @@ -28,6 +28,18 @@ ".tpp", ".txx", } +COMPILE_COMMANDS_HINT = """\ +Generate compile_commands.json with one of: + CMake: cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + Meson: meson setup builddir +Then run clang-tidy with --compile-commands=build or --compile-commands=builddir.""" + +MSVC_HINT = """\ +Windows/MSVC clang-tidy hints: + - Run from a Visual Studio Developer Command Prompt, or call vcvars64.bat first. + - Make sure the Windows SDK and MSVC include paths are visible in that shell. + - For MSVC-style compile databases, try --extra-arg-before=--driver-mode=cl. + - If using CMake, generate compile_commands.json from the same toolchain.""" def _positive_int(value: str) -> int: @@ -38,7 +50,7 @@ def _positive_int(value: str) -> int: parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION) +parser.add_argument("--version", default=None) parser.add_argument("--compile-commands", default=None, dest="compile_commands") parser.add_argument( "--no-compile-commands", action="store_true", dest="no_compile_commands" @@ -55,6 +67,17 @@ def _find_compile_commands() -> Optional[str]: return None +def _compile_commands_not_found_message(path: Optional[str] = None) -> str: + if path is None: + return "No compile_commands.json was found in common build directories.\n\n" + ( + COMPILE_COMMANDS_HINT + ) + return ( + f"--compile-commands: no compile_commands.json in '{path}'.\n\n" + f"{COMPILE_COMMANDS_HINT}" + ) + + def _resolve_compile_db( hook_args, other_args ) -> Tuple[Optional[str], Optional[Tuple[int, str]]]: @@ -79,8 +102,7 @@ def _resolve_compile_db( if not p.is_dir() or not (p / "compile_commands.json").exists(): return None, ( 1, - f"--compile-commands: no compile_commands.json" - f" in '{hook_args.compile_commands}'", + _compile_commands_not_found_message(hook_args.compile_commands), ) return hook_args.compile_commands, None @@ -90,6 +112,60 @@ def _resolve_compile_db( return None, None +def _looks_like_compile_db_error(output: str) -> bool: + lower_output = output.lower() + compile_db_error = "compile_commands.json" in lower_output and any( + pattern in lower_output + for pattern in ( + "not found", + "no such file", + "missing", + "error", + "could not", + ) + ) + return compile_db_error or any( + pattern in lower_output + for pattern in ( + "error while trying to load a compilation database", + "could not auto-detect compilation database", + "no compilation database found", + ) + ) + + +def _looks_like_msvc_error(output: str) -> bool: + lower_output = output.lower() + cl_driver_error = "cl.exe" in lower_output and any( + pattern in lower_output + for pattern in ("not found", "doesn't exist", "unable to execute") + ) + msvc_patterns = ( + "unable to find a visual studio installation", + "visual studio installation", + "vcruntime.h", + "windows.h' file not found", + "sal.h' file not found", + "msvc", + "unknown argument: '/", + "unsupported option '/", + "argument unused during compilation: '/", + ) + return cl_driver_error or any(pattern in lower_output for pattern in msvc_patterns) + + +def _append_guidance(output: str) -> str: + hints: List[str] = [] + if _looks_like_compile_db_error(output) and COMPILE_COMMANDS_HINT not in output: + hints.append(COMPILE_COMMANDS_HINT) + if _looks_like_msvc_error(output) and MSVC_HINT not in output: + hints.append(MSVC_HINT) + if not hints: + return output + separator = "\n\n" if output.rstrip("\n") else "" + return output.rstrip("\n") + separator + "\n\n".join(hints) + + def _exec_clang_tidy(command) -> Tuple[int, str]: """Run clang-tidy and return (retval, output).""" try: @@ -97,6 +173,7 @@ def _exec_clang_tidy(command) -> Tuple[int, str]: command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8" ) output = (sp.stdout or "") + (sp.stderr or "") + output = _append_guidance(output) retval = ( 1 if sp.returncode != 0 or "warning:" in output or "error:" in output else 0 ) @@ -139,8 +216,11 @@ def run_file(source_file: str) -> Tuple[int, str]: def run_clang_tidy(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - if hook_args.version: - resolve_install("clang-tidy", hook_args.version) + _, version_error = resolve_install_with_diagnostics( + "clang-tidy", hook_args.version, hook_args.verbose + ) + if version_error is not None: + return 1, version_error compile_db_path, error = _resolve_compile_db(hook_args, other_args) if error is not None: @@ -152,6 +232,10 @@ def run_clang_tidy(args=None) -> Tuple[int, str]: f"Using compile_commands.json from: {compile_db_path}", file=sys.stderr ) other_args = ["-p", compile_db_path] + other_args + elif hook_args.verbose and not hook_args.no_compile_commands: + has_p = any(a == "-p" or a.startswith("-p=") for a in other_args) + if not has_p: + print(_compile_commands_not_found_message(), file=sys.stderr) clang_tidy_args, source_files = _split_source_files(other_args) diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 7530914..781a23b 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -3,7 +3,7 @@ import subprocess from pathlib import Path import logging -from typing import Optional, List +from typing import Optional, List, Tuple if sys.version_info >= (3, 11): import tomllib @@ -34,6 +34,23 @@ def get_version_from_dependency(tool: str) -> Optional[str]: DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py +def _versions_for_tool(tool: str) -> List[str]: + return CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS + + +def _default_version_for_tool(tool: str) -> Optional[str]: + return ( + DEFAULT_CLANG_FORMAT_VERSION + if tool == "clang-format" + else DEFAULT_CLANG_TIDY_VERSION + ) + + +def _supported_versions_message(tool: str) -> str: + versions = ", ".join(_versions_for_tool(tool)) + return f"Supported {tool} wheel versions: {versions}" + + def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: """Resolve the latest matching version based on user input and available versions.""" if user_input is None: @@ -59,6 +76,23 @@ def parse_version(v: str): return None +def resolve_tool_version( + tool: str, version: Optional[str] +) -> Tuple[Optional[str], Optional[str]]: + """Resolve a requested tool version or return a user-facing error message.""" + if version is None: + return _default_version_for_tool(tool), None + + resolved = _resolve_version(_versions_for_tool(tool), version) + if resolved is None: + return ( + None, + f"Unsupported {tool} version '{version}'.\n" + f"{_supported_versions_message(tool)}", + ) + return resolved, None + + def _is_version_installed(tool: str, version: str) -> Optional[Path]: """Return the tool path if the installed version matches, otherwise None.""" existing = shutil.which(tool) @@ -85,19 +119,38 @@ def _install_tool(tool: str, version: str) -> Optional[Path]: return None -def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]: - """Resolve the installation of a tool, checking for version and installing if necessary.""" - user_version = _resolve_version( - CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS, - version, +def resolve_install_with_diagnostics( + tool: str, version: Optional[str], verbose: bool = False +) -> Tuple[Optional[Path], Optional[str]]: + """Resolve/install a tool, returning a user-facing error for bad versions.""" + user_version, error = resolve_tool_version(tool, version) + if error is not None: + return None, error + + if verbose: + if version is None: + print( + f"Using default {tool} Python wheel version {user_version}", + file=sys.stderr, + ) + elif version == user_version: + print(f"Using {tool} Python wheel version {user_version}", file=sys.stderr) + else: + print( + f"Resolved {tool} --version={version} to Python wheel version " + f"{user_version}", + file=sys.stderr, + ) + + return ( + _is_version_installed(tool, user_version) or _install_tool(tool, user_version), + None, ) - if user_version is None: - user_version = ( - DEFAULT_CLANG_FORMAT_VERSION - if tool == "clang-format" - else DEFAULT_CLANG_TIDY_VERSION - ) - return _is_version_installed(tool, user_version) or _install_tool( - tool, user_version - ) + +def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]: + """Resolve/install a tool, logging bad-version diagnostics.""" + path, error = resolve_install_with_diagnostics(tool, version) + if error is not None: + LOG.error(error) + return path diff --git a/tests/test_clang_format.py b/tests/test_clang_format.py index 84ddd57..9eb8901 100644 --- a/tests/test_clang_format.py +++ b/tests/test_clang_format.py @@ -1,5 +1,6 @@ import pytest from pathlib import Path +from unittest.mock import patch from cpp_linter_hooks.clang_format import run_clang_format @@ -103,3 +104,35 @@ def test_run_clang_format_verbose_error(tmp_path): assert ret != 0 # Should have error message in output assert "Invalid value for -style" in output + + +def test_run_clang_format_invalid_version_returns_supported_versions(): + with patch( + "cpp_linter_hooks.clang_format.resolve_install_with_diagnostics", + return_value=( + None, + "Unsupported clang-format version '99'.\nSupported versions", + ), + ): + ret, output = run_clang_format(["--version=99", "dummy.cpp"]) + + assert ret == 1 + assert "Unsupported clang-format version '99'" in output + assert "Supported versions" in output + + +def test_run_clang_format_verbose_passes_version_diagnostics(): + with ( + patch( + "cpp_linter_hooks.clang_format.resolve_install_with_diagnostics", + return_value=(None, None), + ) as mock_resolve, + patch("cpp_linter_hooks.clang_format.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = "" + mock_run.return_value.stderr = "" + ret, output = run_clang_format(["--verbose", "--version=21", "dummy.cpp"]) + + assert (ret, output) == (0, "") + mock_resolve.assert_called_once_with("clang-format", "21", True) diff --git a/tests/test_clang_tidy.py b/tests/test_clang_tidy.py index e47f4f0..3fa5a2f 100644 --- a/tests/test_clang_tidy.py +++ b/tests/test_clang_tidy.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock -from cpp_linter_hooks.clang_tidy import run_clang_tidy +from cpp_linter_hooks.clang_tidy import _exec_clang_tidy, run_clang_tidy @pytest.fixture(scope="function") @@ -65,7 +65,10 @@ def test_run_clang_tidy_invalid(args, expected_retval, tmp_path): def _patch(): return ( patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN), - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ) @@ -77,7 +80,10 @@ def test_compile_commands_explicit(tmp_path): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy([f"--compile-commands={db_dir}", "dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -94,7 +100,10 @@ def test_compile_commands_auto_detect(tmp_path, monkeypatch): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -112,7 +121,10 @@ def test_compile_commands_auto_detect_fallback(tmp_path, monkeypatch): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -126,7 +138,10 @@ def test_compile_commands_none(tmp_path, monkeypatch): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -143,7 +158,10 @@ def test_compile_commands_conflict_guard(tmp_path, monkeypatch): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["-p", "./custom", "dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -161,7 +179,10 @@ def test_compile_commands_no_flag(tmp_path, monkeypatch): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--no-compile-commands", "dummy.cpp"]) cmd = mock_run.call_args[0][0] @@ -171,18 +192,28 @@ def test_compile_commands_no_flag(tmp_path, monkeypatch): 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"): + with patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ): ret, output = run_clang_tidy([f"--compile-commands={fake_dir}", "dummy.cpp"]) assert ret == 1 assert "nonexistent" in output + assert "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON" in output + assert "meson setup builddir" 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"): + with patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ): ret, output = run_clang_tidy([f"--compile-commands={empty_dir}", "dummy.cpp"]) assert ret == 1 assert "empty_build" in output + assert "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON" in output + assert "meson setup builddir" in output def test_compile_commands_explicit_with_p_conflict(tmp_path, capsys): @@ -194,7 +225,10 @@ def test_compile_commands_explicit_with_p_conflict(tmp_path, capsys): patch( "cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN ) as mock_run, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy([f"--compile-commands={db_dir}", "-p", "./other", "dummy.cpp"]) captured = capsys.readouterr() @@ -211,12 +245,76 @@ def test_verbose_prints_compile_db_path(tmp_path, monkeypatch, capsys): (build_dir / "compile_commands.json").write_text("[]") with ( patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN), - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--verbose", "dummy.cpp"]) assert "build" in capsys.readouterr().err +def test_verbose_prints_compile_db_generation_hint_when_not_found( + tmp_path, monkeypatch, capsys +): + monkeypatch.chdir(tmp_path) + with ( + patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), + ): + run_clang_tidy(["--verbose", "dummy.cpp"]) + + stderr = capsys.readouterr().err + assert "No compile_commands.json was found" in stderr + assert "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON" in stderr + assert "meson setup builddir" in stderr + + +def test_invalid_version_returns_supported_versions(): + with patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, "Unsupported clang-tidy version '99'.\nSupported versions"), + ): + ret, output = run_clang_tidy(["--version=99", "dummy.cpp"]) + + assert ret == 1 + assert "Unsupported clang-tidy version '99'" in output + assert "Supported versions" in output + + +def test_exec_clang_tidy_appends_compile_db_hint(): + completed = MagicMock( + returncode=1, + stdout="", + stderr="Error while trying to load a compilation database: missing\n", + ) + with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=completed): + ret, output = _exec_clang_tidy(["clang-tidy", "-p", "missing", "a.cpp"]) + + assert ret == 1 + assert "Error while trying to load a compilation database" in output + assert "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON" in output + assert "meson setup builddir" in output + + +def test_exec_clang_tidy_appends_msvc_hint(): + completed = MagicMock( + returncode=1, + stdout="", + stderr="fatal error: 'vcruntime.h' file not found\n", + ) + with patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=completed): + ret, output = _exec_clang_tidy(["clang-tidy", "a.cpp"]) + + assert ret == 1 + assert "Windows/MSVC clang-tidy hints" in output + assert "Visual Studio Developer Command Prompt" in output + assert "--extra-arg-before=--driver-mode=cl" in output + + def test_no_verbose_no_extra_stderr(tmp_path, monkeypatch, capsys): monkeypatch.chdir(tmp_path) build_dir = tmp_path / "build" @@ -224,7 +322,10 @@ def test_no_verbose_no_extra_stderr(tmp_path, monkeypatch, capsys): (build_dir / "compile_commands.json").write_text("[]") with ( patch("cpp_linter_hooks.clang_tidy.subprocess.run", return_value=_MOCK_RUN), - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["dummy.cpp"]) assert capsys.readouterr().err == "" @@ -235,7 +336,10 @@ def test_jobs_one_keeps_single_invocation(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--jobs=1", "-p", "./build", "a.cpp", "b.cpp"]) @@ -252,7 +356,10 @@ def fake_exec(command): with ( patch("cpp_linter_hooks.clang_tidy._exec_clang_tidy", side_effect=fake_exec), - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): ret, output = run_clang_tidy( [ @@ -274,7 +381,10 @@ def test_jobs_parallelizes_only_trailing_source_files(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy( [ @@ -299,7 +409,10 @@ def test_jobs_with_export_fixes_forces_serial_execution(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy( [ @@ -331,7 +444,10 @@ def test_fix_flag_appends_fix_to_command(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--fix", "-p", "./build", "dummy.cpp"]) @@ -345,7 +461,10 @@ def test_fix_flag_forces_serial_execution(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--fix", "--jobs=4", "-p", "./build", "a.cpp", "b.cpp"]) @@ -361,7 +480,10 @@ def test_fix_errors_in_args_forces_serial_execution(): patch( "cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "") ) as mock_exec, - patch("cpp_linter_hooks.clang_tidy.resolve_install"), + patch( + "cpp_linter_hooks.clang_tidy.resolve_install_with_diagnostics", + return_value=(None, None), + ), ): run_clang_tidy(["--jobs=4", "-p", "./build", "-fix-errors", "a.cpp", "b.cpp"]) diff --git a/tests/test_util.py b/tests/test_util.py index 22c766b..a326df3 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,6 +9,7 @@ _resolve_version, _is_version_installed, _install_tool, + resolve_install_with_diagnostics, resolve_install, DEFAULT_CLANG_FORMAT_VERSION, DEFAULT_CLANG_TIDY_VERSION, @@ -305,12 +306,39 @@ def test_resolve_install_invalid_version(): ) as mock_install, ): result = resolve_install("clang-format", "invalid.version") - assert result == Path("/usr/bin/clang-format") + assert result is None - # Should fallback to default version - mock_install.assert_called_once_with( - "clang-format", DEFAULT_CLANG_FORMAT_VERSION - ) + mock_install.assert_not_called() + + +@pytest.mark.benchmark +def test_resolve_install_with_diagnostics_invalid_version_lists_supported_versions(): + path, error = resolve_install_with_diagnostics("clang-tidy", "99") + + assert path is None + assert error is not None + assert "Unsupported clang-tidy version '99'" in error + assert "Supported clang-tidy wheel versions:" in error + assert CLANG_TIDY_VERSIONS[-1] in error + + +@pytest.mark.benchmark +def test_resolve_install_with_diagnostics_verbose_prints_resolved_version(capsys): + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-tidy"), + ), + ): + path, error = resolve_install_with_diagnostics("clang-tidy", "21", True) + + assert path == Path("/usr/bin/clang-tidy") + assert error is None + assert ( + "Resolved clang-tidy --version=21 to Python wheel version 21.1.6" + in capsys.readouterr().err + ) # Tests for constants and defaults