From 6b87ffd7585e9f826183e6e5978bf23ace44cffd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 1 Feb 2026 13:13:22 +0100 Subject: [PATCH] Convert RustAnalyzer to use LanguageServerDependencyProviderSinglePath --- docs/02-usage/050_configuration.md | 4 +- .../language_servers/rust_analyzer.py | 230 +++++++++--------- .../rust/test_rust_analyzer_detection.py | 68 +++--- 3 files changed, 154 insertions(+), 148 deletions(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 69d36fa4..50372801 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -173,7 +173,7 @@ You can change this location by setting the `SERENA_HOME` environment variable t (ls-specific-settings)= ### Language Server-Specific Settings -:::note +:::{note} **Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior. Most users will not need to adjust these settings. ::: @@ -209,7 +209,7 @@ ls_specific_settings: ``` This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`. -Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `typescript`. +Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `rust`, `typescript`. We will add support for more languages over time. #### Go (`gopls`) diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index f345380a..a778c017 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -13,10 +13,9 @@ from typing import cast from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -43,136 +42,140 @@ class RustAnalyzer(SolidLanguageServer): return SolidLanguageServer._determine_log_level(line) - @staticmethod - def _get_rustup_version() -> str | None: - """Get installed rustup version or None if not found.""" - try: - result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) - if result.returncode == 0: - return result.stdout.strip() - except FileNotFoundError: + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + @staticmethod + def _get_rustup_version() -> str | None: + """Get installed rustup version or None if not found.""" + try: + result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None return None - return None - @staticmethod - def _get_rust_analyzer_via_rustup() -> str | None: - """Get rust-analyzer path via rustup. Returns None if not found.""" - try: - 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 _get_rust_analyzer_via_rustup() -> str | None: + """Get rust-analyzer path via rustup. Returns None if not found.""" + try: + 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. + @staticmethod + def _ensure_rust_analyzer_installed() -> str: + """ + 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) + 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 - """ - # 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 + :return: path to rust-analyzer executable + """ + # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) + rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path - # 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 + # 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.DependencyProvider._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.DependencyProvider._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" + # 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] = [] + # 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 - ] + 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 + ] + ) + + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return 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 + + # 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) ) - for path in common_paths: - if path and os.path.isfile(path) and os.access(path, os.X_OK): - return path + def _get_or_install_core_dependency(self) -> str: + return self._ensure_rust_analyzer_installed() - # 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 - - # 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 _create_launch_command(self, core_path: str) -> list[str] | str: + return [core_path] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - rustanalyzer_executable_path = self._ensure_rust_analyzer_installed() - log.info(f"Using rust-analyzer at: {rustanalyzer_executable_path}") - super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), + None, "rust", solidlsp_settings, ) @@ -181,6 +184,9 @@ class RustAnalyzer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] diff --git a/test/solidlsp/rust/test_rust_analyzer_detection.py b/test/solidlsp/rust/test_rust_analyzer_detection.py index 31b0254a..85a978c3 100644 --- a/test/solidlsp/rust/test_rust_analyzer_detection.py +++ b/test/solidlsp/rust/test_rust_analyzer_detection.py @@ -48,7 +48,7 @@ class TestRustAnalyzerDetection: 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): + with patch.object(RustAnalyzer.DependencyProvider, "_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 @@ -60,7 +60,7 @@ class TestRustAnalyzerDetection: return path == "/custom/bin/rust-analyzer" with patch("os.path.isfile", side_effect=selective_isfile): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/custom/bin/rust-analyzer" mock_which.assert_called_with("rust-analyzer") @@ -86,11 +86,11 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/opt/homebrew/bin/rust-analyzer" @@ -115,11 +115,11 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/usr/local/bin/rust-analyzer" @@ -146,11 +146,11 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == cargo_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @@ -169,11 +169,11 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object( - RustAnalyzer, + RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert "rustup" in result or ".rustup" in result @@ -193,10 +193,10 @@ class TestRustAnalyzerDetection: 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 patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should list the locations that were searched (Unix paths) @@ -222,11 +222,11 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should use rustup version, NOT PATH or common locations assert result == rustup_path @@ -260,11 +260,11 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should skip non-executable Homebrew and use cargo assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") @@ -292,12 +292,12 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == scoop_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == scoop_path @@ -324,12 +324,12 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == cargo_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @@ -349,10 +349,10 @@ class TestRustAnalyzerDetection: 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 patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should suggest Windows-specific package managers @@ -377,13 +377,13 @@ class TestRustAnalyzerDetection: 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: + with patch.object(RustAnalyzer.DependencyProvider, "_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.object(RustAnalyzer.DependencyProvider, "_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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" mock_run.assert_called_once() @@ -407,14 +407,14 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_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() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should provide helpful installation instructions @@ -439,12 +439,12 @@ class TestRustAnalyzerDetection: 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.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_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() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should indicate rust-analyzer is not available and provide install instructions @@ -482,7 +482,7 @@ class TestRustAnalyzerDetectionIntegration: 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() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result is not None assert os.path.isfile(result)