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" },