diff --git a/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py index aeec0a18..109fff91 100644 --- a/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py @@ -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""" - - net9.0 - - - - - - - {';'.join(NUGET_SOURCES)} - - -""" - - 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) diff --git a/src/solidlsp/language_servers/solargraph/solargraph.py b/src/solidlsp/language_servers/solargraph/solargraph.py index 2eea3ec7..171cc888 100644 --- a/src/solidlsp/language_servers/solargraph/solargraph.py +++ b/src/solidlsp/language_servers/solargraph/solargraph.py @@ -10,6 +10,7 @@ import pathlib import stat import subprocess import threading + from overrides import override from solidlsp.ls import SolidLanguageServer diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 63df56e7..56db5cfa 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -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)