diff --git a/.gitignore b/.gitignore index 03279f6f..fabbe519 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,6 @@ tmp/ .serena/ .vscode/ + +# Claude settings +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5d4b25..7360edee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Status of the `main` branch. Changes prior to the next official version change w * allows the language server to be initialized in the background, making the MCP server respond to requests immediately upon startup, * ensures that all tool executions are fully synchronized (executed linearly). +* Better support for C# by switching from `omnisharp` to Microsoft's official C# language server. Fixes: * Fix `ExecuteShellCommandTool` and `GetCurrentConfigTool` hanging on Windows diff --git a/README.md b/README.md index 8e315b21..ea5cd34f 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,16 @@ than existing solutions that charge a premium. Language servers provide support for a wide range of programming languages. With Serena, we provide * direct, out-of-the-box support for: - * Python + * Python * TypeScript/Javascript * PHP * Go (need to install go and gopls first) * Rust * C/C++ - * Java (_Note_: startup is slow, initial startup especially so. There seem to be issues with java on macos) + * C# (requires dotnet to be installed. We switched the underlying language server recently, please report any issues you encounter) + * Java (_Note_: startup is slow, initial startup especially so. There may be issues with java on macos and linux, we are working on it.) * indirect support (may require some code changes/manual installation) for: * Ruby (untested) - * C# (untested) * Kotlin (untested) * Dart (untested) diff --git a/docker_build_and_run.sh b/docker_build_and_run.sh old mode 100755 new mode 100644 diff --git a/pyproject.toml b/pyproject.toml index bd33a237..0e244680 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,7 @@ select = [ "YTT", ] ignore = [ + "PLC0415", "RUF002", "RUF005", "SIM118", @@ -235,6 +236,7 @@ max-complexity = 20 "scripts/**" = ["D103"] [tool.pytest.ini_options] +addopts = "--snapshot-patch-pycharm-diff" markers = [ "clojure: language server running for Clojure", "python: language server running for Python", @@ -243,6 +245,7 @@ markers = [ "rust: language server running for Rust", "typescript: language server running for TypeScript", "php: language server running for PHP", + "csharp: language server running for C#", "snapshot: snapshot tests for symbolic editing operations", "isolated_process: test runs with process isolated agent", ] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index a9d2f112..00000000 --- a/pytest.ini +++ /dev/null @@ -1,14 +0,0 @@ -[pytest] -addopts = --snapshot-patch-pycharm-diff -# this shouldn't be necessary since it's already in pyproject.toml -# but tests show lots of warnings without it -markers = - clojure: language server running for Clojure - python: language server running for Python - go: language server running for Go - java: language server running for Java - rust: language server running for Rust - typescript: language server running for TypeScript - php: language server running for PHP - snapshot: snapshot tests for symbolic editing operations - isolated_process: test runs with process isolated agent \ No newline at end of file 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 new file mode 100644 index 00000000..aeec0a18 --- /dev/null +++ b/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py @@ -0,0 +1,772 @@ +""" +CSharp Language Server using Microsoft.CodeAnalysis.LanguageServer (Official Roslyn-based LSP server) +""" + +import logging +import os +import platform +import shutil +import stat +import subprocess +import tarfile +import tempfile +import threading +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +@dataclass +class RuntimeDependency: + """Represents a runtime dependency for the C# language server.""" + + id: str + description: str + platform_id: str + archive_type: str + binary_name: str + package_name: str | None = None + package_version: str | None = None + extract_path: str | None = None + url: str | None = None + + +# Runtime dependencies configuration +RUNTIME_DEPENDENCIES = [ + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for Windows (x64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.win-x64", + package_version="5.0.0-1.25277.114", + platform_id="win-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/win-x64", + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for Windows (ARM64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.win-arm64", + package_version="5.0.0-1.25277.114", + platform_id="win-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/win-arm64", + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for macOS (x64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.osx-x64", + package_version="5.0.0-1.25277.114", + platform_id="osx-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/osx-x64", + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for macOS (ARM64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.osx-arm64", + package_version="5.0.0-1.25277.114", + platform_id="osx-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/osx-arm64", + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for Linux (x64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.linux-x64", + package_version="5.0.0-1.25277.114", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/linux-x64", + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Microsoft.CodeAnalysis.LanguageServer for Linux (ARM64)", + package_name="Microsoft.CodeAnalysis.LanguageServer.linux-arm64", + package_version="5.0.0-1.25277.114", + platform_id="linux-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/linux-arm64", + ), + RuntimeDependency( + id="DotNetRuntime", + description=".NET 9 Runtime for Windows (x64)", + url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-x64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="dotnet.exe", + ), + RuntimeDependency( + id="DotNetRuntime", + description=".NET 9 Runtime for Linux (x64)", + url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-x64.tar.gz", + platform_id="linux-x64", + archive_type="tar.gz", + binary_name="dotnet", + ), + RuntimeDependency( + id="DotNetRuntime", + description=".NET 9 Runtime for macOS (x64)", + url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-x64.tar.gz", + platform_id="osx-x64", + archive_type="tar.gz", + binary_name="dotnet", + ), + RuntimeDependency( + id="DotNetRuntime", + description=".NET 9 Runtime for macOS (ARM64)", + url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-arm64.tar.gz", + platform_id="osx-arm64", + archive_type="tar.gz", + binary_name="dotnet", + ), +] + +# 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): + """ + Perform a breadth-first scan of files in the given directory. + Yields file paths in breadth-first order. + """ + queue = [root_dir] + while queue: + current_dir = queue.pop(0) + try: + for item in os.listdir(current_dir): + if item.startswith("."): + continue + item_path = os.path.join(current_dir, item) + if os.path.isdir(item_path): + queue.append(item_path) + elif os.path.isfile(item_path): + yield item_path + except (PermissionError, OSError): + # Skip directories we can't access + pass + + +def find_solution_or_project_file(root_dir) -> str | None: + """ + Find the first .sln file in breadth-first order. + If no .sln file is found, look for a .csproj file. + """ + sln_file = None + csproj_file = None + + for filename in breadth_first_file_scan(root_dir): + if filename.endswith(".sln") and sln_file is None: + sln_file = filename + elif filename.endswith(".csproj") and csproj_file is None: + csproj_file = filename + + # If we found a .sln file, return it immediately + if sln_file: + return sln_file + + # If no .sln file was found, return the first .csproj file + return csproj_file + + +class CSharpLanguageServer(SolidLanguageServer): + """ + Provides C# specific instantiation of the LanguageServer class using Microsoft.CodeAnalysis.LanguageServer. + This is the official Roslyn-based language server from Microsoft. + """ + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + 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) + + # 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)]) + + # The language server will discover the solution/project from the workspace root + if solution_or_project: + logger.log(f"Found solution/project file: {solution_or_project}", logging.INFO) + 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) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + "csharp", + ) + + self.initialization_complete = threading.Event() + + @override + 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]: + """ + Set up .NET 9 runtime and Microsoft.CodeAnalysis.LanguageServer using runtime dependencies. + 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}") + + # 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 + lang_server_dep = None + dotnet_runtime_dep = None + + for dep in RUNTIME_DEPENDENCIES: + if dep.id == "CSharpLanguageServer" and dep.platform_id == runtime_id: + lang_server_dep = dep + elif dep.id == "DotNetRuntime" and dep.platform_id == runtime_id: + dotnet_runtime_dep = dep + + if not lang_server_dep: + raise LanguageServerException(f"No C# language server dependency found for platform {runtime_id}") + 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) + + # Then set up the language server + package_name = lang_server_dep.package_name + package_version = lang_server_dep.package_version + + server_dir = cache_dir / f"{package_name}.{package_version}" + server_dll = server_dir / lang_server_dep.binary_name + + 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 + + # Download the language server package + logger.log(f"Downloading {package_name} version {package_version}...", logging.INFO) + + # Check if nuget or dotnet is available + nuget_cmd = shutil.which("nuget") + dotnet_cmd = shutil.which("dotnet") + + if not nuget_cmd and not dotnet_cmd: + 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 + + def _ensure_dotnet_runtime_from_config(self, logger: LanguageServerLogger, cache_dir: Path, runtime_dep: RuntimeDependency) -> str: + """ + Ensure .NET 9 runtime is available using runtime dependency configuration. + Returns the path to the dotnet executable. + """ + # Check if dotnet is already available in 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: + logger.log("Found system .NET 9 runtime", logging.INFO) + return system_dotnet + except subprocess.CalledProcessError: + pass + + # Download .NET 9 runtime using config + dotnet_dir = cache_dir / "dotnet-runtime-9.0" + 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) + + # Download .NET runtime + logger.log("Downloading .NET 9 runtime...", logging.INFO) + dotnet_dir.mkdir(parents=True, exist_ok=True) + + url = runtime_dep.url + archive_type = runtime_dep.archive_type + + # Download the runtime + download_path = dotnet_dir / f"dotnet-runtime.{archive_type}" + try: + logger.log(f"Downloading from {url}", logging.DEBUG) + urllib.request.urlretrieve(url, download_path) + + # 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) + + # Remove the archive + download_path.unlink() + + # Make dotnet executable on Unix + if platform.system().lower() != "windows": + dotnet_exe.chmod(dotnet_exe.stat().st_mode | stat.S_IEXEC) + + logger.log(f"Successfully installed .NET 9 runtime to {dotnet_exe}", logging.INFO) + return str(dotnet_exe) + + except Exception as e: + raise LanguageServerException(f"Failed to download .NET 9 runtime from {url}: {e}") from e + + def _get_initialize_params(self) -> InitializeParams: + """ + Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. + """ + root_uri = PathUtils.path_to_uri(self.repository_root_path) + root_name = os.path.basename(self.repository_root_path) + return cast( + InitializeParams, + { + "workspaceFolders": [{"uri": root_uri, "name": root_name}], + "processId": os.getpid(), + "rootPath": self.repository_root_path, + "rootUri": root_uri, + "capabilities": { + "window": { + "workDoneProgress": True, + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + }, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "workDoneProgress": True, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + "foldingRange": {"dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True}, + }, + }, + }, + ) + + def _start_server(self): + def do_nothing(params): + return + + def window_log_message(msg): + """Log messages from the language server.""" + message_text = msg.get("message", "") + level = msg.get("type", 4) # Default to Log level + + # Map LSP message types to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log + + self.logger.log(f"LSP: {message_text}", level_map.get(level, logging.DEBUG)) + + # Also print important messages to console + if level <= 3: # Error, Warning, or Info + print(f"C# Language Server: {message_text}") + + def handle_progress(params): + """Handle progress notifications from the language server.""" + token = params.get("token", "") + value = params.get("value", {}) + + # Log raw progress for debugging + self.logger.log(f"Progress notification received: {params}", logging.DEBUG) + + # Handle different progress notification types + kind = value.get("kind") + + if kind == "begin": + title = value.get("title", "Operation in progress") + message = value.get("message", "") + percentage = value.get("percentage") + + if percentage is not None: + self.logger.log(f"Progress [{token}]: {title} - {message} ({percentage}%)", logging.INFO) + print(f"C# Language Server: {title} - {message} ({percentage}%)") + else: + self.logger.log(f"Progress [{token}]: {title} - {message}", logging.INFO) + print(f"C# Language Server: {title} - {message}") + + elif kind == "report": + message = value.get("message", "") + percentage = value.get("percentage") + + if percentage is not None: + self.logger.log(f"Progress [{token}]: {message} ({percentage}%)", logging.INFO) + if percentage % 10 == 0: # Print every 10% + print(f"C# Language Server: {message} ({percentage}%)") + elif message: + self.logger.log(f"Progress [{token}]: {message}", logging.INFO) + + elif kind == "end": + message = value.get("message", "Operation completed") + self.logger.log(f"Progress [{token}]: {message}", logging.INFO) + print(f"C# Language Server: {message}") + + def handle_workspace_configuration(params): + """Handle workspace/configuration requests from the server.""" + items = params.get("items", []) + result = [] + + for item in items: + section = item.get("section", "") + + # Provide default values based on the configuration section + if section.startswith(("dotnet", "csharp")): + # Default configuration for C# settings + if "enable" in section or "show" in section or "suppress" in section or "navigate" in section: + # Boolean settings + result.append(False) + elif "scope" in section: + # Scope settings - use appropriate enum values + if "analyzer_diagnostics_scope" in section: + result.append("openFiles") # BackgroundAnalysisScope + elif "compiler_diagnostics_scope" in section: + result.append("openFiles") # CompilerDiagnosticsScope + else: + result.append("openFiles") + elif section == "dotnet_member_insertion_location": + # ImplementTypeInsertionBehavior enum + result.append("with_other_members_of_the_same_kind") + elif section == "dotnet_property_generation_behavior": + # ImplementTypePropertyGenerationBehavior enum + result.append("prefer_throwing_properties") + elif "location" in section or "behavior" in section: + # Other enum settings - return null to avoid parsing errors + result.append(None) + else: + # Default for other dotnet/csharp settings + result.append(None) + elif section == "tab_width" or section == "indent_size": + # Tab and indent settings + result.append(4) + elif section == "insert_final_newline": + # Editor settings + result.append(True) + else: + # Unknown configuration - return null + result.append(None) + + return result + + def handle_work_done_progress_create(params): + """Handle work done progress create requests.""" + # Just acknowledge the request + return + + def handle_register_capability(params): + """Handle client/registerCapability requests.""" + # Just acknowledge the request - we don't need to track these for now + return + + # Set up notification handlers + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", handle_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request("workspace/configuration", handle_workspace_configuration) + self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) + self.server.on_request("client/registerCapability", handle_register_capability) + + self.logger.log("Starting Microsoft.CodeAnalysis.LanguageServer process", logging.INFO) + + try: + self.server.start() + except Exception as e: + self.logger.log(f"Failed to start language server process: {e}", logging.ERROR) + raise LanguageServerException(f"Failed to start C# language server: {e}") + + # Send initialization + initialize_params = self._get_initialize_params() + + self.logger.log("Sending initialize request to language server", logging.INFO) + try: + init_response = self.server.send.initialize(initialize_params) + self.logger.log(f"Received initialize response: {init_response}", logging.DEBUG) + except Exception as e: + raise LanguageServerException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e + + # Verify required capabilities + capabilities = init_response.get("capabilities", {}) + required_capabilities = [ + "textDocumentSync", + "definitionProvider", + "referencesProvider", + "documentSymbolProvider", + ] + missing = [cap for cap in required_capabilities if cap not in capabilities] + if missing: + raise RuntimeError( + f"Language server is missing required capabilities: {', '.join(missing)}. " + "Initialization failed. Please ensure the correct version of Microsoft.CodeAnalysis.LanguageServer is installed and the .NET runtime is working." + ) + + # Complete initialization + self.server.notify.initialized({}) + self.initialization_complete.set() + self.completions_available.set() + + self.logger.log( + "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" + "Waiting for language server to index project files...\n" + "This may take a while for large projects", + logging.INFO, + ) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index e80ef0e1..aa3879bc 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -6,6 +6,7 @@ import os import pathlib import pickle import re +import subprocess import threading from abc import ABC, abstractmethod from collections import defaultdict @@ -135,9 +136,14 @@ class SolidLanguageServer(ABC): ls = RustAnalyzer(config, logger, repository_root_path) elif config.code_language == Language.CSHARP: - from solidlsp.language_servers.omnisharp.omnisharp import OmniSharp + # You can also switch to OmniSharp if you prefer that + # But OmniSharp seems to crash and has some issues with newer C# versions + # from solidlsp.language_servers.omnisharp.omnisharp import OmniSharp + # ls = OmniSharp(config, logger, repository_root_path) - ls = OmniSharp(config, logger, repository_root_path) + from solidlsp.language_servers.csharp_language_server.csharp_language_server import CSharpLanguageServer + + ls = CSharpLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.TYPESCRIPT: from solidlsp.language_servers.typescript_language_server.typescript_language_server import ( @@ -343,15 +349,46 @@ class SolidLanguageServer(ABC): # Stage 1: Graceful Termination Request # Send LSP shutdown and close stdin to signal no more input. try: - self.server.shutdown() + self.logger.log("Sending LSP shutdown request...", logging.DEBUG) + # Use a thread to timeout the LSP shutdown call since it can hang + shutdown_thread = threading.Thread(target=self.server.shutdown) + shutdown_thread.daemon = True + shutdown_thread.start() + shutdown_thread.join(timeout=2.0) # 2 second timeout for LSP shutdown + + if shutdown_thread.is_alive(): + self.logger.log("LSP shutdown request timed out, proceeding to terminate...", logging.DEBUG) + else: + self.logger.log("LSP shutdown request completed.", logging.DEBUG) + if process.stdin and not process.stdin.is_closing(): process.stdin.close() - except Exception: - pass # Ignore errors here, we are proceeding to terminate anyway. + self.logger.log("Stage 1 shutdown complete.", logging.DEBUG) + except Exception as e: + self.logger.log(f"Exception during graceful shutdown: {e}", logging.DEBUG) + # Ignore errors here, we are proceeding to terminate anyway. - # Stage 2: Terminate and Concurrently Drain stdout/stderr + # Stage 2: Terminate and Wait for Process to Exit + self.logger.log(f"Terminating process {process.pid}, current status: {process.poll()}", logging.DEBUG) process.terminate() + # Stage 3: Wait for process termination with timeout + try: + self.logger.log(f"Waiting for process {process.pid} to terminate...", logging.DEBUG) + exit_code = process.wait(timeout=timeout) + self.logger.log(f"Language server process terminated successfully with exit code {exit_code}.", logging.INFO) + except subprocess.TimeoutExpired: + # If termination failed, forcefully kill the process + self.logger.log(f"Process {process.pid} termination timed out, killing process forcefully...", logging.WARNING) + process.kill() + try: + exit_code = process.wait(timeout=2.0) + self.logger.log(f"Language server process killed successfully with exit code {exit_code}.", logging.INFO) + except subprocess.TimeoutExpired: + self.logger.log(f"Process {process.pid} could not be killed within timeout.", logging.ERROR) + except Exception as e: + self.logger.log(f"Error during process shutdown: {e}", logging.ERROR) + @contextmanager def start_server(self) -> Iterator["SolidLanguageServer"]: self.start() diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index 65a59311..731952f3 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import platform import subprocess import threading import time @@ -147,9 +148,15 @@ class SolidLanguageServerHandler: child_proc_env = os.environ.copy() child_proc_env.update(self.process_launch_info.env) + cmd = self.process_launch_info.cmd + is_windows = platform.system() == "Windows" + if not isinstance(cmd, str) and not is_windows: + # Since we are using the shell, we need to convert the command list to a single string + # on Linux/macOS + cmd = " ".join(cmd) log.info("Starting language server process via command: %s", self.process_launch_info.cmd) self.process = subprocess.Popen( - self.process_launch_info.cmd, + cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/src/solidlsp/ls_logger.py b/src/solidlsp/ls_logger.py index 6bdf12cb..7aa0e0a6 100644 --- a/src/solidlsp/ls_logger.py +++ b/src/solidlsp/ls_logger.py @@ -28,7 +28,7 @@ class LanguageServerLogger: """ def __init__(self, json_format: bool = False, log_level: int = logging.INFO) -> None: - self.logger = logging.getLogger("multilspy") + self.logger = logging.getLogger("solidlsp") self.logger.setLevel(log_level) self.json_format = json_format diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 7aa610ed..57875c04 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -131,7 +131,8 @@ class PathUtils: from urlparse import urlparse parsed = urlparse(uri) host = f"{os.path.sep}{os.path.sep}{parsed.netloc}{os.path.sep}" - return os.path.normpath(os.path.join(host, url2pathname(unquote(parsed.path)))) + path = os.path.normpath(os.path.join(host, url2pathname(unquote(parsed.path)))) + return path @staticmethod def path_to_uri(path: str) -> str: @@ -152,7 +153,8 @@ class PathUtils: returns `None` otherwise. """ if PurePath(path).drive == PurePath(base_path).drive: - return str(PurePath(os.path.relpath(path, base_path))) + rel_path = str(PurePath(os.path.relpath(path, base_path))) + return rel_path return None diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 5045312c..0cb59490 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -50,7 +50,7 @@ class ProcessLaunchInfo: """ # The command to launch the process - cmd: str + cmd: str | list[str] # The environment variables to set for the process env: dict[str, str] = dataclasses.field(default_factory=dict) diff --git a/test/resources/repos/csharp/serena.sln b/test/resources/repos/csharp/serena.sln new file mode 100644 index 00000000..30e1b535 --- /dev/null +++ b/test/resources/repos/csharp/serena.sln @@ -0,0 +1,38 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EF7103B4-4C75-1E6D-A485-A154A88D107A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repos", "repos", "{E2326EEF-E677-6A44-0935-7677816F09E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{C21E6CE7-177A-86D9-040F-A317F18B6DBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "test\resources\repos\csharp\test_repo\TestProject.csproj", "{A4D04E18-760A-73F9-3303-0542F6298C84}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EF7103B4-4C75-1E6D-A485-A154A88D107A} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {E2326EEF-E677-6A44-0935-7677816F09E7} = {EF7103B4-4C75-1E6D-A485-A154A88D107A} + {C21E6CE7-177A-86D9-040F-A317F18B6DBF} = {E2326EEF-E677-6A44-0935-7677816F09E7} + {A4D04E18-760A-73F9-3303-0542F6298C84} = {C21E6CE7-177A-86D9-040F-A317F18B6DBF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BDCA748E-D888-4BAF-BF24-DAC683113BFC} + EndGlobalSection +EndGlobal diff --git a/test/resources/repos/csharp/test_repo/.gitignore b/test/resources/repos/csharp/test_repo/.gitignore new file mode 100644 index 00000000..c4943979 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/.gitignore @@ -0,0 +1,37 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio temporary files +.vs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Files built by Visual Studio +*.user +*.userosscache +*.sln.docstates + +# Build results +*.dll +*.exe +*.pdb + +# NuGet +*.nupkg +*.snupkg +packages/ \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/Models/Person.cs b/test/resources/repos/csharp/test_repo/Models/Person.cs new file mode 100644 index 00000000..c771195c --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Models/Person.cs @@ -0,0 +1,26 @@ +namespace TestProject.Models +{ + public class Person + { + public string Name { get; set; } + public int Age { get; set; } + public string Email { get; set; } + + public Person(string name, int age, string email) + { + Name = name; + Age = age; + Email = email; + } + + public override string ToString() + { + return $"{Name} ({Age}) - {Email}"; + } + + public bool IsAdult() + { + return Age >= 18; + } + } +} \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs new file mode 100644 index 00000000..55c8d228 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Program.cs @@ -0,0 +1,43 @@ +using System; + +namespace TestProject +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello, World!"); + + var calculator = new Calculator(); + int result = calculator.Add(5, 3); + Console.WriteLine($"5 + 3 = {result}"); + } + } + + public class Calculator + { + public int Add(int a, int b) + { + return a + b; + } + + public int Subtract(int a, int b) + { + return a - b; + } + + public int Multiply(int a, int b) + { + return a * b; + } + + public double Divide(int a, int b) + { + if (b == 0) + { + throw new DivideByZeroException("Cannot divide by zero"); + } + return (double)a / b; + } + } +} \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/TestProject.csproj b/test/resources/repos/csharp/test_repo/TestProject.csproj new file mode 100644 index 00000000..c28018db --- /dev/null +++ b/test/resources/repos/csharp/test_repo/TestProject.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 2db5537b..bae716dc 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -32,7 +32,16 @@ def serena_config(): """Create an in-memory configuration for tests with test repositories pre-registered.""" # Create test projects for all supported languages test_projects = [] - for language in [Language.PYTHON, Language.GO, Language.JAVA, Language.RUST, Language.TYPESCRIPT, Language.PHP, Language.CLOJURE]: + for language in [ + Language.PYTHON, + Language.GO, + Language.JAVA, + Language.RUST, + Language.TYPESCRIPT, + Language.PHP, + Language.CSHARP, + Language.CLOJURE, + ]: repo_path = get_repo_path(language) if repo_path.exists(): project_name = f"test_repo_{language}" @@ -102,6 +111,7 @@ class TestSerenaAgent: clj.CORE_PATH, marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], ), + pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), ], indirect=["serena_agent"], ) @@ -147,6 +157,7 @@ class TestSerenaAgent: clj.UTILS_PATH, marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], ), + pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), ], indirect=["serena_agent"], ) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py new file mode 100644 index 00000000..63df56e7 --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -0,0 +1,79 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.csharp +class TestCSharpLanguageServer: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Add"), "Add method not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from a C# file.""" + file_path = os.path.join("Program.cs") + symbols = language_server.request_document_symbols(file_path) + + # Check that we have symbols + assert len(symbols) > 0 + + # Flatten the symbols if they're nested + if isinstance(symbols[0], list): + symbols = symbols[0] + + # Look for expected classes + class_names = [s.get("name") for s in symbols if s.get("kind") == 5] # 5 is class + assert "Program" in class_names + assert "Calculator" in class_names + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding references using symbol selection range.""" + file_path = os.path.join("Program.cs") + symbols = language_server.request_document_symbols(file_path) + add_symbol = None + # Handle nested symbol structure + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + for sym in symbol_list: + if sym.get("name") == "Add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any( + "Program.cs" in ref.get("relativePath", "") for ref in refs + ), "Program.cs should reference Add method (tried all positions in selectionRange)" + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_nested_namespace_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting symbols from nested namespace.""" + file_path = os.path.join("Models", "Person.cs") + symbols = language_server.request_document_symbols(file_path) + + # Check that we have symbols + assert len(symbols) > 0 + + # Flatten the symbols if they're nested + if isinstance(symbols[0], list): + symbols = symbols[0] + + # Check that we have the Person class + assert any(s.get("name") == "Person" and s.get("kind") == 5 for s in symbols) + + # Check for properties and methods + symbol_names = [s.get("name") for s in symbols] + assert "Name" in symbol_names + assert "Age" in symbol_names + assert "Email" in symbol_names + assert "ToString" in symbol_names + assert "IsAdult" in symbol_names diff --git a/uv.lock b/uv.lock index c7318117..d70c1b72 100644 --- a/uv.lock +++ b/uv.lock @@ -209,6 +209,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + [[package]] name = "executing" version = "2.2.0" @@ -832,6 +841,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797 }, ] +[[package]] +name = "pytest-xdist" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/865845cfe987b21658e871d16e0a24e871e00884c545f246dd8f6f69edda/pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126", size = 87550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/b2/0e802fde6f1c5b2f7ae7e9ad42b83fd4ecebac18a8a8c2f2f14e39dce6e1/pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0", size = 46142 }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -1012,6 +1034,7 @@ dev = [ { name = "mypy" }, { name = "poethepoet" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "syrupy" }, { name = "toml-sort" }, @@ -1042,6 +1065,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyright", specifier = ">=1.1.396,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.2" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.32.3,<3" },