CSharpLanguageServer: Refactored to use DependencyProvider

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-01-15 18:55:15 +01:00
1 parent 62cff92b0f
commit b33a4085c2
2 files changed
+235 -226

No files matched your search

@@ -18,12 +18,11 @@ from typing import Any, cast
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_utils import PathUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.zip import SafeZipExtractor
@@ -212,266 +211,276 @@ class CSharpLanguageServer(SolidLanguageServer):
Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
dotnet_path, language_server_path = self._ensure_server_installed(config, solidlsp_settings)
# Find solution or project file
solution_or_project = find_solution_or_project_file(repository_root_path)
# Create log directory
log_dir = Path(self.ls_resources_dir(solidlsp_settings)) / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
# Build command using dotnet directly
cmd = [dotnet_path, language_server_path, "--logLevel=Information", f"--extensionLogDirectory={log_dir}", "--stdio"]
# The language server will discover the solution/project from the workspace root
if solution_or_project:
log.info(f"Found solution/project file: {solution_or_project}")
else:
log.warning("No .sln or .csproj file found, language server will attempt auto-discovery")
log.debug(f"Language server command: {' '.join(cmd)}")
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings)
super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings)
self.initialization_complete = threading.Event()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"]
@classmethod
def _ensure_server_installed(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]:
"""
Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
Returns a tuple of (dotnet_path, language_server_dll_path).
"""
language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
runtime_dependency_overrides = cast(list[dict[str, Any]], language_specific_config.get("runtime_dependencies", []))
class DependencyProvider(LanguageServerDependencyProvider):
def __init__(
self,
custom_settings: SolidLSPSettings.CustomLSSettings,
ls_resources_dir: str,
solidlsp_settings: SolidLSPSettings,
repository_root_path: str,
):
super().__init__(custom_settings, ls_resources_dir)
self._solidlsp_settings = solidlsp_settings
self._repository_root_path = repository_root_path
self._dotnet_path, self._language_server_path = self._ensure_server_installed()
log.debug("Resolving runtime dependencies")
def create_launch_command(self) -> list[str] | str:
# Find solution or project file
solution_or_project = find_solution_or_project_file(self._repository_root_path)
runtime_dependencies = RuntimeDependencyCollection(
_RUNTIME_DEPENDENCIES,
overrides=runtime_dependency_overrides,
)
# Create log directory
log_dir = Path(self._ls_resources_dir) / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log.debug(
f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}",
)
# Build command using dotnet directly
cmd = [self._dotnet_path, self._language_server_path, "--logLevel=Information", f"--extensionLogDirectory={log_dir}", "--stdio"]
# 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 = CSharpLanguageServer._ensure_dotnet_runtime(dotnet_runtime_dep, solidlsp_settings)
server_dll_path = CSharpLanguageServer._ensure_language_server(lang_server_dep, solidlsp_settings)
# The language server will discover the solution/project from the workspace root
if solution_or_project:
log.info(f"Found solution/project file: {solution_or_project}")
else:
log.warning("No .sln or .csproj file found, language server will attempt auto-discovery")
return dotnet_path, server_dll_path
log.debug(f"Language server command: {' '.join(cmd)}")
@classmethod
def _ensure_dotnet_runtime(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
"""Ensure .NET runtime is available and return the dotnet executable path."""
# TODO: use RuntimeDependency util methods instead of custom validation/download logic
return cmd
# 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
def _ensure_server_installed(self) -> tuple[str, str]:
"""
Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
Returns a tuple of (dotnet_path, language_server_dll_path).
"""
runtime_dependency_overrides = cast(list[dict[str, Any]], self._custom_settings.get("runtime_dependencies", []))
# Download .NET 9 runtime using config
return cls._ensure_dotnet_runtime_from_config(dotnet_runtime_dep, solidlsp_settings)
log.debug("Resolving runtime dependencies")
@classmethod
def _ensure_language_server(cls, lang_server_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
"""Ensure language server is available and return the DLL path."""
package_name = lang_server_dep.package_name
package_version = lang_server_dep.package_version
runtime_dependencies = RuntimeDependencyCollection(
_RUNTIME_DEPENDENCIES,
overrides=runtime_dependency_overrides,
)
server_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / f"{package_name}.{package_version}"
assert lang_server_dep.binary_name is not None
server_dll = server_dir / lang_server_dep.binary_name
log.debug(
f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}",
)
if server_dll.exists():
log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}")
# 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)
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:
"""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
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
return self._ensure_dotnet_runtime_from_config(dotnet_runtime_dep)
def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str:
"""Ensure language server is available and return the DLL path."""
package_name = lang_server_dep.package_name
package_version = lang_server_dep.package_version
server_dir = Path(self._ls_resources_dir) / f"{package_name}.{package_version}"
assert lang_server_dep.binary_name is not None
server_dll = server_dir / lang_server_dep.binary_name
if server_dll.exists():
log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}")
return str(server_dll)
# Download and install the language server
log.info(f"Downloading {package_name} version {package_version}...")
assert package_version is not None
assert package_name is not None
package_path = self._download_nuget_package_direct(package_name, package_version)
# Extract and install
self._extract_language_server(lang_server_dep, package_path, server_dir)
if not server_dll.exists():
raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
# Make executable on Unix systems
if platform.system().lower() != "windows":
server_dll.chmod(0o755)
log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}")
return str(server_dll)
# Download and install the language server
log.info(f"Downloading {package_name} version {package_version}...")
assert package_version is not None
assert package_name is not None
package_path = cls._download_nuget_package_direct(package_name, package_version, solidlsp_settings)
@staticmethod
def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None:
"""Extract language server files from downloaded package."""
extract_path = lang_server_dep.extract_path or "lib/net9.0"
source_dir = package_path / extract_path
# Extract and install
cls._extract_language_server(lang_server_dep, package_path, server_dir)
if not source_dir.exists():
# Try alternative locations
for possible_dir in [
package_path / "tools" / "net9.0" / "any",
package_path / "lib" / "net9.0",
package_path / "contentFiles" / "any" / "net9.0",
]:
if possible_dir.exists():
source_dir = possible_dir
break
else:
raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}")
if not server_dll.exists():
raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
# Copy files to cache directory
server_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, server_dir, dirs_exist_ok=True)
# Make executable on Unix systems
if platform.system().lower() != "windows":
server_dll.chmod(0o755)
def _download_nuget_package_direct(self, package_name: str, package_version: str) -> Path:
"""
Download a NuGet package directly from the Azure NuGet feed.
Returns the path to the extracted package directory.
"""
azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json"
log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}")
return str(server_dll)
# Create temporary directory for package download
temp_dir = Path(self._ls_resources_dir) / "temp_downloads"
temp_dir.mkdir(parents=True, exist_ok=True)
@staticmethod
def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None:
"""Extract language server files from downloaded package."""
extract_path = lang_server_dep.extract_path or "lib/net9.0"
source_dir = package_path / extract_path
if not source_dir.exists():
# Try alternative locations
for possible_dir in [
package_path / "tools" / "net9.0" / "any",
package_path / "lib" / "net9.0",
package_path / "contentFiles" / "any" / "net9.0",
]:
if possible_dir.exists():
source_dir = possible_dir
break
else:
raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}")
# Copy files to cache directory
server_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, server_dir, dirs_exist_ok=True)
@classmethod
def _download_nuget_package_direct(cls, package_name: str, package_version: str, solidlsp_settings: SolidLSPSettings) -> Path:
"""
Download a NuGet package directly from the Azure NuGet feed.
Returns the path to the extracted package directory.
"""
azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json"
# Create temporary directory for package download
temp_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "temp_downloads"
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# First, get the service index from the Azure feed
log.debug("Fetching NuGet service index from Azure feed...")
with urllib.request.urlopen(azure_feed_url) as response:
service_index = json.loads(response.read().decode())
# Find the package base address (for downloading packages)
package_base_address = None
for resource in service_index.get("resources", []):
if resource.get("@type") == "PackageBaseAddress/3.0.0":
package_base_address = resource.get("@id")
break
if not package_base_address:
raise SolidLSPException("Could not find package base address in Azure NuGet feed")
# Construct the download URL for the specific package
package_id_lower = package_name.lower()
package_version_lower = package_version.lower()
package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg"
log.debug(f"Downloading package from: {package_url}")
# Download the .nupkg file
nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg"
urllib.request.urlretrieve(package_url, nupkg_file)
# Extract the .nupkg file (it's just a zip file)
package_extract_dir = temp_dir / f"{package_name}.{package_version}"
package_extract_dir.mkdir(exist_ok=True)
# Use SafeZipExtractor to handle long paths and skip errors
extractor = SafeZipExtractor(archive_path=nupkg_file, extract_dir=package_extract_dir, verbose=False)
extractor.extract_all()
# Clean up the nupkg file
nupkg_file.unlink()
log.info(f"Successfully downloaded and extracted {package_name} version {package_version}")
return package_extract_dir
except Exception as e:
raise SolidLSPException(
f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}"
) from e
@classmethod
def _ensure_dotnet_runtime_from_config(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
"""
Ensure .NET 9 runtime is available using runtime dependency configuration.
Returns the path to the dotnet executable.
"""
# TODO: use RuntimeDependency util methods instead of custom 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
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
# First, get the service index from the Azure feed
log.debug("Fetching NuGet service index from Azure feed...")
with urllib.request.urlopen(azure_feed_url) as response:
service_index = json.loads(response.read().decode())
# Download .NET 9 runtime using config
dotnet_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "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
# Find the package base address (for downloading packages)
package_base_address = None
for resource in service_index.get("resources", []):
if resource.get("@type") == "PackageBaseAddress/3.0.0":
package_base_address = resource.get("@id")
break
if dotnet_exe.exists():
log.info(f"Using cached .NET runtime from {dotnet_exe}")
return str(dotnet_exe)
if not package_base_address:
raise SolidLSPException("Could not find package base address in Azure NuGet feed")
# Download .NET runtime
log.info("Downloading .NET 9 runtime...")
dotnet_dir.mkdir(parents=True, exist_ok=True)
# Construct the download URL for the specific package
package_id_lower = package_name.lower()
package_version_lower = package_version.lower()
package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg"
custom_settings = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
custom_dotnet_runtime_url = 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
log.debug(f"Downloading package from: {package_url}")
archive_type = dotnet_runtime_dep.archive_type
# Download the .nupkg file
nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg"
urllib.request.urlretrieve(package_url, nupkg_file)
# 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)
# Extract the .nupkg file (it's just a zip file)
package_extract_dir = temp_dir / f"{package_name}.{package_version}"
package_extract_dir.mkdir(exist_ok=True)
# Extract the archive
if archive_type == "zip":
with zipfile.ZipFile(download_path, "r") as zip_ref:
zip_ref.extractall(dotnet_dir)
# Use SafeZipExtractor to handle long paths and skip errors
extractor = SafeZipExtractor(archive_path=nupkg_file, extract_dir=package_extract_dir, verbose=False)
extractor.extract_all()
# Clean up the nupkg file
nupkg_file.unlink()
log.info(f"Successfully downloaded and extracted {package_name} version {package_version}")
return package_extract_dir
except Exception as e:
raise SolidLSPException(
f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}"
) from e
def _ensure_dotnet_runtime_from_config(self, dotnet_runtime_dep: RuntimeDependency) -> str:
"""
Ensure .NET 9 runtime is available using runtime dependency configuration.
Returns the path to the dotnet executable.
"""
# TODO: use RuntimeDependency util methods instead of custom 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
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
if dotnet_exe.exists():
log.info(f"Using cached .NET runtime from {dotnet_exe}")
return str(dotnet_exe)
# Download .NET runtime
log.info("Downloading .NET 9 runtime...")
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:
# tar.gz
with tarfile.open(download_path, "r:gz") as tar_ref:
tar_ref.extractall(dotnet_dir)
url = dotnet_runtime_dep.url
# Remove the archive
download_path.unlink()
archive_type = dotnet_runtime_dep.archive_type
# Make dotnet executable on Unix
if platform.system().lower() != "windows":
dotnet_exe.chmod(0o755)
# 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)
log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}")
return str(dotnet_exe)
# Extract the archive
if archive_type == "zip":
with zipfile.ZipFile(download_path, "r") as zip_ref:
zip_ref.extractall(dotnet_dir)
else:
# tar.gz
with tarfile.open(download_path, "r:gz") as tar_ref:
tar_ref.extractall(dotnet_dir)
except Exception as e:
raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e
# Remove the archive
download_path.unlink()
# Make dotnet executable on Unix
if platform.system().lower() != "windows":
dotnet_exe.chmod(0o755)
log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}")
return str(dotnet_exe)
except Exception as e:
raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e
def _get_initialize_params(self) -> InitializeParams:
"""
+2 -2
View File
@@ -237,7 +237,7 @@ class TestCSharpSolutionProjectOpening:
# Should still prefer .sln file even though it's deeper
assert result == str(solution_file)
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_solution_discovery(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs solution/project discovery during initialization."""
@@ -266,7 +266,7 @@ class TestCSharpSolutionProjectOpening:
expected_log_msg = f"Found solution/project file: {solution_file}"
assert expected_log_msg in mem_log.get_log()
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_no_solution_warning(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs warning when no solution/project files are found."""