From c807e98191ce468182ab0e92deb26582ee2824e0 Mon Sep 17 00:00:00 2001 From: Ketema Harris Date: Sun, 4 Jan 2026 06:11:52 -0500 Subject: [PATCH] Enhance rust-analyzer detection by checking common paths Includes tests that rely on mocks --- .../language_servers/rust_analyzer.py | 110 +++- .../rust/test_rust_analyzer_detection.py | 489 ++++++++++++++++++ 2 files changed, 577 insertions(+), 22 deletions(-) create mode 100644 test/solidlsp/rust/test_rust_analyzer_detection.py diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 7791b3f1..f345380a 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -5,6 +5,8 @@ Provides Rust specific instantiation of the LanguageServer class. Contains vario import logging import os import pathlib +import platform +import shutil import subprocess import threading from typing import cast @@ -53,48 +55,112 @@ class RustAnalyzer(SolidLanguageServer): return None @staticmethod - def _get_rust_analyzer_path() -> str | None: + def _get_rust_analyzer_via_rustup() -> str | None: """Get rust-analyzer path via rustup. Returns None if not found.""" try: - # Note: we avoid using system PATH to avoid picking up incorrect aliases result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() - except FileNotFoundError: pass - return None @staticmethod def _ensure_rust_analyzer_installed() -> str: """ - Ensure rust-analyzer is available, install via rustup if needed. + Ensure rust-analyzer is available. + + Priority order: + 1. Rustup existing installation (preferred - matches toolchain version) + 2. Rustup auto-install if rustup is available (ensures correct version) + 3. Common installation locations as fallback (only if rustup not available) + 4. System PATH last (can pick up incompatible versions) :return: path to rust-analyzer executable """ - path = RustAnalyzer._get_rust_analyzer_path() - if path: - return path + # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) + rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path - # Check if rustup is available - if not RustAnalyzer._get_rustup_version(): - raise RuntimeError( - "Neither rust-analyzer nor rustup is installed.\n" - "Please install Rust via https://rustup.rs/ or install rust-analyzer separately." + # If rustup is available but rust-analyzer not installed, auto-install it BEFORE + # checking common paths. This ensures we get the correct version matching the toolchain. + if RustAnalyzer._get_rustup_version(): + result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) + if result.returncode == 0: + # Verify installation worked + rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path + # If auto-install failed, fall through to common paths as last resort + + # Determine platform-specific binary name and paths + is_windows = platform.system() == "Windows" + binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer" + + # Fallback to common installation locations (only used if rustup not available) + common_paths: list[str | None] = [] + + if is_windows: + # Windows-specific paths + home = pathlib.Path.home() + common_paths.extend( + [ + str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup + str(home / "scoop" / "shims" / binary_name), # Scoop package manager + str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct + str( + pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name + ), # Standalone install + ] + ) + else: + # Unix-like paths (macOS, Linux) + common_paths.extend( + [ + "/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon) + "/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system + os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install + os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin + ] ) - # Try to install rust-analyzer component - result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"Failed to install rust-analyzer via rustup: {result.stderr}") + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return path - # Try again after installation - path = RustAnalyzer._get_rust_analyzer_path() - if not path: - raise RuntimeError("rust-analyzer installation succeeded but binary not found in PATH") + # Last resort: check system PATH (can pick up incorrect aliases, hence checked last) + path_result = shutil.which("rust-analyzer") + if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK): + return path_result - return path + # Provide helpful error message with all searched locations + searched = [p for p in common_paths if p] + install_instructions = [ + " - Rustup: rustup component add rust-analyzer", + " - Cargo: cargo install rust-analyzer", + ] + if is_windows: + install_instructions.extend( + [ + " - Scoop: scoop install rust-analyzer", + " - Chocolatey: choco install rust-analyzer", + " - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases", + ] + ) + else: + install_instructions.extend( + [ + " - Homebrew (macOS): brew install rust-analyzer", + " - System package manager (Linux): apt/dnf/pacman install rust-analyzer", + ] + ) + + raise RuntimeError( + "rust-analyzer is not installed or not in PATH.\n" + "Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n" + "Please install rust-analyzer via:\n" + "\n".join(install_instructions) + ) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ diff --git a/test/solidlsp/rust/test_rust_analyzer_detection.py b/test/solidlsp/rust/test_rust_analyzer_detection.py new file mode 100644 index 00000000..31b0254a --- /dev/null +++ b/test/solidlsp/rust/test_rust_analyzer_detection.py @@ -0,0 +1,489 @@ +""" +Tests for rust-analyzer detection logic. + +These tests describe the expected behavior of RustAnalyzer._ensure_rust_analyzer_installed(): + +1. Rustup should be checked FIRST (avoids picking up incorrect PATH aliases) +2. Common installation locations (Homebrew, cargo, Scoop) should be checked as fallback +3. System PATH should be checked last (can pick up incompatible versions) +4. Error messages should list all searched locations +5. Windows-specific paths should be checked on Windows + +WHY these tests matter: +- Users install rust-analyzer via Homebrew, cargo, Scoop, or system packages - not just rustup +- macOS Homebrew installs to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel) +- Windows users install via Scoop, Chocolatey, or cargo +- Detection failing means Serena is unusable for Rust, even when rust-analyzer is correctly installed +- Without these tests, the detection logic can silently break for non-rustup users +""" + +import os +import pathlib +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Platform detection for skipping platform-specific tests +IS_WINDOWS = sys.platform == "win32" +IS_UNIX = sys.platform != "win32" + + +class TestRustAnalyzerDetection: + """Unit tests for rust-analyzer binary detection logic.""" + + @pytest.mark.rust + def test_detect_from_path_as_last_resort(self): + """ + GIVEN rustup is not available + AND rust-analyzer is NOT in common locations (Homebrew, cargo) + AND rust-analyzer IS in system PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the path from shutil.which as last resort + + WHY: PATH is checked last to avoid picking up incorrect aliases. + Users with rust-analyzer in PATH but not via rustup/common locations + should still work. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + # Mock rustup to be unavailable + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + # Mock common locations to NOT exist + with patch("os.path.isfile", return_value=False): + # Mock PATH to have rust-analyzer + with patch("shutil.which") as mock_which: + mock_which.return_value = "/custom/bin/rust-analyzer" + with patch("os.access", return_value=True): + # Need isfile to return True for PATH result only + def selective_isfile(path): + return path == "/custom/bin/rust-analyzer" + + with patch("os.path.isfile", side_effect=selective_isfile): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == "/custom/bin/rust-analyzer" + mock_which.assert_called_with("rust-analyzer") + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") + def test_detect_from_homebrew_apple_silicon_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Homebrew on Apple Silicon Mac + AND it is NOT in PATH (shutil.which returns None) + WHEN _ensure_rust_analyzer_installed is called + THEN it should find /opt/homebrew/bin/rust-analyzer + + WHY: Apple Silicon Macs use /opt/homebrew/bin for Homebrew. + This path should be checked as fallback when rustup is unavailable. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + return path == "/opt/homebrew/bin/rust-analyzer" + + def mock_access(path, mode): + return path == "/opt/homebrew/bin/rust-analyzer" + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == "/opt/homebrew/bin/rust-analyzer" + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") + def test_detect_from_homebrew_intel_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Homebrew on Intel Mac + AND it is NOT in PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should find /usr/local/bin/rust-analyzer + + WHY: Intel Macs use /usr/local/bin for Homebrew. + Linux systems may also install to this location. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + return path == "/usr/local/bin/rust-analyzer" + + def mock_access(path, mode): + return path == "/usr/local/bin/rust-analyzer" + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == "/usr/local/bin/rust-analyzer" + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Unix cargo path - Windows has separate test") + def test_detect_from_cargo_install_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via `cargo install rust-analyzer` + AND it is NOT in PATH or Homebrew locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/.cargo/bin/rust-analyzer + + WHY: `cargo install rust-analyzer` is a common installation method. + The binary lands in ~/.cargo/bin which may not be in PATH. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") + + def mock_isfile(path): + return path == cargo_path + + def mock_access(path, mode): + return path == cargo_path + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == cargo_path + + @pytest.mark.rust + def test_detect_from_rustup_when_available(self): + """ + GIVEN rustup has rust-analyzer installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the rustup path + + WHY: Rustup is checked FIRST to avoid picking up incorrect aliases from PATH. + This ensures compatibility with the toolchain. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object( + RustAnalyzer, + "_get_rust_analyzer_via_rustup", + return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer", + ): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert "rustup" in result or ".rustup" in result + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Unix error messages - Windows has separate test") + def test_error_message_lists_searched_locations_when_not_found(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup is NOT installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should raise RuntimeError with helpful message listing searched locations + + WHY: Users need to know WHERE Serena looked so they can fix their installation. + The old error "Neither rust-analyzer nor rustup is installed" was unhelpful. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None): + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should list the locations that were searched (Unix paths) + assert "/opt/homebrew/bin/rust-analyzer" in error_message or "Homebrew" in error_message + assert "cargo" in error_message.lower() or ".cargo/bin" in error_message + # Error should suggest installation methods + assert "rustup" in error_message.lower() or "Rustup" in error_message + + @pytest.mark.rust + def test_detection_priority_prefers_rustup_over_path_and_common_locations(self): + """ + GIVEN rust-analyzer is available via rustup + AND rust-analyzer also exists in PATH and common locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the rustup version + + WHY: Rustup provides version management and ensures compatibility. + Using PATH directly can pick up incorrect aliases or incompatible versions + that cause LSP crashes (as discovered in CI failures). + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer" + + # Rustup has rust-analyzer, PATH also has it, common locations also exist + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=rustup_path): + with patch("shutil.which", return_value="/custom/path/rust-analyzer"): + with patch("os.path.isfile", return_value=True): + with patch("os.access", return_value=True): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + # Should use rustup version, NOT PATH or common locations + assert result == rustup_path + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Uses Unix paths - Windows has different behavior") + def test_skips_nonexecutable_files(self): + """ + GIVEN a file exists at a detection path but is NOT executable + WHEN _ensure_rust_analyzer_installed is called + THEN it should skip that path and continue checking others + + WHY: A non-executable file (e.g., broken symlink, wrong permissions) + should not be returned as a valid rust-analyzer path. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + # File exists at Homebrew location but not executable + return path == "/opt/homebrew/bin/rust-analyzer" + + def mock_access(path, mode): + # Homebrew location exists but not executable + if path == "/opt/homebrew/bin/rust-analyzer": + return False + # Cargo location is executable + if path == os.path.expanduser("~/.cargo/bin/rust-analyzer"): + return True + return False + + def mock_isfile_for_cargo(path): + return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")] + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile_for_cargo): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + # Should skip non-executable Homebrew and use cargo + assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") + + @pytest.mark.rust + def test_detect_from_scoop_shims_path_on_windows(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Scoop on Windows + AND it is NOT in PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/scoop/shims/rust-analyzer.exe + + WHY: Scoop is a popular package manager for Windows. + The binary lands in ~/scoop/shims which may not be in PATH. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + home = pathlib.Path.home() + scoop_path = str(home / "scoop" / "shims" / "rust-analyzer.exe") + + def mock_isfile(path): + return path == scoop_path + + def mock_access(path, mode): + return path == scoop_path + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == scoop_path + + @pytest.mark.rust + def test_detect_from_cargo_path_on_windows(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via cargo on Windows + AND it is NOT in PATH or Scoop locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/.cargo/bin/rust-analyzer.exe + + WHY: `cargo install rust-analyzer` works on Windows. + The binary has .exe extension and lands in ~/.cargo/bin. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + home = pathlib.Path.home() + cargo_path = str(home / ".cargo" / "bin" / "rust-analyzer.exe") + + def mock_isfile(path): + return path == cargo_path + + def mock_access(path, mode): + return path == cargo_path + + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == cargo_path + + @pytest.mark.rust + def test_windows_error_message_suggests_windows_package_managers(self): + """ + GIVEN rust-analyzer is NOT installed anywhere on Windows + AND rustup is NOT installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should raise RuntimeError with Windows-specific installation suggestions + + WHY: Windows users need Windows-specific package manager suggestions + (Scoop, Chocolatey) instead of Homebrew/apt. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None): + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should suggest Windows-specific package managers + assert "Scoop" in error_message or "scoop" in error_message + assert "Chocolatey" in error_message or "choco" in error_message + # Should NOT suggest Homebrew on Windows + assert "Homebrew" not in error_message and "brew" not in error_message + + @pytest.mark.rust + def test_auto_install_via_rustup_when_not_found(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add succeeds + THEN it should return the rustup-installed path + + WHY: Serena should auto-install rust-analyzer via rustup when possible. + This matches the original behavior and enables CI to work without pre-installing. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup") as mock_rustup_path: + # First call returns None (not installed), second returns path (after install) + mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"] + with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["rustup", "component", "add", "rust-analyzer"] + + @pytest.mark.rust + def test_auto_install_failure_falls_through_to_common_paths(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add FAILS + THEN it should fall through to common paths and eventually raise helpful error + + WHY: The new resilient behavior tries all fallback options before failing. + When rustup auto-install fails, we try common paths (Homebrew, cargo, etc.) + as a last resort. This is more robust than failing immediately. + The error message should still help users install rust-analyzer. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available" + ) + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should provide helpful installation instructions + assert "rust-analyzer is not installed" in error_message.lower() + assert "rustup" in error_message.lower() # Should suggest rustup installation + + @pytest.mark.rust + def test_auto_install_success_but_binary_not_found_falls_through(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add SUCCEEDS + BUT the binary is still not found after installation + THEN it should fall through to common paths and eventually raise helpful error + + WHY: Even if rustup install reports success but binary isn't found, + we try common paths as fallback. The final error provides installation + guidance to help users resolve the issue. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should indicate rust-analyzer is not available and provide install instructions + assert "rust-analyzer is not installed" in error_message.lower() + assert "searched locations" in error_message.lower() # Should show what was checked + + +class TestRustAnalyzerDetectionIntegration: + """ + Integration tests that verify detection works on the current system. + These tests are skipped if rust-analyzer is not installed. + """ + + @pytest.mark.rust + def test_detection_finds_installed_rust_analyzer(self): + """ + GIVEN rust-analyzer is installed on this system (via any method) + WHEN _ensure_rust_analyzer_installed is called + THEN it should return a valid path + + This test verifies the detection logic works end-to-end on the current system. + """ + import shutil + + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + # Skip if rust-analyzer is not installed at all + if not shutil.which("rust-analyzer"): + # Check common locations + common_paths = [ + "/opt/homebrew/bin/rust-analyzer", + "/usr/local/bin/rust-analyzer", + os.path.expanduser("~/.cargo/bin/rust-analyzer"), + ] + if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths): + pytest.skip("rust-analyzer not installed on this system") + + result = RustAnalyzer._ensure_rust_analyzer_installed() + + assert result is not None + assert os.path.isfile(result) + assert os.access(result, os.X_OK)