From 9800d4e98b5977a5d56c90963df65ea9a772799f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 15:43:01 +0100 Subject: [PATCH 1/5] Introduce DotNETUtil * Apply in FSharpLanguageServer and CSharpLanguageServer * CSharpLanguageServer: Disabled auto-install of .NET (does not work reliably) and remove corresponding test * Remove TestFSharpLanguageServerSetup --- src/serena/util/dotnet.py | 156 ++++++++++ src/serena/util/version.py | 2 +- .../csharp_language_server.py | 101 +----- .../fsharp_language_server.py | 17 +- .../csharp/test_csharp_dotnet_install.py | 290 ------------------ test/solidlsp/fsharp/test_fsharp_basic.py | 42 --- 6 files changed, 161 insertions(+), 447 deletions(-) create mode 100644 src/serena/util/dotnet.py delete mode 100644 test/solidlsp/csharp/test_csharp_dotnet_install.py diff --git a/src/serena/util/dotnet.py b/src/serena/util/dotnet.py new file mode 100644 index 00000000..99b34526 --- /dev/null +++ b/src/serena/util/dotnet.py @@ -0,0 +1,156 @@ +import logging +import platform +import re +import shutil +import subprocess +import urllib +from pathlib import Path + +from serena.util.version import Version +from solidlsp.ls_exceptions import SolidLSPException + +log = logging.getLogger(__name__) + + +class DotNETUtil: + def __init__(self, required_version: str, allow_higher_version: bool = True): + """ + :param required_version: the required .NET runtime version specified as a string (e.g. "10.0" for .NET 10.0) + :param allow_higher_version: whether to allow higher versions than the required version + """ + self._system_dotnet = shutil.which("dotnet") + self._required_version_str = required_version + self._required_version_components = [int(c) for c in required_version.split(".")] + self._allow_higher_version = allow_higher_version + self._installed_versions = self._determine_installed_versions() + + def _determine_installed_versions(self) -> list[Version]: + if self._system_dotnet: + try: + result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) + version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout) + log.info("Installed .NET runtime versions: %s", version_strings) + return [Version(v) for v in version_strings] + except: + log.warning("Failed to run 'dotnet --list-runtimes' to check .NET version; assuming no installed .NET versions") + return [] + else: + log.info("Found no `dotnet` on system PATH; assuming no installed .NET versions") + return [] + + def is_required_version_available(self) -> bool: + """ + Checks whether the required .NET runtime version is installed and raises an exception if not. + + :param required_version_components: the required .NET runtime version specified as a list of integers representing the version components (e.g., [6, 1] for .NET 6.1) + :param allow_higher_version: whether to allow higher versions than the required version (e.g., if True, .NET 7.0 would satisfy a requirement of .NET 6.1) + """ + required_version_str = ".".join(str(c) for c in self._required_version_components) + for v in self._installed_versions: + if self._allow_higher_version: + if v.is_at_least(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str} or higher") + return True + else: + if v.is_equal(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str}") + return True + return False + + def get_dotnet_path_or_raise(self) -> str: + """ + Returns the path to the dotnet executable if the required .NET runtime version is available, otherwise raises an exception. + """ + if not self.is_required_version_available(): + raise SolidLSPException( + f"Required .NET runtime version {self._required_version_str} not found " + f"(installed versions: {self._installed_versions}). " + "Please install the required .NET runtime version from https://dotnet.microsoft.com/en-us/download/dotnet " + "and ensure that `dotnet` is on the system PATH." + ) + assert self._system_dotnet is not None + return self._system_dotnet + + @staticmethod + def install_dotnet_with_script(version: str, base_path: str) -> str: + """ + Install .NET runtime using Microsoft's official installation script. + + NOTE: This method is unreliable and therefore currently unused. It is kept for reference. + + :version: the version to install as a string (e.g. "10.0") + :return: the path to the dotnet executable. + """ + dotnet_dir = Path(base_path) / f"dotnet-runtime-{version}" + + # Determine binary name based on platform + is_windows = platform.system().lower() == "windows" + dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") + + if dotnet_exe.exists(): + log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") + return str(dotnet_exe) + + # Download and run install script + log.info(f"Installing .NET {version} runtime using official Microsoft install script...") + dotnet_dir.mkdir(parents=True, exist_ok=True) + + try: + if is_windows: + # PowerShell script for Windows + script_url = "https://dot.net/v1/dotnet-install.ps1" + script_path = dotnet_dir / "dotnet-install.ps1" + urllib.request.urlretrieve(script_url, script_path) + + cmd = [ + "pwsh", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script_path), + "-Version", + version, + "-InstallDir", + str(dotnet_dir), + "-Runtime", + "dotnet", + "-NoPath", + ] + else: + # Bash script for Linux/macOS + script_url = "https://dot.net/v1/dotnet-install.sh" + script_path = dotnet_dir / "dotnet-install.sh" + urllib.request.urlretrieve(script_url, script_path) + script_path.chmod(0o755) + + cmd = [ + "bash", + str(script_path), + "--version", + version, + "--install-dir", + str(dotnet_dir), + "--runtime", + "dotnet", + "--no-path", + ] + + # Run the install script + log.info("Running .NET install script: %s", cmd) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + log.debug(f"Install script output: {result.stdout}") + + if not dotnet_exe.exists(): + raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") + + log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") + return str(dotnet_exe) + + except subprocess.CalledProcessError as e: + raise SolidLSPException(f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}") from e + except Exception as e: + message = f"Failed to install .NET {version} runtime: {e}" + if is_windows and isinstance(e, FileNotFoundError): + message += "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." + raise SolidLSPException(message) from e diff --git a/src/serena/util/version.py b/src/serena/util/version.py index d8d8381f..142d4356 100644 --- a/src/serena/util/version.py +++ b/src/serena/util/version.py @@ -23,7 +23,7 @@ class Version: self.version_string = version_string self.components = self._get_version_components(version_string) - def __str__(self) -> str: + def __repr__(self) -> str: return self.version_string @staticmethod diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index ae9d9264..28621bb2 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -6,7 +6,6 @@ import logging import os import platform import shutil -import subprocess import threading import urllib.request from collections.abc import Iterable @@ -15,6 +14,7 @@ from typing import Any, cast from overrides import override +from serena.util.dotnet import DotNETUtil from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException @@ -366,21 +366,7 @@ class CSharpLanguageServer(SolidLanguageServer): def _ensure_dotnet_runtime(self) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" - # Check if dotnet is already available on the system - system_dotnet = shutil.which("dotnet") - if system_dotnet: - # Check if it's .NET 10 or compatible - try: - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - # Accept .NET 10 or higher (10.x, 11.x, etc.) - if any(f"Microsoft.NETCore.App {v}." in result.stdout for v in range(10, 20)): - log.info("Found system .NET 10+ runtime") - return system_dotnet - except subprocess.CalledProcessError: - pass - - # Install .NET 10 runtime using Microsoft's install script - return self._install_dotnet_with_script() + return DotNETUtil("10.0", allow_higher_version=True).get_dotnet_path_or_raise() def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" @@ -475,89 +461,6 @@ class CSharpLanguageServer(SolidLanguageServer): except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e - def _install_dotnet_with_script(self, version: str = "10.0") -> str: - """ - Install .NET runtime using Microsoft's official install script. - Returns the path to the dotnet executable. - """ - dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}" - - # Determine binary name based on platform - is_windows = platform.system().lower() == "windows" - dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") - - if dotnet_exe.exists(): - log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") - return str(dotnet_exe) - - # Download and run install script - log.info(f"Installing .NET {version} runtime using official Microsoft install script...") - dotnet_dir.mkdir(parents=True, exist_ok=True) - - try: - if is_windows: - # PowerShell script for Windows - script_url = "https://dot.net/v1/dotnet-install.ps1" - script_path = dotnet_dir / "dotnet-install.ps1" - urllib.request.urlretrieve(script_url, script_path) - - cmd = [ - "pwsh", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-Version", - version, - "-InstallDir", - str(dotnet_dir), - "-Runtime", - "dotnet", - "-NoPath", - ] - else: - # Bash script for Linux/macOS - script_url = "https://dot.net/v1/dotnet-install.sh" - script_path = dotnet_dir / "dotnet-install.sh" - urllib.request.urlretrieve(script_url, script_path) - script_path.chmod(0o755) - - cmd = [ - "bash", - str(script_path), - "--version", - version, - "--install-dir", - str(dotnet_dir), - "--runtime", - "dotnet", - "--no-path", - ] - - # Run the install script - log.info("Running .NET install script: %s", cmd) - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - log.debug(f"Install script output: {result.stdout}") - - if not dotnet_exe.exists(): - raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") - - log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") - return str(dotnet_exe) - - except subprocess.CalledProcessError as e: - raise SolidLSPException( - f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}" - ) from e - except Exception as e: - message = f"Failed to install .NET {version} runtime: {e}" - if is_windows and isinstance(e, FileNotFoundError): - message += ( - "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." - ) - raise SolidLSPException(message) from e - def _get_initialize_params(self) -> InitializeParams: """ Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index e4ee3e2c..9d12cf5d 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -11,6 +11,7 @@ from pathlib import Path from overrides import override +from serena.util.dotnet import DotNETUtil from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig @@ -61,21 +62,7 @@ class FSharpLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for F# Language Server and return the command to start the server. """ - # First check if .NET SDK is installed - dotnet_exe = shutil.which("dotnet") - if not dotnet_exe: - raise RuntimeError( - ".NET SDK is not installed or not in PATH. Please install .NET SDK 8.0 or later and ensure 'dotnet' is in your PATH." - ) - - # Verify dotnet version - import subprocess - - try: - result = subprocess.run([dotnet_exe, "--version"], capture_output=True, text=True, check=True) - log.info(f"Found .NET SDK version: {result.stdout.strip()}") - except subprocess.CalledProcessError: - raise RuntimeError("Failed to get .NET SDK version. Please ensure .NET SDK is properly installed.") + dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() RuntimeDependencyCollection( [ diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py deleted file mode 100644 index f59db6e5..00000000 --- a/test/solidlsp/csharp/test_csharp_dotnet_install.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Tests for C# language server .NET runtime installation using official install scripts.""" - -import tempfile -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest - -from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer -from solidlsp.settings import SolidLSPSettings - - -@pytest.mark.csharp -class TestDotNetInstallScript: - """Test .NET runtime installation using Microsoft's official install scripts.""" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_install_dotnet_uses_bash_script_on_linux( - self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls - ): - """Test that Linux uses bash install script.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Linux" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_which.return_value = None # No system dotnet - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - # Create the file that urlretrieve would create - Path(path).touch() - # Also create the dotnet executable after script download - if "dotnet-install.sh" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_urlretrieve.side_effect = mock_retrieve - mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - _ = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Verify bash script was downloaded - assert mock_urlretrieve.called - script_url = mock_urlretrieve.call_args[0][0] - assert script_url == "https://dot.net/v1/dotnet-install.sh" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_install_dotnet_uses_powershell_script_on_windows( - self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls - ): - """Test that Windows uses PowerShell install script.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Windows" - mock_platform_id.return_value = PlatformId.WIN_x64 - mock_which.return_value = None # No system dotnet - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - # Create the file that urlretrieve would create - Path(path).touch() - # Also create the dotnet executable after script download - if "dotnet-install.ps1" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet.exe" - dotnet_exe.touch() - - mock_urlretrieve.side_effect = mock_retrieve - mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - _ = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Verify PowerShell script was downloaded - assert mock_urlretrieve.called - script_url = mock_urlretrieve.call_args[0][0] - assert script_url == "https://dot.net/v1/dotnet-install.ps1" - - def test_uses_system_dotnet_10_if_available(self): - """Test that system .NET 10 is used if available instead of downloading.""" - from solidlsp.ls_utils import PlatformId - - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - - # Mock dotnet --list-runtimes output showing .NET 10 - mock_result = Mock() - mock_result.stdout = "Microsoft.NETCore.App 10.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result - - with tempfile.TemporaryDirectory() as temp_dir: - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should have used system dotnet - assert dependency_provider._dotnet_path == "/usr/bin/dotnet" - - def test_runtime_dependencies_no_longer_include_dotnet_downloads(self): - """Test that _RUNTIME_DEPENDENCIES no longer includes manual .NET download entries.""" - from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES - - # Check that DotNetRuntime entries don't exist (we use install script instead) - dotnet_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "DotNetRuntime"] - - # After our changes, there should be no DotNetRuntime entries - # (we'll use the install script instead) - assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_cached_dotnet_is_reused(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls): - """Test that cached .NET installation is reused without re-downloading.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Linux" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_which.return_value = None - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - # Pre-create the cached dotnet executable - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should use cached dotnet without downloading install script - assert dependency_provider._dotnet_path == str(dotnet_exe) - assert not mock_urlretrieve.called, "Should not download install script when cached dotnet exists" - - def test_rejects_dotnet_9_and_installs_dotnet_10(self): - """Test that .NET 9 is rejected and .NET 10 install is triggered.""" - from solidlsp.ls_utils import PlatformId - - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_urlretrieve: - with patch("solidlsp.language_servers.csharp_language_server.platform.system") as mock_platform: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_platform.return_value = "Linux" - - # Mock dotnet --list-runtimes output showing only .NET 9 - mock_result = Mock() - mock_result.stdout = "Microsoft.NETCore.App 9.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - - call_count = 0 - - def subprocess_side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - # First call: dotnet --list-runtimes returns .NET 9 - return mock_result - else: - # Second call: install script execution - return Mock(returncode=0, stdout="", stderr="") - - mock_subprocess.side_effect = subprocess_side_effect - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - Path(path).touch() - if "dotnet-install.sh" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_urlretrieve.side_effect = mock_retrieve - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should have installed .NET 10 (not used system .NET 9) - assert dependency_provider._dotnet_path == str(Path(temp_dir) / "dotnet-runtime-10.0" / "dotnet") - assert mock_urlretrieve.called, "Should download install script when .NET 9 found" - - def test_accepts_dotnet_11_and_higher(self): - """Test that .NET 11+ versions are accepted for forward compatibility.""" - from solidlsp.ls_utils import PlatformId - - for version in ["11.0.1", "12.0.0", "15.3.2"]: - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - - # Mock dotnet --list-runtimes output showing .NET 11+ - mock_result = Mock() - mock_result.stdout = f"Microsoft.NETCore.App {version} [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result - - with tempfile.TemporaryDirectory() as temp_dir: - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should use system dotnet for .NET 11+ - assert dependency_provider._dotnet_path == "/usr/bin/dotnet", f"Should accept .NET {version}" diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 578ae704..4e6d7024 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -1,14 +1,10 @@ import os -import tempfile import threading -from pathlib import Path from typing import Any -from unittest.mock import Mock, patch import pytest from solidlsp import SolidLanguageServer -from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci @@ -183,41 +179,3 @@ class TestFSharpLanguageServer: # This is a successful test - FsAutoComplete is working with F# files assert True, "F# language server can handle files successfully" - - -@pytest.mark.fsharp -class TestFSharpLanguageServerSetup: - """Test F# language server setup and configuration.""" - - def test_runtime_dependency_setup_without_dotnet(self) -> None: - """Test that setup fails gracefully when .NET is not installed.""" - with patch("shutil.which", return_value=None): - with pytest.raises(RuntimeError, match=r"\.NET SDK is not installed"): - FSharpLanguageServer._setup_runtime_dependencies(Mock(), Mock()) - - def test_runtime_dependency_setup_with_dotnet(self) -> None: - """Test that setup works when .NET is available.""" - mock_config = Mock() - mock_settings = Mock() - - # Mock the ls_resources_dir to return a temp directory - with tempfile.TemporaryDirectory() as temp_dir: - with patch("shutil.which", return_value="/usr/bin/dotnet"): - with patch.object(FSharpLanguageServer, "ls_resources_dir", return_value=temp_dir): - with patch("subprocess.run") as mock_run: - # Mock successful dotnet version check - mock_run.return_value.stdout = "8.0.100" - mock_run.return_value.returncode = 0 - - # Create a fake fsautocomplete executable - fsharp_dir = os.path.join(temp_dir, "fsharp-lsp") - os.makedirs(fsharp_dir, exist_ok=True) - # Use .exe extension on Windows, matching production code - exe_name = "fsautocomplete.exe" if os.name == "nt" else "fsautocomplete" - fsautocomplete_path = os.path.join(fsharp_dir, exe_name) - Path(fsautocomplete_path).touch() - - result = FSharpLanguageServer._setup_runtime_dependencies(mock_config, mock_settings) - - assert fsautocomplete_path in result - assert "--adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" in result From c4844ec4a22ce606fd0504a3ef814e4c06257175 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 15:55:17 +0100 Subject: [PATCH 2/5] Raise .NET version in CI to 10 --- .github/workflows/pytest.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 058ecbea..51577d5c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -139,7 +139,10 @@ jobs: - name: Setup .NET SDK (for F# and C# languages) uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' + - name: List .NET runtimes + shell: bash + run: dotnet --list-runtimes - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: From 9c336cc3390815aa4d14bef7c2b33316086cf586 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 19:24:07 +0100 Subject: [PATCH 3/5] Update language requirements for C# and F# [skip ci] --- docs/01-about/020_programming-languages.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 97c7fc40..728da19f 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -30,7 +30,7 @@ Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** * **C#** - (by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows; + (by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)); set language to `csharp_omnisharp` to use OmiSharp instead) * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); @@ -45,7 +45,7 @@ Some languages require additional installations or setup steps, as noted. * **Erlang** (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) * **F#** - (requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) + (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) * **Go** From eb78921c0899af26c6eb51508b7c96d5e49045cf Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 19:12:10 +0100 Subject: [PATCH 4/5] Split test_find_symbol_references and test_find_symbol into stable and unstable tests --- test/serena/test_serena_agent.py | 172 ++++++++++++++++++------------- 1 file changed, 103 insertions(+), 69 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 3dbef5d8..b04dc37f 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -117,37 +117,7 @@ class TestSerenaAgent: serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), - pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), - pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), - pytest.param( - Language.KOTLIN, - "Model", - "Struct", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), - pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), - pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), - pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), - ], - indirect=["serena_agent"], - ) - def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): - # skip flaky tests in CI - # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040 - flaky_languages = {Language.FSHARP, Language.RUST} - if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: - pytest.skip("Test is flaky and thus skipped in CI environment.") - + def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) @@ -175,54 +145,53 @@ class TestSerenaAgent: assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", + "serena_agent,symbol_name,expected_kind,expected_file", [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - os.path.join("test_repo", "services.py"), - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - os.path.join("src", "main", "java", "test_repo", "Main.java"), - marks=pytest.mark.java, - ), + pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), + pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), + pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), pytest.param( Language.KOTLIN, "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Struct", + "Model.kt", marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), ), - pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), - pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - "multiply", - clj.CORE_PATH, - clj.UTILS_PATH, - marks=pytest.mark.clojure, - ), - pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), + pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), + pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), + pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), + pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), ], indirect=["serena_agent"], ) - def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - # skip flaky tests in CI - # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040 - flaky_languages = {Language.TYPESCRIPT} - if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: - pytest.skip("Test is flaky and thus skipped in CI environment.") + def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + @pytest.mark.parametrize( + "serena_agent,symbol_name,expected_kind,expected_file", + [ + pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 + def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,expected_kind,expected_file", + [ + pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 + def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + + def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: agent = serena_agent # Find the symbol location first @@ -257,6 +226,71 @@ class TestSerenaAgent: refs = json.loads(result) assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param( + Language.PYTHON, + "User", + os.path.join("test_repo", "models.py"), + os.path.join("test_repo", "services.py"), + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + "Model", + os.path.join("src", "main", "java", "test_repo", "Model.java"), + os.path.join("src", "main", "java", "test_repo", "Main.java"), + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + "Model", + os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), + pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + "multiply", + clj.CORE_PATH, + clj.UTILS_PATH, + marks=pytest.mark.clojure, + ), + pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), + ], + indirect=["serena_agent"], + ) + def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="TypeScript language server is unreliable") # See issue #1040 + def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 + def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ From 90967ad67870e0a6ebe9b93b8df0c7066baa25d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 21:13:18 +0100 Subject: [PATCH 5/5] TestFSharpLanguageServer: Mark test_find_referencing_symbols with xfail --- test/solidlsp/fsharp/test_fsharp_basic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 4e6d7024..26a0e695 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -49,6 +49,7 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" + @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range."""