Merge pull request #239 from thomhurst/feature/csharp-language-server

Microsoft.CodeAnalysis.LanguageServer for C#
This commit is contained in:
Michael Panchenko authored and GitHub committed 2025-06-29 17:37:39 +02:00
commit 1d7eddb43b
20 files changed
+1108 -29

No files matched your search

+3
View File
@@ -218,3 +218,6 @@ tmp/
.serena/
.vscode/
# Claude settings
.claude/settings.local.json
+1
View File
@@ -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
+3 -3
View File
@@ -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)
Executable → Regular
View File
File mode changed.
+3
View File
@@ -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",
]
-14
View File
@@ -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
@@ -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"""<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="{package_name}" Version="{package_version}" />
</ItemGroup>
<PropertyGroup>
<RestoreAdditionalProjectSources>
{';'.join(NUGET_SOURCES)}
</RestoreAdditionalProjectSources>
</PropertyGroup>
</Project>"""
project_file = temp_path / "temp.csproj"
project_file.write_text(project_content)
try:
# Use dotnet restore with no dependencies
subprocess.run(
[
dotnet_cmd,
"restore",
str(project_file),
"--packages",
str(temp_path),
"--no-dependencies",
"--ignore-failed-sources",
],
check=True,
capture_output=True,
text=True,
)
package_path = temp_path / package_name.lower() / package_version
except subprocess.CalledProcessError as e:
logger.log(f"Dotnet restore stdout: {e.stdout}", logging.ERROR)
logger.log(f"Dotnet restore stderr: {e.stderr}", logging.ERROR)
raise LanguageServerException(f"Failed to download package: stdout={e.stdout}, stderr={e.stderr}")
elif package_path is None and nuget_cmd:
# Use nuget to download the package
try:
subprocess.run(
[
nuget_cmd,
"install",
package_name,
"-Version",
package_version,
"-OutputDirectory",
str(temp_path),
"-NonInteractive",
],
check=True,
capture_output=True,
text=True,
)
# Find the downloaded package
package_path = temp_path / f"{package_name}.{package_version}"
except subprocess.CalledProcessError as e:
raise LanguageServerException(f"Failed to download package: {e.stderr}")
if package_path is None or not package_path.exists():
raise LanguageServerException("Failed to download Microsoft.CodeAnalysis.LanguageServer package")
# Extract the language server files
extract_path = lang_server_dep.extract_path or "lib/net9.0"
source_dir = package_path / extract_path
if not source_dir.exists():
# Try alternative locations
for possible_dir in [
package_path / "tools" / "net9.0" / "any",
package_path / "lib" / "net9.0",
package_path / "contentFiles" / "any" / "net9.0",
]:
if possible_dir.exists():
source_dir = possible_dir
break
else:
raise LanguageServerException(f"Could not find language server files in package. Searched in {package_path}")
# Copy files to cache directory
server_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, server_dir, dirs_exist_ok=True)
if not server_dll.exists():
raise LanguageServerException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
# Make the DLL executable on Unix-like systems
if system != "windows":
server_dll.chmod(server_dll.stat().st_mode | stat.S_IEXEC)
logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO)
return dotnet_path, str(server_dll), cache_dir
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,
)
+43 -6
View File
@@ -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()
+8 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+38
View File
@@ -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
@@ -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/
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+12 -1
View File
@@ -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"],
)
+79
View File
@@ -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
Generated
+24
View File
@@ -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" },