Convert RustAnalyzer to use LanguageServerDependencyProviderSinglePath

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-02-01 14:29:39 +01:00
1 parent 01dc6f8734
commit 6b87ffd758
3 files changed
+154 -148

No files matched your search

+2 -2
View File
@@ -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`)
+118 -112
View File
@@ -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"]
@@ -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)