mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 08:18:36 +00:00
Fix C# Language Server (#262)
Now when the language server starts, the initialisation will scan for solution or project files, and then pass them to the language server instructing it to open them. Which in-turn should give us all the nice context since the code is actually being 'loaded' up properly now.
This commit is contained in:
1 parent
16973aff61
commit
cfe3350c34
3 files changed
+405
-197
No files matched your search
@@ -2,14 +2,13 @@
|
||||
CSharp Language Server using Microsoft.CodeAnalysis.LanguageServer (Official Roslyn-based LSP server)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
@@ -49,7 +48,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for Windows (x64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.win-x64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="win-x64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -59,7 +58,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for Windows (ARM64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.win-arm64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="win-arm64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -69,7 +68,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for macOS (x64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.osx-x64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="osx-x64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -79,7 +78,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for macOS (ARM64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.osx-arm64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="osx-arm64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -89,7 +88,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for Linux (x64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.linux-x64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="linux-x64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -99,7 +98,7 @@ RUNTIME_DEPENDENCIES = [
|
||||
id="CSharpLanguageServer",
|
||||
description="Microsoft.CodeAnalysis.LanguageServer for Linux (ARM64)",
|
||||
package_name="Microsoft.CodeAnalysis.LanguageServer.linux-arm64",
|
||||
package_version="5.0.0-1.25277.114",
|
||||
package_version="5.0.0-1.25329.6",
|
||||
platform_id="linux-arm64",
|
||||
archive_type="nupkg",
|
||||
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
|
||||
@@ -139,14 +138,6 @@ RUNTIME_DEPENDENCIES = [
|
||||
),
|
||||
]
|
||||
|
||||
# NuGet sources for package downloads
|
||||
NUGET_SOURCES = [
|
||||
"https://api.nuget.org/v3/index.json",
|
||||
"https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json",
|
||||
"https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json",
|
||||
"https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json",
|
||||
]
|
||||
|
||||
|
||||
def breadth_first_file_scan(root_dir):
|
||||
"""
|
||||
@@ -203,27 +194,17 @@ 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, cache_dir = self.setup_runtime_dependencies(logger, config)
|
||||
dotnet_path, language_server_path, cache_dir = self._ensure_server_installed(logger, config)
|
||||
|
||||
# Find solution or project file
|
||||
solution_or_project = find_solution_or_project_file(repository_root_path)
|
||||
|
||||
# Build command - Microsoft.CodeAnalysis.LanguageServer requires specific parameters
|
||||
cmd_parts = [dotnet_path, language_server_path]
|
||||
|
||||
# Required parameters
|
||||
cmd_parts.extend(["--stdio"]) # Use stdio for communication
|
||||
|
||||
# Set log level based on logger settings
|
||||
if logger.logger.level <= logging.DEBUG:
|
||||
cmd_parts.extend(["--logLevel", "Information"])
|
||||
else:
|
||||
cmd_parts.extend(["--logLevel", "Warning"])
|
||||
|
||||
# Create log directory
|
||||
log_dir = cache_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd_parts.extend(["--extensionLogDirectory", str(log_dir)])
|
||||
|
||||
# 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:
|
||||
@@ -231,11 +212,7 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
else:
|
||||
logger.log("No .sln or .csproj file found, language server will attempt auto-discovery", logging.WARNING)
|
||||
|
||||
# Join command parts with spaces
|
||||
# ProcessLaunchInfo expects a simple string command, not shell-quoted
|
||||
cmd = cmd_parts
|
||||
|
||||
logger.log(f"Language server command: {cmd}", logging.DEBUG)
|
||||
logger.log(f"Language server command: {' '.join(cmd)}", logging.DEBUG)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
@@ -251,30 +228,45 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"]
|
||||
|
||||
def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str, Path]:
|
||||
def _ensure_server_installed(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str, Path]:
|
||||
"""
|
||||
Set up .NET 9 runtime and Microsoft.CodeAnalysis.LanguageServer using runtime dependencies.
|
||||
Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
|
||||
Returns a tuple of (dotnet_path, language_server_dll_path, cache_dir).
|
||||
"""
|
||||
# Determine the runtime ID based on the platform
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
# Map platform info to runtime ID
|
||||
if system == "windows":
|
||||
runtime_id = "win-x64" if machine in ["amd64", "x86_64"] else "win-arm64"
|
||||
elif system == "darwin":
|
||||
runtime_id = "osx-x64" if machine in ["x86_64"] else "osx-arm64"
|
||||
elif system == "linux":
|
||||
runtime_id = "linux-x64" if machine in ["x86_64", "amd64"] else "linux-arm64"
|
||||
else:
|
||||
raise LanguageServerException(f"Unsupported platform: {system} {machine}")
|
||||
# Determine platform runtime ID
|
||||
runtime_id = self._get_runtime_id()
|
||||
|
||||
# Set up cache directory
|
||||
cache_dir = Path.home() / ".cache" / "serena" / "language-servers" / "csharp"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find the appropriate language server dependency
|
||||
# Get runtime dependencies for this platform
|
||||
lang_server_dep, dotnet_runtime_dep = self._get_runtime_dependencies(runtime_id)
|
||||
|
||||
# Ensure .NET runtime is available
|
||||
dotnet_path = self._ensure_dotnet_runtime(logger, cache_dir, dotnet_runtime_dep)
|
||||
|
||||
# Ensure language server is available
|
||||
server_dll_path = self._ensure_language_server(logger, cache_dir, lang_server_dep)
|
||||
|
||||
return dotnet_path, server_dll_path, cache_dir
|
||||
|
||||
def _get_runtime_id(self) -> str:
|
||||
"""Determine the runtime ID based on the platform."""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "windows":
|
||||
return "win-x64" if machine in ["amd64", "x86_64"] else "win-arm64"
|
||||
elif system == "darwin":
|
||||
return "osx-x64" if machine in ["x86_64"] else "osx-arm64"
|
||||
elif system == "linux":
|
||||
return "linux-x64" if machine in ["x86_64", "amd64"] else "linux-arm64"
|
||||
else:
|
||||
raise LanguageServerException(f"Unsupported platform: {system} {machine}")
|
||||
|
||||
def _get_runtime_dependencies(self, runtime_id: str) -> tuple[RuntimeDependency, RuntimeDependency]:
|
||||
"""Get the language server and .NET runtime dependencies for the platform."""
|
||||
lang_server_dep = None
|
||||
dotnet_runtime_dep = None
|
||||
|
||||
@@ -289,10 +281,29 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
if not dotnet_runtime_dep:
|
||||
raise LanguageServerException(f"No .NET runtime dependency found for platform {runtime_id}")
|
||||
|
||||
# First, ensure we have .NET 9 runtime
|
||||
dotnet_path = self._ensure_dotnet_runtime_from_config(logger, cache_dir, dotnet_runtime_dep)
|
||||
return lang_server_dep, dotnet_runtime_dep
|
||||
|
||||
# Then set up the language server
|
||||
def _ensure_dotnet_runtime(self, logger: LanguageServerLogger, cache_dir: Path, runtime_dep: RuntimeDependency) -> str:
|
||||
"""Ensure .NET runtime is available and return the dotnet executable path."""
|
||||
# Check if dotnet is already available in system
|
||||
system_dotnet = shutil.which("dotnet")
|
||||
if system_dotnet:
|
||||
# Check if it's .NET 9
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
|
||||
if "Microsoft.NETCore.App 9." in result.stdout:
|
||||
logger.log("Found system .NET 9 runtime", logging.INFO)
|
||||
return system_dotnet
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Download .NET 9 runtime using config
|
||||
return self._ensure_dotnet_runtime_from_config(logger, cache_dir, runtime_dep)
|
||||
|
||||
def _ensure_language_server(self, logger: LanguageServerLogger, cache_dir: Path, 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
|
||||
|
||||
@@ -301,145 +312,104 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
|
||||
if server_dll.exists():
|
||||
logger.log(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}", logging.INFO)
|
||||
return dotnet_path, str(server_dll), cache_dir
|
||||
return str(server_dll)
|
||||
|
||||
# Download the language server package
|
||||
# Download and install the language server
|
||||
logger.log(f"Downloading {package_name} version {package_version}...", logging.INFO)
|
||||
package_path = self._download_nuget_package_direct(logger, package_name, package_version, cache_dir)
|
||||
|
||||
# Check if nuget or dotnet is available
|
||||
nuget_cmd = shutil.which("nuget")
|
||||
dotnet_cmd = shutil.which("dotnet")
|
||||
# Extract and install
|
||||
self._extract_language_server(lang_server_dep, package_path, server_dir)
|
||||
|
||||
if not nuget_cmd and not dotnet_cmd:
|
||||
if not server_dll.exists():
|
||||
raise LanguageServerException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
|
||||
|
||||
# Make executable on Unix systems
|
||||
if platform.system().lower() != "windows":
|
||||
server_dll.chmod(server_dll.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO)
|
||||
return str(server_dll)
|
||||
|
||||
def _extract_language_server(self, 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 LanguageServerException(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)
|
||||
|
||||
def _download_nuget_package_direct(
|
||||
self, logger: LanguageServerLogger, package_name: str, package_version: str, cache_dir: Path
|
||||
) -> 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 = cache_dir / "temp_downloads"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# First, get the service index from the Azure feed
|
||||
logger.log("Fetching NuGet service index from Azure feed...", logging.DEBUG)
|
||||
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 LanguageServerException("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"
|
||||
|
||||
logger.log(f"Downloading package from: {package_url}", logging.DEBUG)
|
||||
|
||||
# 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)
|
||||
|
||||
with zipfile.ZipFile(nupkg_file, "r") as zip_ref:
|
||||
zip_ref.extractall(package_extract_dir)
|
||||
|
||||
# Clean up the nupkg file
|
||||
nupkg_file.unlink()
|
||||
|
||||
logger.log(f"Successfully downloaded and extracted {package_name} version {package_version}", logging.INFO)
|
||||
return package_extract_dir
|
||||
|
||||
except Exception as e:
|
||||
raise LanguageServerException(
|
||||
"Neither nuget nor dotnet CLI is available. Please install .NET SDK from https://dotnet.microsoft.com/download"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Try to download directly from the NuGet API first
|
||||
direct_download_url = f"https://api.nuget.org/v3-flatcontainer/{package_name.lower()}/{package_version}/{package_name.lower()}.{package_version}.nupkg"
|
||||
|
||||
package_path = None
|
||||
try:
|
||||
nupkg_path = temp_path / f"{package_name}.{package_version}.nupkg"
|
||||
logger.log(f"Attempting direct download from {direct_download_url}", logging.INFO)
|
||||
|
||||
urllib.request.urlretrieve(direct_download_url, nupkg_path)
|
||||
|
||||
# Extract the nupkg (it's a zip file)
|
||||
package_path = temp_path / f"{package_name}.{package_version}"
|
||||
with zipfile.ZipFile(nupkg_path, "r") as zip_ref:
|
||||
zip_ref.extractall(package_path)
|
||||
|
||||
logger.log("Successfully downloaded and extracted package", logging.INFO)
|
||||
|
||||
except Exception as e:
|
||||
logger.log(f"Direct download failed: {e}, falling back to package manager", logging.WARNING)
|
||||
package_path = None
|
||||
|
||||
if package_path is None and dotnet_cmd:
|
||||
# Use dotnet restore to download the package
|
||||
project_content = f"""<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="{package_name}" Version="{package_version}" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<RestoreAdditionalProjectSources>
|
||||
{';'.join(NUGET_SOURCES)}
|
||||
</RestoreAdditionalProjectSources>
|
||||
</PropertyGroup>
|
||||
</Project>"""
|
||||
|
||||
project_file = temp_path / "temp.csproj"
|
||||
project_file.write_text(project_content)
|
||||
|
||||
try:
|
||||
# Use dotnet restore with no dependencies
|
||||
subprocess.run(
|
||||
[
|
||||
dotnet_cmd,
|
||||
"restore",
|
||||
str(project_file),
|
||||
"--packages",
|
||||
str(temp_path),
|
||||
"--no-dependencies",
|
||||
"--ignore-failed-sources",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
package_path = temp_path / package_name.lower() / package_version
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.log(f"Dotnet restore stdout: {e.stdout}", logging.ERROR)
|
||||
logger.log(f"Dotnet restore stderr: {e.stderr}", logging.ERROR)
|
||||
raise LanguageServerException(f"Failed to download package: stdout={e.stdout}, stderr={e.stderr}")
|
||||
|
||||
elif package_path is None and nuget_cmd:
|
||||
# Use nuget to download the package
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
nuget_cmd,
|
||||
"install",
|
||||
package_name,
|
||||
"-Version",
|
||||
package_version,
|
||||
"-OutputDirectory",
|
||||
str(temp_path),
|
||||
"-NonInteractive",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Find the downloaded package
|
||||
package_path = temp_path / f"{package_name}.{package_version}"
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise LanguageServerException(f"Failed to download package: {e.stderr}")
|
||||
|
||||
if package_path is None or not package_path.exists():
|
||||
raise LanguageServerException("Failed to download Microsoft.CodeAnalysis.LanguageServer package")
|
||||
|
||||
# Extract the language server files
|
||||
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 LanguageServerException(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)
|
||||
|
||||
if not server_dll.exists():
|
||||
raise LanguageServerException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
|
||||
|
||||
# Make the DLL executable on Unix-like systems
|
||||
if system != "windows":
|
||||
server_dll.chmod(server_dll.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO)
|
||||
return dotnet_path, str(server_dll), cache_dir
|
||||
f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}"
|
||||
) from e
|
||||
|
||||
def _ensure_dotnet_runtime_from_config(self, logger: LanguageServerLogger, cache_dir: Path, runtime_dep: RuntimeDependency) -> str:
|
||||
"""
|
||||
@@ -451,6 +421,8 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
if system_dotnet:
|
||||
# Check if it's .NET 9
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
|
||||
if "Microsoft.NETCore.App 9." in result.stdout:
|
||||
logger.log("Found system .NET 9 runtime", logging.INFO)
|
||||
@@ -463,14 +435,8 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
dotnet_exe = dotnet_dir / runtime_dep.binary_name
|
||||
|
||||
if dotnet_exe.exists():
|
||||
# Verify it still works
|
||||
try:
|
||||
subprocess.run([str(dotnet_exe), "--info"], capture_output=True, check=True)
|
||||
logger.log(f"Using cached .NET runtime from {dotnet_exe}", logging.INFO)
|
||||
return str(dotnet_exe)
|
||||
except subprocess.CalledProcessError:
|
||||
logger.log("Cached .NET runtime is corrupted, re-downloading", logging.WARNING)
|
||||
shutil.rmtree(dotnet_dir, ignore_errors=True)
|
||||
logger.log(f"Using cached .NET runtime from {dotnet_exe}", logging.INFO)
|
||||
return str(dotnet_exe)
|
||||
|
||||
# Download .NET runtime
|
||||
logger.log("Downloading .NET 9 runtime...", logging.INFO)
|
||||
@@ -744,6 +710,9 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
except Exception as e:
|
||||
raise LanguageServerException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e
|
||||
|
||||
# Apply diagnostic capabilities
|
||||
self._force_pull_diagnostics(init_response)
|
||||
|
||||
# Verify required capabilities
|
||||
capabilities = init_response.get("capabilities", {})
|
||||
required_capabilities = [
|
||||
@@ -761,6 +730,10 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
|
||||
# Complete initialization
|
||||
self.server.notify.initialized({})
|
||||
|
||||
# Open solution and project files
|
||||
self._open_solution_and_projects()
|
||||
|
||||
self.initialization_complete.set()
|
||||
self.completions_available.set()
|
||||
|
||||
@@ -770,3 +743,51 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
"This may take a while for large projects",
|
||||
logging.INFO,
|
||||
)
|
||||
|
||||
def _force_pull_diagnostics(self, init_response: dict) -> None:
|
||||
"""
|
||||
Apply the diagnostic capabilities hack.
|
||||
Forces the server to support pull diagnostics.
|
||||
"""
|
||||
capabilities = init_response.get("capabilities", {})
|
||||
diagnostic_provider = capabilities.get("diagnosticProvider", {})
|
||||
|
||||
# Add the diagnostic capabilities hack
|
||||
if isinstance(diagnostic_provider, dict):
|
||||
diagnostic_provider.update(
|
||||
{
|
||||
"interFileDependencies": True,
|
||||
"workDoneProgress": True,
|
||||
"workspaceDiagnostics": True,
|
||||
}
|
||||
)
|
||||
self.logger.log("Applied diagnostic capabilities hack for better C# diagnostics", logging.DEBUG)
|
||||
|
||||
def _open_solution_and_projects(self) -> None:
|
||||
"""
|
||||
Open solution and project files using notifications.
|
||||
"""
|
||||
# Find solution file
|
||||
solution_file = None
|
||||
for filename in breadth_first_file_scan(self.repository_root_path):
|
||||
if filename.endswith(".sln"):
|
||||
solution_file = filename
|
||||
break
|
||||
|
||||
# Send solution/open notification if solution file found
|
||||
if solution_file:
|
||||
solution_uri = PathUtils.path_to_uri(solution_file)
|
||||
self.server.notify.send_notification("solution/open", {"solution": solution_uri})
|
||||
self.logger.log(f"Opened solution file: {solution_file}", logging.INFO)
|
||||
|
||||
# Find and open project files
|
||||
project_files = []
|
||||
for filename in breadth_first_file_scan(self.repository_root_path):
|
||||
if filename.endswith(".csproj"):
|
||||
project_files.append(filename)
|
||||
|
||||
# Send project/open notifications for each project file
|
||||
if project_files:
|
||||
project_uris = [PathUtils.path_to_uri(project_file) for project_file in project_files]
|
||||
self.server.notify.send_notification("project/open", {"projects": project_uris})
|
||||
self.logger.log(f"Opened project files: {project_files}", logging.DEBUG)
|
||||
@@ -10,6 +10,7 @@ import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from overrides import override
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.language_servers.csharp_language_server.csharp_language_server import (
|
||||
CSharpLanguageServer,
|
||||
breadth_first_file_scan,
|
||||
find_solution_or_project_file,
|
||||
)
|
||||
from solidlsp.ls_config import Language, LanguageServerConfig
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
|
||||
|
||||
@@ -77,3 +85,181 @@ class TestCSharpLanguageServer:
|
||||
assert "Email" in symbol_names
|
||||
assert "ToString" in symbol_names
|
||||
assert "IsAdult" in symbol_names
|
||||
|
||||
|
||||
@pytest.mark.csharp
|
||||
class TestCSharpSolutionProjectOpening:
|
||||
"""Test C# language server solution and project opening functionality."""
|
||||
|
||||
def test_breadth_first_file_scan(self):
|
||||
"""Test that breadth_first_file_scan finds files in breadth-first order."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test directory structure
|
||||
(temp_path / "file1.txt").touch()
|
||||
(temp_path / "subdir1").mkdir()
|
||||
(temp_path / "subdir1" / "file2.txt").touch()
|
||||
(temp_path / "subdir2").mkdir()
|
||||
(temp_path / "subdir2" / "file3.txt").touch()
|
||||
(temp_path / "subdir1" / "subdir3").mkdir()
|
||||
(temp_path / "subdir1" / "subdir3" / "file4.txt").touch()
|
||||
|
||||
# Scan files
|
||||
files = list(breadth_first_file_scan(str(temp_path)))
|
||||
filenames = [os.path.basename(f) for f in files]
|
||||
|
||||
# Should find all files
|
||||
assert len(files) == 4
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.txt" in filenames
|
||||
assert "file3.txt" in filenames
|
||||
assert "file4.txt" in filenames
|
||||
|
||||
# file1.txt should be found first (breadth-first)
|
||||
assert filenames[0] == "file1.txt"
|
||||
|
||||
def test_find_solution_or_project_file_with_solution(self):
|
||||
"""Test that find_solution_or_project_file prefers .sln files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create both .sln and .csproj files
|
||||
solution_file = temp_path / "MySolution.sln"
|
||||
project_file = temp_path / "MyProject.csproj"
|
||||
solution_file.touch()
|
||||
project_file.touch()
|
||||
|
||||
result = find_solution_or_project_file(str(temp_path))
|
||||
|
||||
# Should prefer .sln file
|
||||
assert result == str(solution_file)
|
||||
|
||||
def test_find_solution_or_project_file_with_project_only(self):
|
||||
"""Test that find_solution_or_project_file falls back to .csproj files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create only .csproj file
|
||||
project_file = temp_path / "MyProject.csproj"
|
||||
project_file.touch()
|
||||
|
||||
result = find_solution_or_project_file(str(temp_path))
|
||||
|
||||
# Should return .csproj file
|
||||
assert result == str(project_file)
|
||||
|
||||
def test_find_solution_or_project_file_with_nested_files(self):
|
||||
"""Test that find_solution_or_project_file finds files in subdirectories."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create nested structure
|
||||
(temp_path / "src").mkdir()
|
||||
solution_file = temp_path / "src" / "MySolution.sln"
|
||||
solution_file.touch()
|
||||
|
||||
result = find_solution_or_project_file(str(temp_path))
|
||||
|
||||
# Should find nested .sln file
|
||||
assert result == str(solution_file)
|
||||
|
||||
def test_find_solution_or_project_file_returns_none_when_no_files(self):
|
||||
"""Test that find_solution_or_project_file returns None when no .sln or .csproj files exist."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create some other files
|
||||
(temp_path / "readme.txt").touch()
|
||||
(temp_path / "other.cs").touch()
|
||||
|
||||
result = find_solution_or_project_file(str(temp_path))
|
||||
|
||||
# Should return None
|
||||
assert result is None
|
||||
|
||||
def test_find_solution_or_project_file_prefers_solution_breadth_first(self):
|
||||
"""Test that solution files are preferred even when deeper in the tree."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create .csproj at root and .sln in subdirectory
|
||||
project_file = temp_path / "MyProject.csproj"
|
||||
project_file.touch()
|
||||
|
||||
(temp_path / "src").mkdir()
|
||||
solution_file = temp_path / "src" / "MySolution.sln"
|
||||
solution_file.touch()
|
||||
|
||||
result = find_solution_or_project_file(str(temp_path))
|
||||
|
||||
# Should still prefer .sln file even though it's deeper
|
||||
assert result == str(solution_file)
|
||||
|
||||
@patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._ensure_server_installed")
|
||||
@patch("solidlsp.language_servers.csharp_language_server.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."""
|
||||
with tempfile.TemporaryDirectory() as cache_dir:
|
||||
# Mock the server installation
|
||||
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll", Path(cache_dir))
|
||||
|
||||
# Create test directory with solution file
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
solution_file = temp_path / "TestSolution.sln"
|
||||
solution_file.touch()
|
||||
|
||||
# Mock logger to capture log messages
|
||||
mock_logger = Mock()
|
||||
mock_config = Mock(spec=LanguageServerConfig)
|
||||
mock_config.ignored_paths = []
|
||||
|
||||
# Create CSharpLanguageServer instance
|
||||
CSharpLanguageServer(mock_config, mock_logger, str(temp_path))
|
||||
|
||||
# Verify that logger was called with solution file discovery
|
||||
mock_logger.log.assert_any_call(f"Found solution/project file: {solution_file}", 20) # logging.INFO
|
||||
|
||||
@patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._ensure_server_installed")
|
||||
@patch("solidlsp.language_servers.csharp_language_server.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."""
|
||||
with tempfile.TemporaryDirectory() as cache_dir:
|
||||
# Mock the server installation
|
||||
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll", Path(cache_dir))
|
||||
|
||||
# Create empty test directory
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Mock logger to capture log messages
|
||||
mock_logger = Mock()
|
||||
mock_config = Mock(spec=LanguageServerConfig)
|
||||
mock_config.ignored_paths = []
|
||||
|
||||
# Create CSharpLanguageServer instance
|
||||
CSharpLanguageServer(mock_config, mock_logger, str(temp_path))
|
||||
|
||||
# Verify that logger was called with warning about no solution/project files
|
||||
mock_logger.log.assert_any_call(
|
||||
"No .sln or .csproj file found, language server will attempt auto-discovery", 30 # logging.WARNING
|
||||
)
|
||||
|
||||
def test_solution_and_project_opening_with_real_test_repo(self):
|
||||
"""Test solution and project opening with the actual C# test repository."""
|
||||
# Get the C# test repo path
|
||||
test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "csharp" / "test_repo"
|
||||
|
||||
if not test_repo_path.exists():
|
||||
pytest.skip("C# test repository not found")
|
||||
|
||||
# Test solution/project discovery in the real test repo
|
||||
result = find_solution_or_project_file(str(test_repo_path))
|
||||
|
||||
# Should find either .sln or .csproj file
|
||||
assert result is not None
|
||||
assert result.endswith((".sln", ".csproj"))
|
||||
|
||||
# Verify the file actually exists
|
||||
assert os.path.exists(result)
|
||||
Reference in new issue
Block a user