diff --git a/.gitignore b/.gitignore index c7a7c37..ab1c8ff 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ result.txt testing/main.c */*compile_commands.json -# Ignore clang-tools binaries +# Ignore Python wheel packages (clang-format, clang-tidy) clang-tidy-1* clang-tidy-2* clang-format-1* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 325e6c1..8b5a3cb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.1 + rev: v0.12.2 hooks: - id: ruff - id: ruff-format diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 741827b..4e30e38 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,7 +4,7 @@ entry: clang-format-hook language: python files: \.(h\+\+|h|hh|hxx|hpp|c|cc|cpp|c\+\+|cxx)$ - require_serial: true + require_serial: false - id: clang-tidy name: clang-tidy @@ -12,4 +12,4 @@ entry: clang-tidy-hook language: python files: \.(h\+\+|h|hh|hxx|hpp|c|cc|cpp|c\+\+|cxx)$ - require_serial: true + require_serial: false diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..95b5f94 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,73 @@ +# Migration: From Clang Tools Binaries to Python Wheels + +## Overview + +Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the `clang-tools` package to using Python wheel packages for `clang-format` and `clang-tidy`. This change provides: + +- **Better cross-platform compatibility** +- **Easier installation and dependency management** +- **Improved performance and reliability** + +## What Changed + +### Core Changes + +| Aspect | Before (< v1.0.0) | After (≥ v1.0.0) | +|--------|-------------------|-------------------| +| **Installation** | Install from GitHub release | Install with from PyPI | +| **Distribution** | Binary packages | Python wheel packages | +| **Performance** | Run serially | Run in parallel | + +### Implementation Details + +- **Dependencies**: Updated to use separate `clang-format==20.1.7` and `clang-tidy==20.1.0` Python wheels +- **Installation Logic**: Enhanced with pip-based installation and runtime version checks +- **Performance**: Pre-commit hooks can now run in parallel for better speed + +## Breaking Changes + +**No breaking changes for end users** + +- Your existing `.pre-commit-config.yaml` files will continue to work without modification +- All hook configurations remain backward compatible +- No changes required to your workflow + +## Migration Steps + +**No action required!** Your existing configuration will continue to work seamlessly. + +However, we recommend updating to the latest version for: +- Better performance +- Enhanced reliability +- Latest features and bug fixes + +## Support + +If you encounter issues after migration: + +1. **Check this guide**: Review the troubleshooting section above +2. **Search existing issues**: [GitHub Issues](https://github.com/cpp-linter/cpp-linter-hooks/issues) +3. **Report new issues**: Include the following information: + - Operating system and version + - Python version + - `cpp-linter-hooks` version + - Complete error message/stack trace + - Your `.pre-commit-config.yaml` configuration + +## References + +### Official Packages +- [clang-format Python wheel](https://pypi.org/project/clang-format/) - PyPI package +- [clang-tidy Python wheel](https://pypi.org/project/clang-tidy/) - PyPI package + +### Source Repositories +- [clang-format wheel source](https://github.com/ssciwr/clang-format-wheel) - GitHub repository +- [clang-tidy wheel source](https://github.com/ssciwr/clang-tidy-wheel) - GitHub repository + +### Documentation +- [cpp-linter-hooks Documentation](https://github.com/cpp-linter/cpp-linter-hooks) - Main repository +- [Pre-commit Framework](https://pre-commit.com/) - Pre-commit documentation + +--- + +**Happy linting!** diff --git a/README.md b/README.md index 3be3194..e110d0c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Add this configuration to your `.pre-commit-config.yaml` file: ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 # Use the tag or commit you want + rev: v1.0.0 # Use the tag or commit you want hooks: - id: clang-format args: [--style=Google] # Other coding style: LLVM, GNU, Chromium, Microsoft, Mozilla, WebKit. @@ -44,7 +44,7 @@ To use custom configurations like `.clang-format` and `.clang-tidy`: ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file] # Loads style from .clang-format file @@ -54,12 +54,12 @@ repos: ### Custom Clang Tool Version -To use specific versions of [clang-tools](https://github.com/cpp-linter/clang-tools-pip?tab=readme-ov-file#supported-versions): +To use specific versions of clang-format and clang-tidy (using Python wheel packages): ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18] # Specifies version @@ -67,6 +67,9 @@ repos: args: [--checks=.clang-tidy, --version=18] # Specifies version ``` +> [!NOTE] +> Starting from version v1.0.0, this package uses Python wheel packages ([clang-format](https://pypi.org/project/clang-format/) and [clang-tidy](https://pypi.org/project/clang-tidy/)) instead of the previous clang-tools binaries. The wheel packages provide better cross-platform compatibility and easier installation. For more details, see the [Migration Guide](MIGRATION.md). + ## Output ### clang-format Output @@ -141,12 +144,12 @@ Use -header-filter=.* to display errors from all non-system headers. Use -system ### Performance Optimization -> [!WARNING] +> [!TIP] > If your `pre-commit` runs longer than expected, it is highly recommended to add `files` in `.pre-commit-config.yaml` to limit the scope of the hook. This helps improve performance by reducing the number of files being checked and avoids unnecessary processing. Here's an example configuration: ```yaml - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18] @@ -172,7 +175,7 @@ This approach ensures that only modified files are checked, further speeding up ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18, --verbose] # Add -v or --verbose for detailed output diff --git a/cpp_linter_hooks/clang_format.py b/cpp_linter_hooks/clang_format.py index de3319f..ae732f6 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 .util import ensure_installed, DEFAULT_CLANG_VERSION +from .util import ensure_installed, DEFAULT_CLANG_FORMAT_VERSION parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_VERSION) +parser.add_argument("--version", default=DEFAULT_CLANG_FORMAT_VERSION) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose output" ) @@ -15,8 +15,8 @@ def run_clang_format(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - path = ensure_installed("clang-format", hook_args.version) - command = [str(path), "-i"] + tool_name = ensure_installed("clang-format", hook_args.version) + command = [tool_name, "-i"] # Add verbose flag if requested if hook_args.verbose: diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index 2a51334..7f5dd5c 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -2,17 +2,17 @@ from argparse import ArgumentParser from typing import Tuple -from .util import ensure_installed, DEFAULT_CLANG_VERSION +from .util import ensure_installed, DEFAULT_CLANG_TIDY_VERSION parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_VERSION) +parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION) def run_clang_tidy(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - path = ensure_installed("clang-tidy", hook_args.version) - command = [str(path)] + tool_name = ensure_installed("clang-tidy", hook_args.version) + command = [tool_name] command.extend(other_args) retval = 0 diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 0907dac..a4fe95e 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -1,51 +1,200 @@ import sys +import shutil +import toml +import subprocess from pathlib import Path import logging -from typing import Optional +from typing import Optional, List +from packaging.version import Version, InvalidVersion -from clang_tools.util import Version -from clang_tools.install import is_installed as _is_installed, install_tool +LOG = logging.getLogger(__name__) -LOG = logging.getLogger(__name__) +def get_version_from_dependency(tool: str) -> Optional[str]: + pyproject_path = Path(__file__).parent.parent / "pyproject.toml" + if not pyproject_path.exists(): + return None + data = toml.load(pyproject_path) + dependencies = data.get("project", {}).get("dependencies", []) + for dep in dependencies: + if dep.startswith(f"{tool}=="): + return dep.split("==")[1] + return None + + +DEFAULT_CLANG_FORMAT_VERSION = get_version_from_dependency("clang-format") or "20.1.7" +DEFAULT_CLANG_TIDY_VERSION = get_version_from_dependency("clang-tidy") or "20.1.0" + + +CLANG_FORMAT_VERSIONS = [ + "6.0.1", + "7.1.0", + "8.0.1", + "9.0.0", + "10.0.1", + "10.0.1.1", + "11.0.1", + "11.0.1.1", + "11.0.1.2", + "11.1.0", + "11.1.0.1", + "11.1.0.2", + "12.0.1", + "12.0.1.1", + "12.0.1.2", + "13.0.0", + "13.0.1", + "13.0.1.1", + "14.0.0", + "14.0.1", + "14.0.3", + "14.0.4", + "14.0.5", + "14.0.6", + "15.0.4", + "15.0.6", + "15.0.7", + "16.0.0", + "16.0.1", + "16.0.2", + "16.0.3", + "16.0.4", + "16.0.5", + "16.0.6", + "17.0.1", + "17.0.2", + "17.0.3", + "17.0.4", + "17.0.5", + "17.0.6", + "18.1.0", + "18.1.1", + "18.1.2", + "18.1.3", + "18.1.4", + "18.1.5", + "18.1.6", + "18.1.7", + "18.1.8", + "19.1.0", + "19.1.1", + "19.1.2", + "19.1.3", + "19.1.4", + "19.1.5", + "19.1.6", + "19.1.7", + "20.1.0", + "20.1.3", + "20.1.4", + "20.1.5", + "20.1.6", + "20.1.7", +] + +CLANG_TIDY_VERSIONS = [ + "13.0.1.1", + "14.0.6", + "15.0.2", + "15.0.2.1", + "16.0.4", + "17.0.1", + "18.1.1", + "18.1.8", + "19.1.0", + "19.1.0.1", + "20.1.0", +] -DEFAULT_CLANG_VERSION = "18" # Default version for clang tools, can be overridden +def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: + if user_input is None: + return None + try: + user_ver = Version(user_input) + except InvalidVersion: + return None + candidates = [Version(v) for v in versions] + if user_input.count(".") == 0: + matches = [v for v in candidates if v.major == user_ver.major] + elif user_input.count(".") == 1: + matches = [ + v + for v in candidates + if f"{v.major}.{v.minor}" == f"{user_ver.major}.{user_ver.minor}" + ] + else: + return str(user_ver) if user_ver in candidates else None -def is_installed(tool_name: str, version: str) -> Optional[Path]: - """Check if tool is installed. + return str(max(matches)) if matches else None - Checks the current python prefix and PATH via clang_tools.install.is_installed. - """ - # check in current python prefix (usual situation when we installed into pre-commit venv) - directory = Path(sys.executable).parent - path = directory / f"{tool_name}-{version}" - if path.is_file(): - return path - # parse the user-input version as a string - parsed_ver = Version(version) - # also check using clang_tools - path = _is_installed(tool_name, parsed_ver) - if path is not None: +def _get_runtime_version(tool: str) -> Optional[str]: + try: + output = subprocess.check_output([tool, "--version"], text=True) + if tool == "clang-tidy": + lines = output.strip().splitlines() + if len(lines) > 1: + return lines[1].split()[-1] + elif tool == "clang-format": + return output.strip().split()[-1] + except Exception: + return None + + +def _install_tool(tool: str, version: str) -> Optional[Path]: + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", f"{tool}=={version}"] + ) + return shutil.which(tool) + except subprocess.CalledProcessError: + LOG.error("Failed to install %s==%s", tool, version) + return None + + +def _resolve_install(tool: str, version: Optional[str]) -> Optional[Path]: + user_version = _resolve_version( + CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS, + version, + ) + if user_version is None: + user_version = ( + DEFAULT_CLANG_FORMAT_VERSION + if tool == "clang-format" + else DEFAULT_CLANG_TIDY_VERSION + ) + + path = shutil.which(tool) + if path: + runtime_version = _get_runtime_version(tool) + if runtime_version and user_version not in runtime_version: + LOG.info( + "%s version mismatch (%s != %s), reinstalling...", + tool, + runtime_version, + user_version, + ) + return _install_tool(tool, user_version) return Path(path) - # not found + return _install_tool(tool, user_version) + + +def is_installed(tool: str) -> Optional[Path]: + """Check if a tool is installed and return its path.""" + path = shutil.which(tool) + if path: + return Path(path) return None -def ensure_installed(tool_name: str, version: str = DEFAULT_CLANG_VERSION) -> Path: - """ - Ensure tool is available at given version. - """ - LOG.info("Checking for %s, version %s", tool_name, version) - path = is_installed(tool_name, version) - if path is not None: - LOG.info("%s, version %s is already installed", tool_name, version) - return path - - LOG.info("Installing %s, version %s", tool_name, version) - directory = Path(sys.executable).parent - install_tool(tool_name, version, directory=str(directory), no_progress_bar=True) - return directory / f"{tool_name}-{version}" +def ensure_installed(tool: str, version: Optional[str] = None) -> str: + LOG.info("Ensuring %s is installed", tool) + tool_path = _resolve_install(tool, version) + if tool_path: + LOG.info("%s available at %s", tool, tool_path) + return tool + LOG.warning("%s not found and could not be installed", tool) + return tool diff --git a/pyproject.toml b/pyproject.toml index 014ca91..9ad8181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,10 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] dependencies = [ - "clang-tools==0.15.1", + "clang-format==20.1.7", + "clang-tidy==20.1.0", + "toml>=0.10.2", + "packaging>=20.0", ] dynamic = ["version"] diff --git a/testing/run.sh b/testing/run.sh index b176b06..3407656 100644 --- a/testing/run.sh +++ b/testing/run.sh @@ -29,7 +29,7 @@ failed_cases=`grep -c "Failed" result.txt` echo $failed_cases " cases failed." -if [ $failed_cases -eq 10 ]; then +if [ $failed_cases -eq 9 ]; then echo "==============================" echo "Test cpp-linter-hooks success." echo "==============================" diff --git a/tests/test_util.py b/tests/test_util.py index 842ccaa..dfeca88 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,50 +1,425 @@ import logging -import sys import pytest from itertools import product +from unittest.mock import patch +from pathlib import Path +import subprocess +import sys -from cpp_linter_hooks.util import ensure_installed, DEFAULT_CLANG_VERSION +from cpp_linter_hooks.util import ( + ensure_installed, + is_installed, + get_version_from_dependency, + _resolve_version, + _get_runtime_version, + _install_tool, + _resolve_install, + CLANG_FORMAT_VERSIONS, + CLANG_TIDY_VERSIONS, + DEFAULT_CLANG_FORMAT_VERSION, + DEFAULT_CLANG_TIDY_VERSION, +) -VERSIONS = [None, "18"] +VERSIONS = [None, "20"] TOOLS = ["clang-format", "clang-tidy"] @pytest.mark.benchmark @pytest.mark.parametrize(("tool", "version"), list(product(TOOLS, VERSIONS))) def test_ensure_installed(tool, version, tmp_path, monkeypatch, caplog): - bin_path = tmp_path / "bin" - with monkeypatch.context() as m: - m.setattr(sys, "executable", str(bin_path / "python")) - - for run in range(2): - # clear any existing log messages - caplog.clear() - caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") - - if version is None: - ensure_installed(tool) - else: - ensure_installed(tool, version=version) - - bin_version = version or DEFAULT_CLANG_VERSION - assert (bin_path / f"{tool}-{bin_version}").is_file - - # first run should install - assert ( - caplog.record_tuples[0][2] - == f"Checking for {tool}, version {bin_version}" - ) - if run == 0: - # FIXME - # assert caplog.record_tuples[1][2] == f"Installing {tool}, version {bin_version}" - assert ( - caplog.record_tuples[1][2] - == f"{tool}, version {bin_version} is already installed" - ) - # second run should just confirm it's already installed - else: - assert ( - caplog.record_tuples[1][2] - == f"{tool}, version {bin_version} is already installed" - ) + """Test that ensure_installed returns the tool name for wheel packages.""" + with monkeypatch.context(): + # Mock shutil.which to simulate the tool being available + with patch("shutil.which", return_value=str(tmp_path / tool)): + # Mock _get_runtime_version to return a matching version + mock_version = "20.1.7" if tool == "clang-format" else "20.1.0" + with patch( + "cpp_linter_hooks.util._get_runtime_version", return_value=mock_version + ): + caplog.clear() + caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") + + if version is None: + result = ensure_installed(tool) + else: + result = ensure_installed(tool, version=version) + + # Should return the tool name for direct execution + assert result == tool + + # Check that we logged ensuring the tool is installed + assert any("Ensuring" in record.message for record in caplog.records) + + +@pytest.mark.benchmark +def test_is_installed_with_shutil_which(tmp_path): + """Test is_installed when tool is found via shutil.which.""" + tool_path = tmp_path / "clang-format" + tool_path.touch() + + with patch("shutil.which", return_value=str(tool_path)): + result = is_installed("clang-format") + assert result == tool_path + + +@pytest.mark.benchmark +def test_is_installed_not_found(): + """Test is_installed when tool is not found anywhere.""" + with ( + patch("shutil.which", return_value=None), + patch("sys.executable", "/nonexistent/python"), + ): + result = is_installed("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_ensure_installed_tool_not_found(caplog): + """Test ensure_installed when tool is not found.""" + with ( + patch("shutil.which", return_value=None), + patch("cpp_linter_hooks.util._install_tool", return_value=None), + ): + caplog.clear() + caplog.set_level(logging.WARNING, logger="cpp_linter_hooks.util") + + result = ensure_installed("clang-format") + + # Should still return the tool name + assert result == "clang-format" + + # Should log a warning + assert any( + "not found and could not be installed" in record.message + for record in caplog.records + ) + + +# Tests for get_version_from_dependency +@pytest.mark.benchmark +def test_get_version_from_dependency_success(): + """Test get_version_from_dependency with valid pyproject.toml.""" + mock_toml_content = { + "project": { + "dependencies": [ + "clang-format==20.1.7", + "clang-tidy==20.1.0", + "other-package==1.0.0", + ] + } + } + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result == "20.1.7" + + result = get_version_from_dependency("clang-tidy") + assert result == "20.1.0" + + +@pytest.mark.benchmark +def test_get_version_from_dependency_missing_file(): + """Test get_version_from_dependency when pyproject.toml doesn't exist.""" + with patch("pathlib.Path.exists", return_value=False): + result = get_version_from_dependency("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_version_from_dependency_missing_dependency(): + """Test get_version_from_dependency with missing dependency.""" + mock_toml_content = {"project": {"dependencies": ["other-package==1.0.0"]}} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_version_from_dependency_malformed_toml(): + """Test get_version_from_dependency with malformed toml.""" + mock_toml_content = {} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result is None + + +# Tests for _resolve_version +@pytest.mark.benchmark +@pytest.mark.parametrize( + "user_input,expected", + [ + (None, None), + ("20", "20.1.7"), # Should find latest 20.x + ("20.1", "20.1.7"), # Should find latest 20.1.x + ("20.1.7", "20.1.7"), # Exact match + ("18", "18.1.8"), # Should find latest 18.x + ("18.1", "18.1.8"), # Should find latest 18.1.x + ("99", None), # Non-existent major version + ("20.99", None), # Non-existent minor version + ("invalid", None), # Invalid version string + ], +) +def test_resolve_version_clang_format(user_input, expected): + """Test _resolve_version with various inputs for clang-format.""" + result = _resolve_version(CLANG_FORMAT_VERSIONS, user_input) + assert result == expected + + +@pytest.mark.benchmark +@pytest.mark.parametrize( + "user_input,expected", + [ + (None, None), + ("20", "20.1.0"), # Should find latest 20.x + ("18", "18.1.8"), # Should find latest 18.x + ("19", "19.1.0.1"), # Should find latest 19.x + ("99", None), # Non-existent major version + ], +) +def test_resolve_version_clang_tidy(user_input, expected): + """Test _resolve_version with various inputs for clang-tidy.""" + result = _resolve_version(CLANG_TIDY_VERSIONS, user_input) + assert result == expected + + +# Tests for _get_runtime_version +@pytest.mark.benchmark +def test_get_runtime_version_clang_format(): + """Test _get_runtime_version for clang-format.""" + mock_output = "Ubuntu clang-format version 20.1.7-1ubuntu1\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-format") + assert result == "20.1.7-1ubuntu1" + + +@pytest.mark.benchmark +def test_get_runtime_version_clang_tidy(): + """Test _get_runtime_version for clang-tidy.""" + mock_output = "LLVM (http://llvm.org/):\n LLVM version 20.1.0\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-tidy") + assert result == "20.1.0" + + +@pytest.mark.benchmark +def test_get_runtime_version_exception(): + """Test _get_runtime_version when subprocess fails.""" + with patch( + "subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, ["clang-format"]), + ): + result = _get_runtime_version("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_runtime_version_clang_tidy_single_line(): + """Test _get_runtime_version for clang-tidy with single line output.""" + mock_output = "LLVM version 20.1.0\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-tidy") + assert result is None # Should return None for single line + + +# Tests for _install_tool +@pytest.mark.benchmark +def test_install_tool_success(): + """Test _install_tool successful installation.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("subprocess.check_call") as mock_check_call, + patch("shutil.which", return_value=mock_path), + ): + result = _install_tool("clang-format", "20.1.7") + assert result == mock_path + + mock_check_call.assert_called_once_with( + [sys.executable, "-m", "pip", "install", "clang-format==20.1.7"] + ) + + +@pytest.mark.benchmark +def test_install_tool_failure(): + """Test _install_tool when pip install fails.""" + with ( + patch( + "subprocess.check_call", + side_effect=subprocess.CalledProcessError(1, ["pip"]), + ), + patch("cpp_linter_hooks.util.LOG") as mock_log, + ): + result = _install_tool("clang-format", "20.1.7") + assert result is None + + mock_log.error.assert_called_once_with( + "Failed to install %s==%s", "clang-format", "20.1.7" + ) + + +@pytest.mark.benchmark +def test_install_tool_success_but_not_found(): + """Test _install_tool when install succeeds but tool not found in PATH.""" + with patch("subprocess.check_call"), patch("shutil.which", return_value=None): + result = _install_tool("clang-format", "20.1.7") + assert result is None + + +# Tests for _resolve_install +@pytest.mark.benchmark +def test_resolve_install_tool_already_installed_correct_version(): + """Test _resolve_install when tool is already installed with correct version.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="20.1.7"), + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path(mock_path) + + +@pytest.mark.benchmark +def test_resolve_install_tool_version_mismatch(): + """Test _resolve_install when tool has wrong version.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="18.1.8"), + patch( + "cpp_linter_hooks.util._install_tool", return_value=Path(mock_path) + ) as mock_install, + patch("cpp_linter_hooks.util.LOG") as mock_log, + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path(mock_path) + + mock_install.assert_called_once_with("clang-format", "20.1.7") + mock_log.info.assert_called_once_with( + "%s version mismatch (%s != %s), reinstalling...", + "clang-format", + "18.1.8", + "20.1.7", + ) + + +@pytest.mark.benchmark +def test_resolve_install_tool_not_installed(): + """Test _resolve_install when tool is not installed.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path("/usr/bin/clang-format") + + mock_install.assert_called_once_with("clang-format", "20.1.7") + + +@pytest.mark.benchmark +def test_resolve_install_no_version_specified(): + """Test _resolve_install when no version is specified.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", None) + assert result == Path("/usr/bin/clang-format") + + mock_install.assert_called_once_with( + "clang-format", DEFAULT_CLANG_FORMAT_VERSION + ) + + +@pytest.mark.benchmark +def test_resolve_install_invalid_version(): + """Test _resolve_install with invalid version.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", "invalid.version") + assert result == Path("/usr/bin/clang-format") + + # Should fallback to default version + mock_install.assert_called_once_with( + "clang-format", DEFAULT_CLANG_FORMAT_VERSION + ) + + +# Tests for ensure_installed edge cases +@pytest.mark.benchmark +def test_ensure_installed_version_mismatch(caplog): + """Test ensure_installed with version mismatch scenario.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="18.1.8"), + patch("cpp_linter_hooks.util._install_tool", return_value=Path(mock_path)), + ): + caplog.clear() + caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") + + result = ensure_installed("clang-format", "20.1.7") + assert result == "clang-format" + + # Should log version mismatch + assert any("version mismatch" in record.message for record in caplog.records) + + +@pytest.mark.benchmark +def test_ensure_installed_no_runtime_version(): + """Test ensure_installed when runtime version cannot be determined.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value=None), + ): + result = ensure_installed("clang-format", "20.1.7") + assert result == "clang-format" + + +# Tests for constants and defaults +@pytest.mark.benchmark +def test_default_versions(): + """Test that default versions are set correctly.""" + assert DEFAULT_CLANG_FORMAT_VERSION is not None + assert DEFAULT_CLANG_TIDY_VERSION is not None + assert isinstance(DEFAULT_CLANG_FORMAT_VERSION, str) + assert isinstance(DEFAULT_CLANG_TIDY_VERSION, str) + + +@pytest.mark.benchmark +def test_version_lists_not_empty(): + """Test that version lists are not empty.""" + assert len(CLANG_FORMAT_VERSIONS) > 0 + assert len(CLANG_TIDY_VERSIONS) > 0 + assert all(isinstance(v, str) for v in CLANG_FORMAT_VERSIONS) + assert all(isinstance(v, str) for v in CLANG_TIDY_VERSIONS)