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:
Tom Longhurst authored and GitHub committed 2025-06-30 22:39:44 +02:00
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
+187 -1
View File
@@ -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)