diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 2a86a492..80a8b137 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -7,10 +7,8 @@ import os import platform import shutil import subprocess -import tarfile import threading import urllib.request -import zipfile from collections.abc import Iterable from pathlib import Path from typing import Any, cast @@ -96,54 +94,6 @@ _RUNTIME_DEPENDENCIES = [ binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/linux-arm64", ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Windows (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-x64.zip", - platform_id="win-x64", - archive_type="zip", - binary_name="dotnet.exe", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Linux (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-x64.tar.gz", - platform_id="linux-x64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Linux (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-arm64.tar.gz", - platform_id="linux-arm64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for macOS (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-x64.tar.gz", - platform_id="osx-x64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for macOS (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-arm64.tar.gz", - platform_id="osx-arm64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Windows (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-arm64.zip", - platform_id="win-arm64", - archive_type="zip", - binary_name="dotnet.exe", - ), ] @@ -279,30 +229,28 @@ class CSharpLanguageServer(SolidLanguageServer): # Find the dependencies for our platform lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer") - dotnet_runtime_dep = runtime_dependencies.get_single_dep_for_current_platform("DotNetRuntime") - dotnet_path = self._ensure_dotnet_runtime(dotnet_runtime_dep) + dotnet_path = self._ensure_dotnet_runtime() server_dll_path = self._ensure_language_server(lang_server_dep) return dotnet_path, server_dll_path - def _ensure_dotnet_runtime(self, dotnet_runtime_dep: RuntimeDependency) -> str: + def _ensure_dotnet_runtime(self) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" - # TODO: use RuntimeDependency util methods instead of custom validation/download logic - # Check if dotnet is already available on the system system_dotnet = shutil.which("dotnet") if system_dotnet: - # Check if it's .NET 9 + # Check if it's .NET 10 or compatible try: result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - if "Microsoft.NETCore.App 9." in result.stdout: - log.info("Found system .NET 9 runtime") + # 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 - # Download .NET 9 runtime using config - return self._ensure_dotnet_runtime_from_config(dotnet_runtime_dep) + # Install .NET 10 runtime using Microsoft's install script + return self._install_dotnet_with_script() def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" @@ -397,74 +345,82 @@ 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 _ensure_dotnet_runtime_from_config(self, dotnet_runtime_dep: RuntimeDependency) -> str: + def _install_dotnet_with_script(self, version: str = "10.0") -> str: """ - Ensure .NET 9 runtime is available using runtime dependency configuration. + Install .NET runtime using Microsoft's official install script. Returns the path to the dotnet executable. """ - # TODO: use RuntimeDependency util methods instead of custom download logic + dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}" - # Check if dotnet is already available on the system - system_dotnet = shutil.which("dotnet") - if system_dotnet: - # Check if it's .NET 9 - try: - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - if "Microsoft.NETCore.App 9." in result.stdout: - log.info("Found system .NET 9 runtime") - return system_dotnet - except subprocess.CalledProcessError: - pass - - # Download .NET 9 runtime using config - dotnet_dir = Path(self._ls_resources_dir) / "dotnet-runtime-9.0" - assert dotnet_runtime_dep.binary_name is not None, "Runtime dependency must have a binary_name" - dotnet_exe = dotnet_dir / dotnet_runtime_dep.binary_name + # 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 runtime from {dotnet_exe}") + log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") return str(dotnet_exe) - # Download .NET runtime - log.info("Downloading .NET 9 runtime...") + # 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) - custom_dotnet_runtime_url = self._custom_settings.get("dotnet_runtime_url") - if custom_dotnet_runtime_url is not None: - log.info(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}") - url = custom_dotnet_runtime_url - else: - url = dotnet_runtime_dep.url - - archive_type = dotnet_runtime_dep.archive_type - - # Download the runtime - download_path = dotnet_dir / f"dotnet-runtime.{archive_type}" try: - log.debug(f"Downloading from {url}") - urllib.request.urlretrieve(url, download_path) + 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) - # Extract the archive - if archive_type == "zip": - with zipfile.ZipFile(download_path, "r") as zip_ref: - zip_ref.extractall(dotnet_dir) + cmd = [ + "pwsh", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script_path), + "-Version", + version, + "-InstallDir", + str(dotnet_dir), + "-Runtime", + "dotnet", + "-NoPath", + ] else: - # tar.gz - with tarfile.open(download_path, "r:gz") as tar_ref: - tar_ref.extractall(dotnet_dir) + # 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) - # Remove the archive - download_path.unlink() + cmd = [ + "bash", + str(script_path), + "--version", + version, + "--install-dir", + str(dotnet_dir), + "--runtime", + "dotnet", + "--no-path", + ] - # Make dotnet executable on Unix - if platform.system().lower() != "windows": - dotnet_exe.chmod(0o755) + # Run the install script + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + log.debug(f"Install script output: {result.stdout}") - log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}") + 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: - raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e + raise SolidLSPException(f"Failed to install .NET {version} runtime: {e}") from e def _get_initialize_params(self) -> InitializeParams: """ diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py new file mode 100644 index 00000000..9f463e4e --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_dotnet_install.py @@ -0,0 +1,145 @@ +"""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.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_ensure_ls): + """Test that Linux uses bash install script.""" + mock_platform.return_value = "Linux" + 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.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_ensure_ls + ): + """Test that Windows uses PowerShell install script.""" + mock_platform.return_value = "Windows" + 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"