Merge branch 'feature/support-phpactor' of ssh://github.com/takeokunn/serena into feature/support-phpactor

# Conflicts:
#	test/conftest.py
This commit is contained in:
takeokunn committed 2026-02-08 01:08:26 +09:00
commit e0c2a4ff93
69 files changed
+1685 -552

No files matched your search

+24 -3
View File
@@ -58,16 +58,19 @@ jobs:
uses: actions/cache@v3
with:
path: .venv
key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-lock-${{ hashFiles('uv.lock') }}
- name: Create virtual environment
shell: bash
run: |
if [ ! -d ".venv" ]; then
uv venv
fi
- name: Install dependencies
- name: Install Python environment
shell: bash
run: uv pip install -e ".[dev]"
run: uv sync --extra dev --locked
- name: List Python dependencies
shell: bash
run: uv pip list
- name: Check formatting
shell: bash
run: uv run poe lint
@@ -110,6 +113,24 @@ jobs:
uses: DeLaGuardo/setup-clojure@13.4
with:
cli: latest
- name: Install ccls (C/C++ Language Server)
shell: bash
run: |
if [[ "${{ runner.os }}" == "Linux" ]]; then
sudo apt-get update
sudo apt-get install -y ccls
elif [[ "${{ runner.os }}" == "macOS" ]]; then
brew install ccls
elif [[ "${{ runner.os }}" == "Windows" ]]; then
choco install ccls -y
fi
# Verify installation
if command -v ccls &> /dev/null; then
echo "ccls installed: $(ccls --version 2>&1 | head -1)"
else
echo "ERROR: ccls installation failed"
exit 1
fi
- name: Setup Java (for JVM based languages)
uses: actions/setup-java@v4
with:
+1
View File
@@ -62,6 +62,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files.
* **Add support for MATLAB** via the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. Set `MATLAB_PATH` environment variable or configure `matlab_path` in `ls_specific_settings`. Supports .m, .mlx, and .mlapp files with code completion, diagnostics, go-to-definition, find references, document symbols, formatting, and rename.
* **Add support for Pascal** via the official Pascal Language Server.
* **C/C++ alternate LS (ccls)**: Add experimental, opt-in support for ccls as an alternative backend to clangd. Enable via `cpp_ccls` in project configuration. Requires `ccls` installed and ideally a `compile_commands.json` at repo root.
# 0.1.4
+13 -17
View File
@@ -11,18 +11,11 @@ The following types of contributions can be submitted directly via pull requests
For other changes, please open an issue first to discuss your ideas with the maintainers.
## Developer Environment Setup
### Adding Support for a New Language Server
You can have a local setup via `uv` or a docker interpreter-based setup.
The repository is also configured to seamlessly work within a GitHub Codespace. See the instructions
for the various setup scenarios below.
See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md).
Independently of how the setup was done, the virtual environment can be
created and activated via `uv` (see below), and the various tasks like formatting, testing, and documentation building
can be executed using `poe`. For example, `poe format` will format the code, including the
notebooks. Just run `poe` to see the available commands.
### Python (uv) setup
## Python Environment Setup
You can install a virtual environment with the required as follows
@@ -30,15 +23,18 @@ You can install a virtual environment with the required as follows
2. Activate the environment:
* On Linux/Unix/macOS or Windows with Git Bash: `source .venv/bin/activate`
* On Windows outside of Git Bash: `.venv\Scripts\activate.bat` (in cmd/ps) or `source .venv/Scripts/activate` (in git-bash)
3. Install the required packages with all extras: `uv pip install --all-extras -r pyproject.toml -e .`
3. Install the required packages with all extras: `uv sync --extra dev`
## Running Tools Locally
## Poe Tasks
We use poe to execute development tasks:
- `poe format` - run code auto-formatters
- `poe type-check` - run type checkers
## Testing Tool Executions
The Serena tools (and in fact all Serena code) can be executed without an LLM, and also without
any MCP specifics (though you can use the mcp inspector, if you want).
An example script for running tools is provided in [scripts/demo_run_tools.py](scripts/demo_run_tools.py).
## Adding a New Supported Language
See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md).
An example script for running tools is provided in [scripts/demo_run_tools.py](scripts/demo_run_tools.py).
+3 -1
View File
@@ -31,7 +31,9 @@ Some languages require additional installations or setup steps, as noted.
* **Bash**
* **C#**
* **C/C++**
(you may experience issues with finding references, we are working on it)
Default: clangd. Optional alternate: ccls (experimental, opt-in).
For best results, provide a `compile_commands.json` at the repository root.
See the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.
* **Clojure**
* **Dart**
* **Elixir**
+2 -2
View File
@@ -162,8 +162,8 @@ to get a list of all available options.
Some useful options include:
* `--project <path|name>`: specify the project to work on by name or path.
* `--project-from-cwd`: auto-detect the project from current working directory
(looking for a directory containing `.serena/project.yml` or `.git` in parent directories, activating the current directory if none is found);
* `--project-from-cwd`: auto-detect the project from current working directory
(looking for a directory containing `.serena/project.yml` or `.git` in parent directories and activating the containing directory as the project root, if any).
This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory
and which do not change directories during their operation.
* `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration)
+1 -1
View File
@@ -96,7 +96,7 @@ claude mcp add --scope user serena -- uvx --from git+https://github.com/oraios/s
```
Whenever you start Claude Code, Serena will search up from the current directory for `.serena/project.yml` or `.git` markers,
activating the current directory as the project if neither is found.
activating the containing directory as the project (if any).
This mechanism makes it suitable for a single global MCP configuration.
**Maximum Token Efficiency.** To maximize token efficiency, you may want to use Claude Code's
+50 -1
View File
@@ -173,6 +173,11 @@ You can change this location by setting the `SERENA_HOME` environment variable t
(ls-specific-settings)=
### Language Server-Specific Settings
:::{note}
**Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior.
Most users will not need to adjust these settings.
:::
Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass per-language,
language server-specific configuration.
@@ -204,9 +209,53 @@ ls_specific_settings:
```
This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`.
Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `typescript`.
Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `rust`, `typescript`.
We will add support for more languages over time.
#### C# (Roslyn Language Server)
Serena uses [Microsoft's Roslyn Language Server](https://github.com/dotnet/roslyn) for C# support.
**Runtime Requirements:**
- .NET 10 or higher is required. If not found in PATH, Serena automatically installs it using Microsoft's official install scripts.
- The Roslyn Language Server is automatically downloaded from NuGet.org.
**Supported Platforms:**
Automatic download is supported for: Windows (x64, ARM64), macOS (x64, ARM64), Linux (x64, ARM64).
**Configuration:**
The `runtime_dependencies` setting allows you to override the download URLs for the Roslyn Language Server. This is useful if you need to use a private package mirror or a specific version.
Example configuration to override the language server download URL:
```yaml
ls_specific_settings:
csharp:
runtime_dependencies:
- id: "CSharpLanguageServer"
platform_id: "linux-x64" # or win-x64, win-arm64, osx-x64, osx-arm64, linux-arm64
url: "https://your-mirror.example.com/roslyn-language-server.linux-x64.5.5.0-2.26078.4.nupkg"
package_version: "5.5.0-2.26078.4"
```
Available fields for `runtime_dependencies` entries:
| Field | Description |
| ----------------- | --------------------------------------------------------------------------- |
| `id` | Dependency identifier (use `CSharpLanguageServer`) |
| `platform_id` | Target platform: `win-x64`, `win-arm64`, `osx-x64`, `osx-arm64`, `linux-x64`, `linux-arm64` |
| `url` | Download URL for the NuGet package |
| `package_version` | Package version string |
| `extract_path` | Path within the package to extract (default: `tools/net10.0/<platform>`) |
Notes:
- Only specify the platforms you want to override; others will use the defaults.
- The language server package is a `.nupkg` file (ZIP format) downloaded from NuGet.org by default.
- If you have .NET 10+ already installed, Serena will use your system installation.
#### Go (`gopls`)
Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started.
+100
View File
@@ -0,0 +1,100 @@
# C/C++ Setup Guide
This guide explains how to prepare a C/C++ project so that Serena can provide reliable code intelligence via clangd or ccls language servers.
This is only necessary if you use the language server variant of Serena, for users of the Serena JetBrains plugin no setup is required
and the limitations described below do not apply.
---
## General
Serena supports two C/C++ language servers, clangd (default) and ccls.
Both have their pros and cons and require a properly configured `compile_commands.json`
for cross-file reference finding, see below for details.
Your project must have a `compile_commands.json` file at the repository root.
This file is essential for correct parsing and cross-file reference finding.
## compile_commands.json Requirements
For reliable cross-file reference finding with clangd, your `compile_commands.json` must:
1. **Include proper C++ standard flags** (e.g., `-std=c++17`)
2. **Include all necessary include paths** (`-I` flags)
---
### With clangd
Serena automatically downloads and manages clangd. Since clangd does not properly work with relative paths in `compile_commands.json`,
Serena will detect them and transform them into absolute paths automatically (writing a new `compile_commands.json` file), if needed.
#### Customizing the Compilation Database Location
By default, Serena creates the transformed compilation database at `.serena/compile_commands.json`.
You can customize this location via project settings:
```yaml
# .serena/project.yml
language_servers:
cpp:
compile_commands_dir: custom/rel/path (defaults to .serena)
```
### With ccls
ccls requires manual installation and configuration. It may perform better in some situations.
#### Installation
**Linux:**
```bash
# Ubuntu/Debian (22.04+)
sudo apt-get install ccls
# Fedora/RHEL
sudo dnf install ccls
# Arch Linux
sudo pacman -S ccls
```
**macOS:**
```bash
brew install ccls
```
**Windows:**
```bash
choco install ccls
```
#### Configuration
After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`)
by adding `cpp_ccls` to the `languages` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry.
ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary
and no transformed `compile_commands.json` file will be created.
---
## Known Limitations
### Files Created After Server Initialization
Both clangd and ccls have a fundamental limitation:
**files created by external mechanisms after the language server starts are not automatically indexed**.
Cross-file references to newly created files will not work unless the new file is at some point opened by the language server (for example, by a symbol lookup in it), or until `compile_commands.json` is updated and
the language server is restarted.
---
## Reference
- Clangd official documentation: https://clangd.llvm.org/
- Clangd project setup: https://clangd.llvm.org/installation#project-setup
- CCLS repository: https://github.com/MaskRay/ccls
+1
View File
@@ -313,6 +313,7 @@ markers = [
"yaml: language server running for YAML",
"powershell: language server running for PowerShell",
"pascal: language server running for Pascal (Free Pascal/Lazarus)",
"cpp: language server running for C/C++",
"slow: tests that require additional Expert instances and have long startup times (~60-90s each)",
"toml: language server running for TOML",
"matlab: language server running for MATLAB (requires MATLAB R2021b+)",
+11 -8
View File
@@ -55,15 +55,14 @@ Overriding them means that they no longer apply, so you will need to
re-specify them in addition to further modes if you want to keep them."""
def find_project_root(root: str | Path | None = None) -> str:
def find_project_root(root: str | Path | None = None) -> str | None:
"""Find project root by walking up from CWD.
Checks for .serena/project.yml first (explicit Serena project), then .git (git root).
Falls back to CWD if no marker is found.
:param root: If provided, constrains the search to this directory and below
(acts as a virtual filesystem root). Search stops at this boundary.
:return: absolute path to project root (falls back to CWD if no marker found)
:return: absolute path to project root or None if not suitable root is found
"""
current = Path.cwd().resolve()
boundary = Path(root).resolve() if root is not None else None
@@ -86,8 +85,7 @@ def find_project_root(root: str | Path | None = None) -> str:
if (directory / ".git").exists(): # .git can be file (worktree) or dir
return str(directory)
# Fall back to CWD
return str(current)
return None
# --------------------- Utilities -------------------------------------
@@ -274,7 +272,10 @@ class TopLevelCommands(AutoRegisteringGroup):
if project is not None or project_file_arg is not None:
raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument")
project = find_project_root()
log.info("Auto-detected project root: %s", project)
if project is not None:
log.info("Auto-detected project root: %s", project)
else:
log.warning("No project root found from %s; not activating any project", os.getcwd())
project_file = project_file_arg or project
factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler)
@@ -762,8 +763,10 @@ class ProjectCommands(AutoRegisteringGroup):
try:
# Create SerenaAgent with dashboard disabled
log.info("Creating SerenaAgent with disabled dashboard...")
config = SerenaConfig(gui_log_window=False, web_dashboard=False)
agent = SerenaAgent(project=project_path, serena_config=config)
serena_config = SerenaConfig.from_config_file()
serena_config.gui_log_window = False
serena_config.web_dashboard = False
agent = SerenaAgent(project=project_path, serena_config=serena_config)
log.info("SerenaAgent created successfully")
# Find first non-empty file that can be analyzed
+1 -1
View File
@@ -438,7 +438,7 @@ class RegisteredProject(ToStringMixin):
:param path: the path to check
:return: True if the path matches the project root, False otherwise
"""
return self.project_root == Path(path).resolve()
return self.project_root.samefile(Path(path).resolve())
def get_project_instance(self) -> "Project":
"""
+1
View File
@@ -342,6 +342,7 @@ class SerenaMCPFactory:
self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible)
log.info("MCP server lifetime setup complete")
yield
log.info("MCP server shutting down")
def _get_initial_instructions(self) -> str:
assert self.agent is not None
+52 -23
View File
@@ -1,10 +1,12 @@
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
import pathspec
from sensai.util.logging import LogTime
from sensai.util.string import ToStringMixin
from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir
@@ -69,27 +71,38 @@ class Project(ToStringMixin):
with open(serena_data_gitignore_path, "w", encoding="utf-8") as f:
f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n")
# gather ignored paths from the project configuration and gitignore files
ignored_patterns = list(project_config.ignored_paths)
if len(ignored_patterns) > 0:
log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.")
log.debug(f"Ignored paths: {ignored_patterns}")
if project_config.ignore_all_files_in_gitignore:
gitignore_parser = GitignoreParser(self.project_root)
for spec in gitignore_parser.get_ignore_specs():
log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.")
ignored_patterns.extend(spec.patterns)
self._ignored_patterns = ignored_patterns
# prepare ignore spec asynchronously, ensuring immediate project activation.
self.__ignored_patterns: list[str]
self.__ignore_spec: pathspec.PathSpec
self._ignore_spec_available = threading.Event()
threading.Thread(name=f"gather-ignorespec[{self.project_config.project_name}]", target=self._gather_ignorespec, daemon=True).start()
# Set up the pathspec matcher for the ignored paths
# for all absolute paths in ignored_paths, convert them to relative paths
processed_patterns = []
for pattern in set(ignored_patterns):
# Normalize separators (pathspec expects forward slashes)
pattern = pattern.replace(os.path.sep, "/")
processed_patterns.append(pattern)
log.debug(f"Processing {len(processed_patterns)} ignored paths")
self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
def _gather_ignorespec(self) -> None:
with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log):
# gather ignored paths from the project configuration and gitignore files
ignored_patterns = list(self.project_config.ignored_paths)
if len(ignored_patterns) > 0:
log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.")
log.debug(f"Ignored paths: {ignored_patterns}")
if self.project_config.ignore_all_files_in_gitignore:
gitignore_parser = GitignoreParser(self.project_root)
for spec in gitignore_parser.get_ignore_specs():
log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.")
ignored_patterns.extend(spec.patterns)
self.__ignored_patterns = ignored_patterns
# Set up the pathspec matcher for the ignored paths
# for all absolute paths in ignored_paths, convert them to relative paths
processed_patterns = []
for pattern in set(ignored_patterns):
# Normalize separators (pathspec expects forward slashes)
pattern = pattern.replace(os.path.sep, "/")
processed_patterns.append(pattern)
log.debug(f"Processing {len(processed_patterns)} ignored paths")
self.__ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
self._ignore_spec_available.set()
def _tostring_includes(self) -> list[str]:
return []
@@ -151,12 +164,28 @@ class Project(ToStringMixin):
abs_path = Path(self.project_root) / relative_path
return FileUtils.read_file(str(abs_path), self.project_config.encoding)
def get_ignore_spec(self) -> pathspec.PathSpec:
@property
def _ignore_spec(self) -> pathspec.PathSpec:
"""
:return: the pathspec matcher for the paths that were configured to be ignored,
either explicitly or implicitly through .gitignore files.
"""
return self._ignore_spec
if not self._ignore_spec_available.is_set():
log.info("Waiting for ignore spec to become available ...")
self._ignore_spec_available.wait()
log.info("Ignore spec is now available for project; proceeding")
return self.__ignore_spec
@property
def _ignored_patterns(self) -> list[str]:
"""
:return: the list of ignored path patterns
"""
if not self._ignore_spec_available.is_set():
log.info("Waiting for ignored patterns to become available ...")
self._ignore_spec_available.wait()
log.info("Ignore patterns are now available for project; proceeding")
return self.__ignored_patterns
def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool:
"""
@@ -198,7 +227,7 @@ class Project(ToStringMixin):
if len(rel_path.parts) > 0 and rel_path.parts[0] == ".git":
return True
return match_path(str(relative_path), self.get_ignore_spec(), root_path=self.project_root)
return match_path(str(relative_path), self._ignore_spec, root_path=self.project_root)
def is_ignored_path(self, path: str | Path, ignore_non_source_files: bool = False) -> bool:
"""
@@ -148,7 +148,6 @@ class BashLanguageServer(SolidLanguageServer):
if "Analyzing" in message_text or "analysis complete" in message_text.lower():
log.info("Bash language server analysis signals detected")
self.server_ready.set()
self.completions_available.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
@@ -183,7 +182,6 @@ class BashLanguageServer(SolidLanguageServer):
# This is common. bash-language-server doesn't always send explicit ready signals. Log as info
log.info("Timeout waiting for bash server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("Bash server initialization complete")
@@ -0,0 +1,154 @@
"""
This is an alternative to clangd for large C++ codebases where ccls may perform
better for indexing and navigation. Requires ccls to be installed and available
on PATH, or configured via ls_specific_settings with key "ls_path".
Installation
------------
ccls must be installed manually as there are no prebuilt binaries available for
direct download. Install using your system package manager:
**Linux:**
- Ubuntu/Debian (22.04+): ``sudo apt-get install ccls``
- Fedora/RHEL: ``sudo dnf install ccls``
- Arch Linux: ``sudo pacman -S ccls``
- openSUSE Tumbleweed: ``sudo zypper install ccls``
- Gentoo: ``sudo emerge dev-util/ccls``
**macOS:**
- Homebrew: ``brew install ccls``
**Windows:**
- Chocolatey: ``choco install ccls``
For alternative installation methods and build-from-source instructions, see:
https://github.com/MaskRay/ccls/wiki/Build
Official documentation:
https://github.com/MaskRay/ccls
"""
import logging
import os
import pathlib
import threading
from typing import Any, cast
from solidlsp.ls import (
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class CCLS(SolidLanguageServer):
"""
C/C++ language server implementation using ccls.
Notes:
- ccls should be installed and on PATH (or specify ls_path in settings)
- compile_commands.json at repo root is recommended for accurate indexing
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a CclsLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings)
self.server_ready = threading.Event()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
"""
Resolve ccls path from system or raise helpful error if missing.
Allows override via ls_specific_settings[language].ls_path.
"""
import shutil
ccls_path = shutil.which("ccls")
if not ccls_path:
raise FileNotFoundError(
"ccls is not installed on your system.\n"
"Please install ccls using your system package manager:\n"
" Linux (Ubuntu/Debian): sudo apt-get install ccls\n"
" Linux (Fedora/RHEL): sudo dnf install ccls\n"
" Linux (Arch): sudo pacman -S ccls\n"
" macOS (Homebrew): brew install ccls\n"
" Windows: choco install ccls\n\n"
"For build instructions and more details, see:\n"
" https://github.com/MaskRay/ccls/wiki/Build"
)
log.info(f"Using system-installed ccls at {ccls_path}")
return ccls_path
def _create_launch_command(self, core_path: str) -> list[str] | str:
return [core_path]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the ccls Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {"dynamicRegistration": True},
},
"workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": "$name",
}
],
# ccls supports initializationOptions but none are required for basic functionality
}
return cast(InitializeParams, initialize_params)
def _start_server(self) -> None:
"""
Starts the ccls language server and initializes the LSP connection.
"""
def do_nothing(params: Any) -> None:
pass
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
# Register minimal handlers
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting ccls server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to ccls and awaiting response")
self.server.send.initialize(initialize_params)
# Do not assert clangd-specific capability shapes; ccls differs
self.server.notify.initialized({})
# Basic readiness
self.server_ready.set()
@@ -1,14 +1,11 @@
"""
Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++.
"""
import json
import logging
import os
import pathlib
import threading
from typing import Any, cast
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
@@ -35,6 +32,89 @@ class ClangdLanguageServer(SolidLanguageServer):
self.initialize_searcher_command_available = threading.Event()
self.resolve_main_method_available = threading.Event()
def _prepare_compile_commands(self) -> str | None:
"""
Prepare clangd compilation database with absolute directory paths.
Clangd requires absolute directory paths in compile_commands.json for correct
cross-file reference finding. This method reads the compile_commands.json,
converts relative directory paths to absolute paths, and writes a transformed
compilation database to the serena managed directory.
The transformed file is persisted in .serena/serena_compile_commands.json
(or a configurable directory via ls_specific_settings) and is not deleted
on cleanup. This allows clangd to use the absolute-path version without
modifying the user's original compile_commands.json.
Returns the path to the serena directory containing the transformed database,
or None if no transformation was needed.
"""
compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json")
if not os.path.exists(compile_db_path):
# No compile_commands.json, nothing to do
return None
try:
with open(compile_db_path, encoding="utf-8") as f:
compile_commands = json.load(f)
if not compile_commands:
return None
# Check if any entries have relative directory paths
has_relative = False
for entry in compile_commands:
directory = entry.get("directory", "")
if directory and not os.path.isabs(directory):
has_relative = True
# Convert to absolute path
entry["directory"] = os.path.abspath(os.path.join(self.repository_root_path, directory))
if not has_relative:
# No relative paths found, no need to create transformed database
return None
# Get the target directory from ls_specific_settings, default to .serena
cpp_settings: dict[str, Any] = self._custom_settings or {}
compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena")
compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir)
os.makedirs(compile_commands_dir, exist_ok=True)
# Write the transformed compile_commands.json
# clangd looks for compile_commands.json in the --compile-commands-dir
compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json")
with open(compile_commands_path, "w", encoding="utf-8") as f:
json.dump(compile_commands, f, indent=2)
# Track the directory for --compile-commands-dir
log.info(f"Created serena compilation database with absolute paths at {compile_commands_path}")
return compile_commands_dir
except (OSError, json.JSONDecodeError) as e:
log.warning(f"Failed to prepare compile_commands.json: {e}")
return None
def _create_process_launch_info(self) -> ProcessLaunchInfo:
"""
Override to add --compile-commands-dir argument if we created a serena compilation database.
"""
# First, ensure the serena compile commands database is prepared
compile_commands_dir = self._prepare_compile_commands()
# Get the default launch info from parent
launch_info = super()._create_process_launch_info()
# If we created a serena compilation database, add --compile-commands-dir to the command
if compile_commands_dir:
# Insert --compile-commands-dir after the executable path
cmd = launch_info.cmd
assert isinstance(cmd, list)
launch_info.cmd = [cmd[0], f"--compile-commands-dir={compile_commands_dir}"] + cmd[1:]
return launch_info
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@@ -116,8 +196,10 @@ class ClangdLanguageServer(SolidLanguageServer):
os.chmod(clangd_executable_path, 0o755)
return clangd_executable_path
def _create_launch_command(self, core_path: str) -> list[str] | str:
return [core_path]
def _create_launch_command(self, core_path: str) -> list[str]:
# --background-index enables clangd to index all files in the project,
# which is required for finding cross-file references
return [core_path, "--background-index"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
@@ -132,6 +214,11 @@ class ClangdLanguageServer(SolidLanguageServer):
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
},
},
"workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}},
},
@@ -160,6 +247,7 @@ class ClangdLanguageServer(SolidLanguageServer):
await lsp.request_references(...)
# Shutdown the LanguageServer on exit from scope
# LanguageServer has been shutdown
```
"""
def register_capability_handler(params: Any) -> None:
@@ -213,8 +301,9 @@ class ClangdLanguageServer(SolidLanguageServer):
}
self.server.notify.initialized({})
self.completions_available.set()
# set ready flag
# set ready flag, clangd sends no meaningful notification when ready
# TODO This defeats the purpose of the event; we should wait for the server to actually be ready
self.server_ready.set()
# wait for server to be ready
self.server_ready.wait()
@@ -214,4 +214,3 @@ class ClojureLSP(SolidLanguageServer):
self.server.notify.initialized({})
# after initialize, Clojure-lsp is ready to serve
self.server_ready.set()
self.completions_available.set()
@@ -1,26 +1,24 @@
"""
CSharp Language Server using Microsoft.CodeAnalysis.LanguageServer (Official Roslyn-based LSP server)
CSharp Language Server using Roslyn Language Server (Official Roslyn-based LSP server from NuGet.org)
"""
import json
import logging
import os
import platform
import shutil
import subprocess
import tarfile
import threading
import urllib.request
import zipfile
from collections.abc import Iterable
from pathlib import Path
from typing import Any, cast
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Hover, UnifiedSymbolInformation
from solidlsp.ls_utils import PathUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult
from solidlsp.settings import SolidLSPSettings
@@ -33,111 +31,69 @@ log = logging.getLogger(__name__)
_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.25329.6",
description="Roslyn Language Server for Windows (x64)",
package_name="roslyn-language-server.win-x64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/5.5.0-2.26078.4",
platform_id="win-x64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/win-x64",
extract_path="tools/net10.0/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.25329.6",
description="Roslyn Language Server for Windows (ARM64)",
package_name="roslyn-language-server.win-arm64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-arm64/5.5.0-2.26078.4",
platform_id="win-arm64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/win-arm64",
extract_path="tools/net10.0/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.25329.6",
description="Roslyn Language Server for macOS (x64)",
package_name="roslyn-language-server.osx-x64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-x64/5.5.0-2.26078.4",
platform_id="osx-x64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/osx-x64",
extract_path="tools/net10.0/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.25329.6",
description="Roslyn Language Server for macOS (ARM64)",
package_name="roslyn-language-server.osx-arm64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-arm64/5.5.0-2.26078.4",
platform_id="osx-arm64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/osx-arm64",
extract_path="tools/net10.0/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.25329.6",
description="Roslyn Language Server for Linux (x64)",
package_name="roslyn-language-server.linux-x64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4",
platform_id="linux-x64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/linux-x64",
extract_path="tools/net10.0/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.25329.6",
description="Roslyn Language Server for Linux (ARM64)",
package_name="roslyn-language-server.linux-arm64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-arm64/5.5.0-2.26078.4",
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 Linux (ARM64)",
url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-arm64.tar.gz",
platform_id="linux-arm64",
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",
),
RuntimeDependency(
id="DotNetRuntime",
description=".NET 9 Runtime for Windows (ARM64)",
url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-arm64.zip",
platform_id="win-arm64",
archive_type="zip",
binary_name="dotnet.exe",
extract_path="tools/net10.0/linux-arm64",
),
]
@@ -188,22 +144,26 @@ def find_solution_or_project_file(root_dir: str) -> str | None:
class CSharpLanguageServer(SolidLanguageServer):
"""
Provides C# specific instantiation of the LanguageServer class using `Microsoft.CodeAnalysis.LanguageServer`,
the official Roslyn-based language server from Microsoft.
Provides C# specific instantiation of the LanguageServer class using the official Roslyn-based
language server from NuGet.org.
You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. This is a list of
dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely identify the dependency to override.
For example, to override the URL of the .NET runtime on windows-x64, add the entry:
You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"].
This is a list of dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely
identify the dependency to override.
Example - Override Roslyn Language Server URL:
```
{
"id": "DotNetRuntime",
"id": "CSharpLanguageServer",
"platform_id": "win-x64",
"url": "https://example.com/custom-dotnet-runtime.zip"
"url": "https://example.com/custom-roslyn-server.nupkg"
}
```
See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids.
Note: .NET runtime (version 10+) is required and installed automatically via Microsoft's official install
scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
@@ -212,8 +172,9 @@ class CSharpLanguageServer(SolidLanguageServer):
Use LanguageServer.create() instead.
"""
super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings)
self.initialization_complete = threading.Event()
# Cache for original Roslyn symbol names with type annotations
# Key: (relative_file_path, line, character) -> Value: original name
self._original_symbol_names: dict[tuple[str, int, int], str] = {}
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path)
@@ -222,6 +183,116 @@ class CSharpLanguageServer(SolidLanguageServer):
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"]
@override
def request_document_symbols(self, relative_file_path: str, file_buffer: Any = None) -> DocumentSymbols:
"""
Override to normalize Roslyn symbol names and cache originals.
Roslyn 5.5.0+ returns symbol names with type annotations:
- Properties: "Name : string"
- Methods: "Add(int, int) : int"
This method:
1. Normalizes names to base form ("Name", "Add")
2. Caches original names for rich information display
3. Populates LSP spec's 'detail' field with type/signature info
"""
symbols = super().request_document_symbols(relative_file_path, file_buffer)
# Normalize all symbols recursively
for symbol in symbols.iter_symbols():
self._normalize_symbol_name(symbol, relative_file_path)
return symbols
@override
def request_hover(self, relative_file_path: str, line: int, column: int) -> Hover | None:
"""
Override to inject original Roslyn symbol names (with type annotations) into hover responses.
When hovering over a symbol whose name was normalized, we prepend the original
full name (e.g., 'Add(int, int) : int') to the hover content.
"""
hover = super().request_hover(relative_file_path, line, column)
if hover is None:
return None
# Check if we have an original name for this position
original_name = self._original_symbol_names.get((relative_file_path, line, column))
if original_name and "contents" in hover:
contents = hover["contents"]
if isinstance(contents, dict) and "value" in contents:
# Prepend the original full name with type information to the hover content
prefix = f"**{original_name}**\n\n---\n\n"
contents["value"] = prefix + contents["value"]
return hover
def _normalize_symbol_name(self, symbol: UnifiedSymbolInformation, relative_file_path: str) -> None:
"""
Normalize a single symbol's name and cache the original.
Processes children recursively.
"""
original_name = symbol.get("name", "")
# Extract base name and type/signature info
normalized_name, type_info = self._extract_base_name_and_type(original_name)
# Store original name if it was normalized
if original_name != normalized_name:
sel_range = symbol.get("selectionRange")
if sel_range:
start = sel_range.get("start")
if start and "line" in start and "character" in start:
line = start["line"]
char = start["character"]
cache_key = (relative_file_path, line, char)
self._original_symbol_names[cache_key] = original_name
# Populate LSP spec's 'detail' field with type/signature information
if type_info and "detail" not in symbol:
symbol["detail"] = type_info
# Update the symbol name
symbol["name"] = normalized_name
# Process children recursively
children = symbol.get("children", [])
for child in children:
self._normalize_symbol_name(child, relative_file_path)
@staticmethod
def _extract_base_name_and_type(roslyn_name: str) -> tuple[str, str]:
"""
Extract base name and type/signature information from Roslyn symbol names.
Examples:
"Name : string" -> ("Name", ": string")
"Add(int, int) : int" -> ("Add", "(int, int) : int")
"ToString()" -> ("ToString", "()")
"SimpleMethod" -> ("SimpleMethod", "")
Returns:
Tuple of (base_name, type_info)
"""
# Check for property pattern: "Name : Type"
if " : " in roslyn_name and "(" not in roslyn_name:
base_name, type_part = roslyn_name.split(" : ", 1)
return base_name.strip(), f": {type_part.strip()}"
# Check for method pattern: "MethodName(params) : ReturnType"
if "(" in roslyn_name:
paren_idx = roslyn_name.index("(")
base_name = roslyn_name[:paren_idx].strip()
signature = roslyn_name[paren_idx:].strip()
return base_name, signature
# No type annotation
return roslyn_name, ""
class DependencyProvider(LanguageServerDependencyProvider):
def __init__(
self,
@@ -263,11 +334,23 @@ class CSharpLanguageServer(SolidLanguageServer):
"""
runtime_dependency_overrides = cast(list[dict[str, Any]], self._custom_settings.get("runtime_dependencies", []))
# Filter out deprecated DotNetRuntime overrides and warn users
filtered_overrides = []
for dep_override in runtime_dependency_overrides:
if dep_override.get("id") == "DotNetRuntime":
log.warning(
"The 'DotNetRuntime' runtime_dependencies override is no longer supported. "
".NET is now installed automatically via Microsoft's official install scripts. "
"Please remove this override from your configuration."
)
else:
filtered_overrides.append(dep_override)
log.debug("Resolving runtime dependencies")
runtime_dependencies = RuntimeDependencyCollection(
_RUNTIME_DEPENDENCIES,
overrides=runtime_dependency_overrides,
overrides=filtered_overrides,
)
log.debug(
@@ -276,30 +359,28 @@ class CSharpLanguageServer(SolidLanguageServer):
# Find the dependencies for our platform
lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer")
dotnet_runtime_dep = runtime_dependencies.get_single_dep_for_current_platform("DotNetRuntime")
dotnet_path = self._ensure_dotnet_runtime(dotnet_runtime_dep)
dotnet_path = self._ensure_dotnet_runtime()
server_dll_path = self._ensure_language_server(lang_server_dep)
return dotnet_path, server_dll_path
def _ensure_dotnet_runtime(self, dotnet_runtime_dep: RuntimeDependency) -> str:
def _ensure_dotnet_runtime(self) -> str:
"""Ensure .NET runtime is available and return the dotnet executable path."""
# TODO: use RuntimeDependency util methods instead of custom validation/download logic
# Check if dotnet is already available on the system
system_dotnet = shutil.which("dotnet")
if system_dotnet:
# Check if it's .NET 9
# Check if it's .NET 10 or compatible
try:
result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
if "Microsoft.NETCore.App 9." in result.stdout:
log.info("Found system .NET 9 runtime")
# Accept .NET 10 or higher (10.x, 11.x, etc.)
if any(f"Microsoft.NETCore.App {v}." in result.stdout for v in range(10, 20)):
log.info("Found system .NET 10+ runtime")
return system_dotnet
except subprocess.CalledProcessError:
pass
# Download .NET 9 runtime using config
return self._ensure_dotnet_runtime_from_config(dotnet_runtime_dep)
# Install .NET 10 runtime using Microsoft's install script
return self._install_dotnet_with_script()
def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str:
"""Ensure language server is available and return the DLL path."""
@@ -311,26 +392,24 @@ class CSharpLanguageServer(SolidLanguageServer):
server_dll = server_dir / lang_server_dep.binary_name
if server_dll.exists():
log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}")
log.info(f"Using cached Roslyn Language Server from {server_dll}")
return str(server_dll)
# Download and install the language server
log.info(f"Downloading {package_name} version {package_version}...")
assert package_version is not None
assert package_name is not None
package_path = self._download_nuget_package_direct(package_name, package_version)
log.info(f"Downloading {package_name} version {package_version} from NuGet.org...")
package_path = self._download_nuget_package(lang_server_dep)
# Extract and install
self._extract_language_server(lang_server_dep, package_path, server_dir)
if not server_dll.exists():
raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
raise SolidLSPException("Roslyn Language Server DLL not found after extraction")
# Make executable on Unix systems
if platform.system().lower() != "windows":
server_dll.chmod(0o755)
log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}")
log.info(f"Successfully installed Roslyn Language Server to {server_dll}")
return str(server_dll)
@staticmethod
@@ -356,43 +435,28 @@ class CSharpLanguageServer(SolidLanguageServer):
server_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, server_dir, dirs_exist_ok=True)
def _download_nuget_package_direct(self, package_name: str, package_version: str) -> Path:
def _download_nuget_package(self, dependency: RuntimeDependency) -> Path:
"""
Download a NuGet package directly from the Azure NuGet feed.
Download a NuGet package from NuGet.org and extract it.
Returns the path to the extracted package directory.
"""
azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json"
package_name = dependency.package_name
package_version = dependency.package_version
url = dependency.url
if url is None:
raise SolidLSPException(f"No URL specified for package {package_name} version {package_version}")
# Create temporary directory for package download
temp_dir = Path(self._ls_resources_dir) / "temp_downloads"
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# First, get the service index from the Azure feed
log.debug("Fetching NuGet service index from Azure feed...")
with urllib.request.urlopen(azure_feed_url) as response:
service_index = json.loads(response.read().decode())
# Find the package base address (for downloading packages)
package_base_address = None
for resource in service_index.get("resources", []):
if resource.get("@type") == "PackageBaseAddress/3.0.0":
package_base_address = resource.get("@id")
break
if not package_base_address:
raise SolidLSPException("Could not find package base address in Azure NuGet feed")
# Construct the download URL for the specific package
package_id_lower = package_name.lower()
package_version_lower = package_version.lower()
package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg"
log.debug(f"Downloading package from: {package_url}")
log.debug(f"Downloading package from: {url}")
# Download the .nupkg file
nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg"
urllib.request.urlretrieve(package_url, nupkg_file)
urllib.request.urlretrieve(url, nupkg_file)
# Extract the .nupkg file (it's just a zip file)
package_extract_dir = temp_dir / f"{package_name}.{package_version}"
@@ -405,82 +469,88 @@ class CSharpLanguageServer(SolidLanguageServer):
# Clean up the nupkg file
nupkg_file.unlink()
log.info(f"Successfully downloaded and extracted {package_name} version {package_version}")
log.info(f"Successfully downloaded and extracted {package_name} version {package_version} from NuGet.org")
return package_extract_dir
except Exception as e:
raise SolidLSPException(
f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}"
) from e
raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e
def _ensure_dotnet_runtime_from_config(self, dotnet_runtime_dep: RuntimeDependency) -> str:
def _install_dotnet_with_script(self, version: str = "10.0") -> str:
"""
Ensure .NET 9 runtime is available using runtime dependency configuration.
Install .NET runtime using Microsoft's official install script.
Returns the path to the dotnet executable.
"""
# TODO: use RuntimeDependency util methods instead of custom download logic
dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}"
# Check if dotnet is already available on the 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:
log.info("Found system .NET 9 runtime")
return system_dotnet
except subprocess.CalledProcessError:
pass
# Download .NET 9 runtime using config
dotnet_dir = Path(self._ls_resources_dir) / "dotnet-runtime-9.0"
assert dotnet_runtime_dep.binary_name is not None, "Runtime dependency must have a binary_name"
dotnet_exe = dotnet_dir / dotnet_runtime_dep.binary_name
# Determine binary name based on platform
is_windows = platform.system().lower() == "windows"
dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet")
if dotnet_exe.exists():
log.info(f"Using cached .NET runtime from {dotnet_exe}")
log.info(f"Using cached .NET {version} runtime from {dotnet_exe}")
return str(dotnet_exe)
# Download .NET runtime
log.info("Downloading .NET 9 runtime...")
# Download and run install script
log.info(f"Installing .NET {version} runtime using official Microsoft install script...")
dotnet_dir.mkdir(parents=True, exist_ok=True)
custom_dotnet_runtime_url = self._custom_settings.get("dotnet_runtime_url")
if custom_dotnet_runtime_url is not None:
log.info(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}")
url = custom_dotnet_runtime_url
else:
url = dotnet_runtime_dep.url
archive_type = dotnet_runtime_dep.archive_type
# Download the runtime
download_path = dotnet_dir / f"dotnet-runtime.{archive_type}"
try:
log.debug(f"Downloading from {url}")
urllib.request.urlretrieve(url, download_path)
if is_windows:
# PowerShell script for Windows
script_url = "https://dot.net/v1/dotnet-install.ps1"
script_path = dotnet_dir / "dotnet-install.ps1"
urllib.request.urlretrieve(script_url, script_path)
# Extract the archive
if archive_type == "zip":
with zipfile.ZipFile(download_path, "r") as zip_ref:
zip_ref.extractall(dotnet_dir)
cmd = [
"pwsh",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script_path),
"-Version",
version,
"-InstallDir",
str(dotnet_dir),
"-Runtime",
"dotnet",
"-NoPath",
]
else:
# tar.gz
with tarfile.open(download_path, "r:gz") as tar_ref:
tar_ref.extractall(dotnet_dir)
# Bash script for Linux/macOS
script_url = "https://dot.net/v1/dotnet-install.sh"
script_path = dotnet_dir / "dotnet-install.sh"
urllib.request.urlretrieve(script_url, script_path)
script_path.chmod(0o755)
# Remove the archive
download_path.unlink()
cmd = [
"bash",
str(script_path),
"--version",
version,
"--install-dir",
str(dotnet_dir),
"--runtime",
"dotnet",
"--no-path",
]
# Make dotnet executable on Unix
if platform.system().lower() != "windows":
dotnet_exe.chmod(0o755)
# Run the install script
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
log.debug(f"Install script output: {result.stdout}")
log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}")
if not dotnet_exe.exists():
raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation")
log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}")
return str(dotnet_exe)
except subprocess.CalledProcessError as e:
raise SolidLSPException(
f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}"
) from e
except Exception as e:
raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e
raise SolidLSPException(f"Failed to install .NET {version} runtime: {e}") from e
def _get_initialize_params(self) -> InitializeParams:
"""
@@ -538,6 +608,8 @@ class CSharpLanguageServer(SolidLanguageServer):
)
def _start_server(self) -> None:
indexing_complete = threading.Event()
def do_nothing(params: dict) -> None:
return
@@ -645,7 +717,7 @@ class CSharpLanguageServer(SolidLanguageServer):
return
def handle_workspace_indexing_complete(params: dict) -> None:
self.completions_available.set()
indexing_complete.set()
# Set up notification handlers
self.server.on_notification("window/logMessage", window_log_message)
@@ -699,19 +771,16 @@ class CSharpLanguageServer(SolidLanguageServer):
# Open solution and project files
self._open_solution_and_projects()
self.initialization_complete.set()
log.info(
"Microsoft.CodeAnalysis.LanguageServer initialized and ready\n"
"Waiting for language server to index project files...\n"
"This may take a while for large projects"
)
if self.completions_available.wait(30): # Wait up to 30 seconds for indexing
if indexing_complete.wait(30): # Wait up to 30 seconds for indexing
log.info("Indexing complete")
else:
log.warning("Timeout waiting for indexing to complete, proceeding anyway")
self.completions_available.set()
def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None:
"""
@@ -739,7 +739,6 @@ class EclipseJDTLS(SolidLanguageServer):
"*",
" ",
]
self.completions_available.set()
if registration["method"] == "workspace/executeCommand":
if "java.intellicode.enable" in registration["registerOptions"]["commands"]:
self._intellicode_enable_command_available.set()
@@ -328,7 +328,6 @@ class ElixirTools(SolidLanguageServer):
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
self.server.notify.initialized({})
self.completions_available.set()
# Expert needs time to compile the project and build indexes on first run.
# This can take 2-3+ minutes for mid-sized codebases.
@@ -50,7 +50,6 @@ class ElmLanguageServer(SolidLanguageServer):
"elm",
solidlsp_settings,
)
self.server_ready = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
@@ -196,8 +195,6 @@ class ElmLanguageServer(SolidLanguageServer):
else:
log.warning("Timeout waiting for Elm workspace scan, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
log.info("Elm server ready")
@override
@@ -155,7 +155,6 @@ class ErlangLanguageServer(SolidLanguageServer):
log.info(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}")
self.server.notify.initialized({})
self.completions_available.set()
# Wait for Erlang LS to be ready - adjust timeout based on environment
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
@@ -7,7 +7,6 @@ import os
import pathlib
import re
import shutil
import threading
from overrides import override
@@ -190,7 +189,6 @@ class FortranLanguageServer(SolidLanguageServer):
super().__init__(
config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings
)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
@@ -283,8 +281,6 @@ class FortranLanguageServer(SolidLanguageServer):
log.info("Fortran LSP document symbol provider available")
self.server.notify.initialized({})
self.completions_available.set()
# Fortran Language Server is ready after initialization
self.server_ready.set()
log.info("Fortran Language Server initialization complete")
+1 -5
View File
@@ -2,7 +2,6 @@ import logging
import os
import pathlib
import subprocess
import threading
from typing import Any, cast
from overrides import override
@@ -95,7 +94,6 @@ class Gopls(SolidLanguageServer):
self._setup_runtime_dependency()
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings)
self.server_ready = threading.Event()
self.request_id = 0
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
@@ -234,8 +232,6 @@ class Gopls(SolidLanguageServer):
assert "definitionProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# gopls server is typically ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
# (no need to wait for events)
@@ -271,4 +271,3 @@ class GroovyLanguageServer(SolidLanguageServer):
assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols"
self.server.notify.initialized({})
self.completions_available.set()
@@ -374,7 +374,6 @@ class HaskellLanguageServer(SolidLanguageServer):
log.info(f"HLS capabilities: {list(capabilities.keys())}")
self.server.notify.initialized({})
self.completions_available.set()
# Give HLS time to index the project
# HLS can be slow to index, especially on first run
@@ -172,7 +172,6 @@ class Intelephense(SolidLanguageServer):
assert "definitionProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# Intelephense server is typically ready immediately after initialization
# TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for!
+3 -1
View File
@@ -5,6 +5,7 @@ Provides Python specific instantiation of the LanguageServer class. Contains var
import logging
import os
import pathlib
import threading
from typing import cast
from overrides import override
@@ -155,6 +156,7 @@ class JediServer(SolidLanguageServer):
"""
Starts the JEDI Language Server
"""
completions_available = threading.Event()
def execute_client_command_handler(params: dict) -> list:
return []
@@ -164,7 +166,7 @@ class JediServer(SolidLanguageServer):
def check_experimental_status(params: dict) -> None:
if params["quiescent"] == True:
self.completions_available.set()
completions_available.set()
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
@@ -166,5 +166,4 @@ class JuliaLanguageServer(SolidLanguageServer):
assert "documentSymbolProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
log.info("Julia Language Server is initialized and ready.")
@@ -485,4 +485,3 @@ class KotlinLanguageServer(SolidLanguageServer):
assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens"
self.server.notify.initialized({})
self.completions_available.set()
+1 -5
View File
@@ -8,7 +8,6 @@ import pathlib
import platform
import shutil
import tarfile
import threading
import zipfile
from pathlib import Path
@@ -161,7 +160,6 @@ class LuaLanguageServer(SolidLanguageServer):
super().__init__(
config, repository_root_path, ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), "lua", solidlsp_settings
)
self.server_ready = threading.Event()
self.request_id = 0
@staticmethod
@@ -281,8 +279,6 @@ class LuaLanguageServer(SolidLanguageServer):
assert "referencesProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# Lua Language Server is typically ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
# (no need to wait for events)
@@ -6,7 +6,6 @@ Contains various configurations and settings specific to Markdown.
import logging
import os
import pathlib
import threading
from overrides import override
@@ -99,7 +98,6 @@ class Marksman(SolidLanguageServer):
"markdown",
solidlsp_settings,
)
self.server_ready = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
@@ -181,5 +179,3 @@ class Marksman(SolidLanguageServer):
# marksman is typically ready immediately after initialization
log.info("Marksman server initialization complete")
self.server_ready.set()
self.completions_available.set()
@@ -463,7 +463,6 @@ class MatlabLanguageServer(SolidLanguageServer):
if "mvm attach success" in message_text.lower() or "adding workspace folder" in message_text.lower():
log.info("MATLAB language server ready signal detected (MVM attached)")
self.server_ready.set()
self.completions_available.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
@@ -508,7 +507,6 @@ class MatlabLanguageServer(SolidLanguageServer):
# Fallback: assume server is ready after timeout
log.info("Timeout waiting for MATLAB server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("MATLAB server initialization complete")
-5
View File
@@ -11,7 +11,6 @@ import pathlib
import platform
import shutil
import subprocess
import threading
from pathlib import Path
from overrides import override
@@ -249,7 +248,6 @@ class NixLanguageServer(SolidLanguageServer):
nixd_path = self._setup_runtime_dependency()
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings)
self.server_ready = threading.Event()
self.request_id = 0
@staticmethod
@@ -374,8 +372,5 @@ class NixLanguageServer(SolidLanguageServer):
assert "referencesProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# nixd server is typically ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
@@ -108,6 +108,7 @@ class OmniSharp(SolidLanguageServer):
self.server_ready = threading.Event()
self.definition_available = threading.Event()
self.references_available = threading.Event()
self.completions_available = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
@@ -141,7 +141,6 @@ class PascalLanguageServer(SolidLanguageServer):
solidlsp_settings,
)
self.server_ready = threading.Event()
self.completions_available_event = threading.Event()
# ============== Metadata Directory Management ==============
@@ -859,7 +858,6 @@ class PascalLanguageServer(SolidLanguageServer):
if "initialized" in message_text.lower() or "ready" in message_text.lower():
log.info("Pascal language server ready signal detected")
self.server_ready.set()
self.completions_available.set()
def publish_diagnostics(params: dict) -> None:
log.debug(f"Diagnostics: {params}")
@@ -904,7 +902,6 @@ class PascalLanguageServer(SolidLanguageServer):
# pasls may not send explicit ready signals, so we proceed after timeout
log.info("Timeout waiting for Pascal server ready signal, assuming server is ready")
self.server_ready.set()
self.completions_available.set()
else:
log.info("Pascal server initialization complete")
@@ -206,8 +206,6 @@ class PerlLanguageServer(SolidLanguageServer):
log.info(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}")
self.server.notify.workspace_did_change_configuration(perl_config)
self.completions_available.set()
# Perl::LanguageServer needs time to index files and resolve cross-file references
# Without this delay, requests for definitions/references may return empty results
settling_time = 0.5
@@ -159,4 +159,3 @@ class PhpactorServer(SolidLanguageServer):
assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support"
self.server.notify.initialized({})
self.completions_available.set()
@@ -287,7 +287,6 @@ class PowerShellLanguageServer(SolidLanguageServer):
if "started" in message_text.lower() or "ready" in message_text.lower():
log.info("PowerShell Editor Services ready signal detected")
self.server_ready.set()
self.completions_available.set()
def do_nothing(params: dict) -> None:
return
@@ -320,6 +319,5 @@ class PowerShellLanguageServer(SolidLanguageServer):
# Fallback: assume server is ready after timeout
log.info("Timeout waiting for PSES ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("PowerShell Editor Services initialization complete")
@@ -154,7 +154,6 @@ class PyrightServer(SolidLanguageServer):
log.info("Pyright workspace scanning complete")
self.found_source_files = True
self.analysis_complete.set()
self.completions_available.set()
def check_experimental_status(params: dict) -> None:
"""
@@ -164,7 +163,6 @@ class PyrightServer(SolidLanguageServer):
log.info("Received experimental/serverStatus with quiescent=true")
if not self.found_source_files:
self.analysis_complete.set()
self.completions_available.set()
# Set up notification handlers
self.server.on_request("client/registerCapability", do_nothing)
@@ -203,4 +201,3 @@ class PyrightServer(SolidLanguageServer):
log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway")
# Fallback: assume analysis is complete after timeout
self.analysis_complete.set()
self.completions_available.set()
@@ -2,7 +2,6 @@ import logging
import os
import pathlib
import subprocess
import threading
from typing import Any
from overrides import override
@@ -68,7 +67,6 @@ class RLanguageServer(SolidLanguageServer):
r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"'
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
@@ -158,7 +156,5 @@ class RLanguageServer(SolidLanguageServer):
log.info("R LSP definition provider available")
self.server.notify.initialized({})
self.completions_available.set()
# R Language Server is ready after initialization
self.server_ready.set()
@@ -3,7 +3,6 @@
import logging
import os
import shutil
import threading
from overrides import override
@@ -53,7 +52,6 @@ class RegalLanguageServer(SolidLanguageServer):
"rego",
solidlsp_settings,
)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
@@ -129,8 +127,5 @@ class RegalLanguageServer(SolidLanguageServer):
assert "textDocumentSync" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# Regal server is ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
@@ -350,7 +350,6 @@ class RubyLsp(SolidLanguageServer):
if params.get("type") == "ready":
log.info("ruby-lsp service is ready.")
self.analysis_complete.set()
self.completions_available.set()
def execute_client_command_handler(params: dict) -> list:
return []
@@ -370,7 +369,6 @@ class RubyLsp(SolidLanguageServer):
if value.get("kind") == "end":
log.info("ruby-lsp indexing complete ($/progress end)")
self.analysis_complete.set()
self.completions_available.set()
elif value.get("kind") == "begin":
log.info("ruby-lsp indexing started ($/progress begin)")
elif "percentage" in value:
@@ -384,7 +382,6 @@ class RubyLsp(SolidLanguageServer):
if value.get("kind") == "end" or value.get("percentage") == 100:
log.info("ruby-lsp indexing complete (token progress)")
self.analysis_complete.set()
self.completions_available.set()
def window_work_done_progress_create(params: dict) -> dict:
"""Handle workDoneProgress/create requests from ruby-lsp"""
@@ -429,7 +426,6 @@ class RubyLsp(SolidLanguageServer):
log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway")
# Fallback: assume indexing is complete after timeout
self.analysis_complete.set()
self.completions_available.set()
def _handle_initialization_response(self, init_response: InitializeResult) -> None:
"""
+118 -113
View File
@@ -13,10 +13,9 @@ from typing import cast
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
@@ -43,136 +42,140 @@ class RustAnalyzer(SolidLanguageServer):
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _get_rustup_version() -> str | None:
"""Get installed rustup version or None if not found."""
try:
result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
@staticmethod
def _get_rustup_version() -> str | None:
"""Get installed rustup version or None if not found."""
try:
result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
return None
return None
return None
@staticmethod
def _get_rust_analyzer_via_rustup() -> str | None:
"""Get rust-analyzer path via rustup. Returns None if not found."""
try:
result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None
@staticmethod
def _get_rust_analyzer_via_rustup() -> str | None:
"""Get rust-analyzer path via rustup. Returns None if not found."""
try:
result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None
@staticmethod
def _ensure_rust_analyzer_installed() -> str:
"""
Ensure rust-analyzer is available.
@staticmethod
def _ensure_rust_analyzer_installed() -> str:
"""
Ensure rust-analyzer is available.
Priority order:
1. Rustup existing installation (preferred - matches toolchain version)
2. Rustup auto-install if rustup is available (ensures correct version)
3. Common installation locations as fallback (only if rustup not available)
4. System PATH last (can pick up incompatible versions)
Priority order:
1. Rustup existing installation (preferred - matches toolchain version)
2. Rustup auto-install if rustup is available (ensures correct version)
3. Common installation locations as fallback (only if rustup not available)
4. System PATH last (can pick up incompatible versions)
:return: path to rust-analyzer executable
"""
# Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH)
rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup()
if rustup_path:
return rustup_path
:return: path to rust-analyzer executable
"""
# Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH)
rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup()
if rustup_path:
return rustup_path
# If rustup is available but rust-analyzer not installed, auto-install it BEFORE
# checking common paths. This ensures we get the correct version matching the toolchain.
if RustAnalyzer._get_rustup_version():
result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True)
if result.returncode == 0:
# Verify installation worked
rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup()
if rustup_path:
return rustup_path
# If auto-install failed, fall through to common paths as last resort
# If rustup is available but rust-analyzer not installed, auto-install it BEFORE
# checking common paths. This ensures we get the correct version matching the toolchain.
if RustAnalyzer.DependencyProvider._get_rustup_version():
result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True)
if result.returncode == 0:
# Verify installation worked
rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup()
if rustup_path:
return rustup_path
# If auto-install failed, fall through to common paths as last resort
# Determine platform-specific binary name and paths
is_windows = platform.system() == "Windows"
binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer"
# Determine platform-specific binary name and paths
is_windows = platform.system() == "Windows"
binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer"
# Fallback to common installation locations (only used if rustup not available)
common_paths: list[str | None] = []
# Fallback to common installation locations (only used if rustup not available)
common_paths: list[str | None] = []
if is_windows:
# Windows-specific paths
home = pathlib.Path.home()
common_paths.extend(
[
str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup
str(home / "scoop" / "shims" / binary_name), # Scoop package manager
str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct
str(
pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name
), # Standalone install
]
)
else:
# Unix-like paths (macOS, Linux)
common_paths.extend(
[
"/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon)
"/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system
os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install
os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin
]
if is_windows:
# Windows-specific paths
home = pathlib.Path.home()
common_paths.extend(
[
str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup
str(home / "scoop" / "shims" / binary_name), # Scoop package manager
str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct
str(
pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name
), # Standalone install
]
)
else:
# Unix-like paths (macOS, Linux)
common_paths.extend(
[
"/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon)
"/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system
os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install
os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin
]
)
for path in common_paths:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
# Last resort: check system PATH (can pick up incorrect aliases, hence checked last)
path_result = shutil.which("rust-analyzer")
if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK):
return path_result
# Provide helpful error message with all searched locations
searched = [p for p in common_paths if p]
install_instructions = [
" - Rustup: rustup component add rust-analyzer",
" - Cargo: cargo install rust-analyzer",
]
if is_windows:
install_instructions.extend(
[
" - Scoop: scoop install rust-analyzer",
" - Chocolatey: choco install rust-analyzer",
" - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases",
]
)
else:
install_instructions.extend(
[
" - Homebrew (macOS): brew install rust-analyzer",
" - System package manager (Linux): apt/dnf/pacman install rust-analyzer",
]
)
raise RuntimeError(
"rust-analyzer is not installed or not in PATH.\n"
"Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n"
"Please install rust-analyzer via:\n" + "\n".join(install_instructions)
)
for path in common_paths:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
def _get_or_install_core_dependency(self) -> str:
return self._ensure_rust_analyzer_installed()
# Last resort: check system PATH (can pick up incorrect aliases, hence checked last)
path_result = shutil.which("rust-analyzer")
if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK):
return path_result
# Provide helpful error message with all searched locations
searched = [p for p in common_paths if p]
install_instructions = [
" - Rustup: rustup component add rust-analyzer",
" - Cargo: cargo install rust-analyzer",
]
if is_windows:
install_instructions.extend(
[
" - Scoop: scoop install rust-analyzer",
" - Chocolatey: choco install rust-analyzer",
" - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases",
]
)
else:
install_instructions.extend(
[
" - Homebrew (macOS): brew install rust-analyzer",
" - System package manager (Linux): apt/dnf/pacman install rust-analyzer",
]
)
raise RuntimeError(
"rust-analyzer is not installed or not in PATH.\n"
"Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n"
"Please install rust-analyzer via:\n" + "\n".join(install_instructions)
)
def _create_launch_command(self, core_path: str) -> list[str] | str:
return [core_path]
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
rustanalyzer_executable_path = self._ensure_rust_analyzer_installed()
log.info(f"Using rust-analyzer at: {rustanalyzer_executable_path}")
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path),
None,
"rust",
solidlsp_settings,
)
@@ -181,6 +184,9 @@ class RustAnalyzer(SolidLanguageServer):
self.initialize_searcher_command_available = threading.Event()
self.resolve_main_method_available = threading.Event()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["target"]
@@ -715,6 +721,5 @@ class RustAnalyzer(SolidLanguageServer):
"completionItem": {"labelDetailsSupport": True},
}
self.server.notify.initialized({})
self.completions_available.set()
self.server_ready.wait()
@@ -320,7 +320,6 @@ class Solargraph(SolidLanguageServer):
if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.":
log.info("Solargraph service is ready.")
self.analysis_complete.set()
self.completions_available.set()
def execute_client_command_handler(params: dict) -> list:
return []
@@ -364,4 +363,3 @@ class Solargraph(SolidLanguageServer):
log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway")
# Fallback: assume analysis is complete after timeout
self.analysis_complete.set()
self.completions_available.set()
@@ -2,7 +2,6 @@ import logging
import os
import pathlib
import subprocess
import threading
import time
from overrides import override
@@ -53,7 +52,6 @@ class SourceKitLSP(SolidLanguageServer):
super().__init__(
config, repository_root_path, ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), "swift", solidlsp_settings
)
self.server_ready = threading.Event()
self.request_id = 0
self._did_sleep_before_requesting_references = False
self._initialization_timestamp: float | None = None
@@ -332,10 +330,6 @@ class SourceKitLSP(SolidLanguageServer):
assert "definitionProvider" in capabilities, "definitionProvider capability missing"
self.server.notify.initialized({})
self.completions_available.set()
self.server_ready.set()
self.server_ready.wait()
# Mark initialization timestamp for smarter delay calculation
self._initialization_timestamp = time.time()
@@ -11,7 +11,6 @@ import platform
import shutil
import socket
import stat
import threading
import urllib.request
from typing import Any
@@ -128,7 +127,6 @@ class TaploServer(SolidLanguageServer):
"toml",
solidlsp_settings,
)
self.server_ready = threading.Event()
@classmethod
def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str:
@@ -308,8 +306,6 @@ class TaploServer(SolidLanguageServer):
self.server.notify.initialized({})
log.info("Taplo server initialization complete")
self.server_ready.set()
self.completions_available.set()
def is_ignored_dirname(self, dirname: str) -> bool:
"""Define TOML-specific directories to ignore."""
@@ -1,7 +1,6 @@
import logging
import os
import shutil
import threading
from typing import cast
from overrides import override
@@ -166,7 +165,6 @@ class TerraformLS(SolidLanguageServer):
"terraform",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.request_id = 0
@staticmethod
@@ -232,8 +230,5 @@ class TerraformLS(SolidLanguageServer):
assert "definitionProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# terraform-ls server is typically ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
@@ -269,7 +269,6 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"""
if params.get("quiescent") == True:
self.server_ready.set()
self.completions_available.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
@@ -302,11 +301,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway")
# Fallback: assume server is ready after timeout
self.server_ready.set()
self.completions_available.set()
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 1
return 2
@override
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
@@ -186,7 +186,6 @@ class VtsLanguageServer(SolidLanguageServer):
"""
if params.get("quiescent") is True:
self.server_ready.set()
self.completions_available.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
@@ -222,7 +221,6 @@ class VtsLanguageServer(SolidLanguageServer):
log.info("Timeout waiting for VTS server to become ready, proceeding anyway")
# Fallback: assume server is ready after timeout
self.server_ready.set()
self.completions_available.set()
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
@@ -633,7 +633,6 @@ class VueLanguageServer(SolidLanguageServer):
if "initialized" in message_text.lower() or "ready" in message_text.lower():
log.info("Vue language server ready signal detected")
self.server_ready.set()
self.completions_available.set()
def tsserver_request_notification_handler(params: list) -> None:
try:
@@ -683,7 +682,6 @@ class VueLanguageServer(SolidLanguageServer):
if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT):
log.info("Timeout waiting for Vue server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("Vue server initialization complete")
@@ -7,7 +7,6 @@ import logging
import os
import pathlib
import shutil
import threading
from typing import Any
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
@@ -55,7 +54,6 @@ class YamlLanguageServer(SolidLanguageServer):
"yaml",
solidlsp_settings,
)
self.server_ready = threading.Event()
@classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
@@ -184,5 +182,3 @@ class YamlLanguageServer(SolidLanguageServer):
# YAML language server is ready immediately after initialization
log.info("YAML server initialization complete")
self.server_ready.set()
self.completions_available.set()
+1 -5
View File
@@ -8,7 +8,6 @@ import pathlib
import platform
import shutil
import subprocess
import threading
from overrides import override
@@ -100,7 +99,6 @@ class ZigLanguageServer(SolidLanguageServer):
self._setup_runtime_dependency()
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings)
self.server_ready = threading.Event()
self.request_id = 0
@staticmethod
@@ -213,11 +211,9 @@ class ZigLanguageServer(SolidLanguageServer):
assert "referencesProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# ZLS server is ready after initialization
self.server_ready.set()
self.server_ready.wait()
# (no need to wait for an event)
# Open build.zig if it exists to help ZLS understand project structure
build_zig_path = os.path.join(self.repository_root_path, "build.zig")
+89 -40
View File
@@ -64,7 +64,18 @@ class LSPFileBuffer:
This class is used to store the contents of an open LSP file in memory.
"""
def __init__(self, uri: str, contents: str, encoding: str, version: int, language_id: str, ref_count: int) -> None:
def __init__(
self,
uri: str,
contents: str,
encoding: str,
version: int,
language_id: str,
ref_count: int,
language_server: "SolidLanguageServer",
open_in_ls: bool = True,
) -> None:
self.language_server = language_server
self.uri = uri
self.contents = contents
self.version = version
@@ -72,6 +83,41 @@ class LSPFileBuffer:
self.ref_count = ref_count
self.encoding = encoding
self._content_hash: str | None = None
self._is_open_in_ls = False
if open_in_ls:
self._open_in_ls()
def _open_in_ls(self) -> None:
"""
Open the file in the language server if it is not already open.
"""
if self._is_open_in_ls:
return
self._is_open_in_ls = True
self.language_server.server.notify.did_open_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: self.uri,
LSPConstants.LANGUAGE_ID: self.language_id,
LSPConstants.VERSION: 0,
LSPConstants.TEXT: self.contents,
}
}
)
def close(self) -> None:
if self._is_open_in_ls:
self.language_server.server.notify.did_close_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: self.uri,
}
}
)
def ensure_open_in_ls(self) -> None:
"""Ensure that the file is opened in the language server."""
self._open_in_ls()
@property
def content_hash(self) -> str:
@@ -409,7 +455,6 @@ class SolidLanguageServer(ABC):
self._load_document_symbols_cache()
self.server_started = False
self.completions_available = threading.Event()
if config.trace_lsp_communication:
def logging_fn(source: str, target: str, msg: StringDict | str) -> None:
@@ -611,11 +656,14 @@ class SolidLanguageServer(ABC):
return self.language_id
@contextmanager
def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]:
def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]:
"""
Open a file in the Language Server. This is required before making any requests to the Language Server.
:param relative_file_path: The relative path of the file to open.
:param open_in_ls: whether to open the file in the language server, sending the didOpen notification.
Set this to False to read the local file buffer without notifying the LS; the file can
be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer.
"""
if not self.server_started:
log.error("open_file called before Language Server started")
@@ -625,56 +673,57 @@ class SolidLanguageServer(ABC):
uri = pathlib.Path(absolute_file_path).as_uri()
if uri in self.open_file_buffers:
assert self.open_file_buffers[uri].uri == uri
assert self.open_file_buffers[uri].ref_count >= 1
fb = self.open_file_buffers[uri]
assert fb.uri == uri
assert fb.ref_count >= 1
self.open_file_buffers[uri].ref_count += 1
yield self.open_file_buffers[uri]
self.open_file_buffers[uri].ref_count -= 1
fb.ref_count += 1
if open_in_ls:
fb.ensure_open_in_ls()
yield fb
fb.ref_count -= 1
else:
contents = FileUtils.read_file(absolute_file_path, self._encoding)
version = 0
language_id = self._get_language_id_for_file(relative_file_path)
self.open_file_buffers[uri] = LSPFileBuffer(
uri=uri, contents=contents, encoding=self._encoding, version=version, language_id=language_id, ref_count=1
fb = LSPFileBuffer(
uri=uri,
contents=contents,
encoding=self._encoding,
version=version,
language_id=language_id,
ref_count=1,
language_server=self,
open_in_ls=open_in_ls,
)
self.server.notify.did_open_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.LANGUAGE_ID: language_id,
LSPConstants.VERSION: 0,
LSPConstants.TEXT: contents,
}
}
)
yield self.open_file_buffers[uri]
self.open_file_buffers[uri].ref_count -= 1
self.open_file_buffers[uri] = fb
yield fb
fb.ref_count -= 1
if self.open_file_buffers[uri].ref_count == 0:
self.server.notify.did_close_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
self.open_file_buffers[uri].close()
del self.open_file_buffers[uri]
@contextmanager
def _open_file_context(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> Iterator[LSPFileBuffer]:
def _open_file_context(
self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True
) -> Iterator[LSPFileBuffer]:
"""
Internal context manager to open a file, optionally reusing an existing file buffer.
:param relative_file_path: the relative path of the file to open.
:param file_buffer: an optional existing file buffer to reuse.
:param open_in_ls: whether to open the file in the language server, sending the didOpen notification.
Set this to False to read the local file buffer without notifying the LS; the file can
be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer.
"""
if file_buffer is not None:
if open_in_ls:
file_buffer.ensure_open_in_ls()
yield file_buffer
else:
with self.open_file(relative_file_path) as fb:
with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb:
yield fb
def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position:
@@ -862,13 +911,13 @@ class SolidLanguageServer(ABC):
log.error("request_references called before Language Server started")
raise SolidLSPException("Language Server not started")
if not self._has_waited_for_cross_file_references:
# Some LS require waiting for a while before they can return cross-file references.
# This is a workaround for such LS that don't have a reliable "finished initializing" signal.
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
with self.open_file(relative_file_path):
if not self._has_waited_for_cross_file_references:
# Some LS require waiting for a while before they can return cross-file references.
# This is a workaround for such LS that don't have a reliable "finished initializing" signal.
# The waiting has to happen after at least one file was opened in the ls
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
try:
response = self._send_references_request(relative_file_path, line=line, column=column)
except Exception as e:
@@ -1005,7 +1054,6 @@ class SolidLanguageServer(ABC):
num_retries = 0
while response is None or (response["isIncomplete"] and num_retries < 30): # type: ignore
self.completions_available.wait()
response = self.server.send.completion(completion_params)
if isinstance(response, list):
response = {"items": response, "isIncomplete": False}
@@ -1130,7 +1178,7 @@ class SolidLanguageServer(ABC):
where the parent attribute will be the file symbol which in turn may have a package symbol as parent.
If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead.
"""
with self._open_file_context(relative_file_path, file_buffer) as file_data:
with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data:
# check if the desired result is cached
cache_key = relative_file_path
file_hash_and_result = self._document_symbols_cache.get(cache_key)
@@ -1145,6 +1193,7 @@ class SolidLanguageServer(ABC):
log.debug("No cache hit for document symbols in %s", relative_file_path)
# no cached result: request the root symbols from the language server
file_data.ensure_open_in_ls()
root_symbols = self._request_document_symbols(relative_file_path, file_data)
if root_symbols is None:
+7 -1
View File
@@ -41,6 +41,7 @@ class Language(str, Enum):
RUBY = "ruby"
DART = "dart"
CPP = "cpp"
CPP_CCLS = "cpp_ccls"
PHP = "php"
R = "r"
PERL = "perl"
@@ -129,6 +130,7 @@ class Language(str, Enum):
self.YAML,
self.TOML,
self.GROOVY,
self.CPP_CCLS,
}
def __str__(self) -> str:
@@ -175,7 +177,7 @@ class Language(str, Enum):
return FilenameMatcher("*.rb", "*.erb")
case self.RUBY_SOLARGRAPH:
return FilenameMatcher("*.rb")
case self.CPP:
case self.CPP | self.CPP_CCLS:
return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx")
case self.KOTLIN:
return FilenameMatcher("*.kt", "*.kts")
@@ -309,6 +311,10 @@ class Language(str, Enum):
from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer
return ClangdLanguageServer
case self.CPP_CCLS:
from solidlsp.language_servers.ccls_language_server import CCLS
return CCLS
case self.PHP:
from solidlsp.language_servers.intelephense import Intelephense
+3
View File
@@ -414,6 +414,9 @@ class PlatformUtils:
class SymbolUtils:
@staticmethod
def symbol_tree_contains_name(roots: list[UnifiedSymbolInformation], name: str) -> bool:
"""
Check if any symbol in the tree has a name matching the given name.
"""
for symbol in roots:
if symbol["name"] == name:
return True
+17 -1
View File
@@ -1,5 +1,6 @@
import logging
import os
import shutil as _sh
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
@@ -35,6 +36,7 @@ class LanguageParamRequest:
_LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
Language.CPP_CCLS: Language.CPP,
Language.PHP_PHPACTOR: Language.PHP,
Language.PYTHON_JEDI: Language.PYTHON,
Language.RUBY_SOLARGRAPH: Language.RUBY,
@@ -60,7 +62,11 @@ def _create_ls(
gitignore_parser = GitignoreParser(str(repo_path))
for spec in gitignore_parser.get_ignore_specs():
ignored_paths.extend(spec.patterns)
config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication)
config = LanguageServerConfig(
code_language=language,
ignored_paths=ignored_paths,
trace_lsp_communication=trace_lsp_communication,
)
effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir
return SolidLanguageServer.create(
config,
@@ -220,6 +226,16 @@ def _determine_disabled_languages() -> list[Language]:
if not clojure_tests_enabled:
result.append(Language.CLOJURE)
# Disable CPP_CCLS tests if ccls is not available
ccls_tests_enabled = _sh.which("ccls") is not None
if not ccls_tests_enabled:
result.append(Language.CPP_CCLS)
# Disable CPP (clangd) tests if clangd is not available
clangd_tests_enabled = _sh.which("clangd") is not None
if not clangd_tests_enabled:
result.append(Language.CPP)
al_tests_enabled = True
if not al_tests_enabled:
result.append(Language.AL)
+6
View File
@@ -0,0 +1,6 @@
#include "b.hpp"
int main() {
int x = add(3, 4);
return x;
}
+5
View File
@@ -0,0 +1,5 @@
#include "b.hpp"
int add(int a, int b) {
return a + b;
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
int add(int a, int b);
@@ -0,0 +1,12 @@
[
{
"directory": ".",
"command": "g++ -std=c++17 -I . -c a.cpp",
"file": "a.cpp"
},
{
"directory": ".",
"command": "g++ -std=c++17 -I . -c b.cpp",
"file": "b.cpp"
}
]
+3 -3
View File
@@ -315,8 +315,8 @@ class TestFindProjectRoot:
finally:
os.chdir(original_cwd)
def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir):
"""Test falls back to CWD when no markers exist within boundary."""
def test_falls_back_to_none_when_no_markers(self, temp_project_dir):
"""Test falls back to None when no markers exist within boundary."""
subdir = os.path.join(temp_project_dir, "src")
os.makedirs(subdir)
@@ -324,7 +324,7 @@ class TestFindProjectRoot:
try:
os.chdir(subdir)
result = find_project_root(root=temp_project_dir)
assert os.path.samefile(result, subdir)
assert result is None
finally:
os.chdir(original_cwd)
+3
View File
@@ -36,6 +36,7 @@ def serena_config():
Language.CLOJURE,
Language.FSHARP,
Language.POWERSHELL,
Language.CPP_CCLS,
]:
repo_path = get_repo_path(language)
if repo_path.exists():
@@ -118,6 +119,7 @@ class TestSerenaAgent:
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp),
],
indirect=["serena_agent"],
)
@@ -186,6 +188,7 @@ class TestSerenaAgent:
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp),
],
indirect=["serena_agent"],
)
View File
Whitespace-only changes.
+144
View File
@@ -0,0 +1,144 @@
"""
Basic tests for C/C++ language server integration (clangd and ccls).
This module tests both Language.CPP (clangd) and Language.CPP_CCLS (ccls)
using the same test repository. Tests are skipped if the respective language
server is not available.
"""
import os
import pathlib
import shutil
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
def _ccls_available() -> bool:
return shutil.which("ccls") is not None
_cpp_servers: list[Language] = [Language.CPP]
if _ccls_available():
_cpp_servers.append(Language.CPP_CCLS)
@pytest.mark.cpp
@pytest.mark.skipif(not _cpp_servers, reason="No C++ language server (clangd or ccls) available")
class TestCppLanguageServer:
"""Tests for C/C++ language servers (clangd and ccls)."""
@pytest.mark.parametrize("language_server", _cpp_servers, indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
"""Test that symbol tree contains expected functions."""
symbols = language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "Function 'add' not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "Function 'main' not found in symbol tree"
@pytest.mark.parametrize("language_server", _cpp_servers, indirect=True)
def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test document symbols for a.cpp."""
file_path = os.path.join("a.cpp")
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
# Flatten nested structure if needed
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
names = [s.get("name") for s in symbol_list]
assert "main" in names, f"Expected 'main' in document symbols, got: {names}"
@pytest.mark.parametrize("language_server", _cpp_servers, indirect=True)
def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None:
"""Test finding references to 'add' function across files."""
# Locate 'add' in b.cpp
file_path = os.path.join("b.cpp")
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
add_symbol = None
for sym in symbol_list:
if sym.get("name") == "add":
add_symbol = sym
break
assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp"
sel_start = add_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
ref_files = [ref.get("relativePath", "") for ref in refs]
assert any("a.cpp" in ref_file for ref_file in ref_files), f"Should find reference in a.cpp, {refs=}"
# Verify second call returns same results (stability check)
def _ref_key(ref: dict) -> tuple:
rp = ref.get("relativePath", "")
rng = ref.get("range") or {}
s = rng.get("start") or {}
e = rng.get("end") or {}
return (
rp,
s.get("line", -1),
s.get("character", -1),
e.get("line", -1),
e.get("character", -1),
)
refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls"
@pytest.mark.parametrize("language_server", _cpp_servers, indirect=True)
@pytest.mark.xfail(
strict=True,
reason=("Both clangd and ccls do not support cross-file references for newly created files that were never opened by the LS."),
)
def test_find_references_in_newly_written_file(self, language_server: SolidLanguageServer) -> None:
# Create a new file that references the 'add' function from b.cpp
new_file_path = os.path.join("temp_new_file.cpp")
new_file_abs_path = os.path.join(language_server.repository_root_path, new_file_path)
try:
# Write the new file with a reference to add()
with open(new_file_abs_path, "w", encoding="utf-8") as f:
f.write(
"""
#include "b.hpp"
int use_add() {
int result = add(5, 3);
return result;
}
"""
)
# Open the new file so clangd knows about it
with language_server.open_file(new_file_path):
# Request document symbols to ensure the file is fully loaded by clangd
new_file_symbols = language_server.request_document_symbols(new_file_path).get_all_symbols_and_roots()
assert new_file_symbols, "New file should have symbols"
# Verify the file stays in open_file_buffers after the context exits
uri = pathlib.Path(new_file_abs_path).as_uri()
assert uri in language_server.open_file_buffers, "File should remain in open_file_buffers"
# Find the 'add' symbol in b.cpp
b_file_path = os.path.join("b.cpp")
symbols = language_server.request_document_symbols(b_file_path).get_all_symbols_and_roots()
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
add_symbol = None
for sym in symbol_list:
if sym.get("name") == "add":
add_symbol = sym
break
assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp"
# Request references for 'add'
sel_start = add_symbol["selectionRange"]["start"]
refs = language_server.request_references(b_file_path, sel_start["line"], sel_start["character"])
ref_files = [ref.get("relativePath", "") for ref in refs]
# Should find reference in the newly written file
assert any(
"temp_new_file.cpp" in ref_file for ref_file in ref_files
), f"Should find reference in newly written temp_new_file.cpp, {ref_files=}"
finally:
# Clean up the new file
if os.path.exists(new_file_abs_path):
os.remove(new_file_abs_path)
+56 -9
View File
@@ -56,6 +56,7 @@ class TestCSharpLanguageServer:
# Handle nested symbol structure
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
for sym in symbol_list:
# Symbol names are normalized to base form (e.g., "Add" not "Add(int, int) : int")
if sym.get("name") == "Add":
add_symbol = sym
break
@@ -82,13 +83,13 @@ class TestCSharpLanguageServer:
# 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
# Check for properties and methods (names are normalized to base form)
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
assert "Name" in symbol_names, "Name property not found"
assert "Age" in symbol_names, "Age property not found"
assert "Email" in symbol_names, "Email property not found"
assert "ToString" in symbol_names, "ToString method not found"
assert "IsAdult" in symbol_names, "IsAdult method not found"
@pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True)
def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None:
@@ -102,6 +103,7 @@ class TestCSharpLanguageServer:
subtract_symbol = None
for sym in symbol_list:
# Symbol names are normalized to base form (e.g., "Subtract" not "Subtract(int, int) : int")
if sym.get("name") == "Subtract":
subtract_symbol = sym
break
@@ -112,21 +114,66 @@ class TestCSharpLanguageServer:
sel_start = subtract_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
# Should find references in both Program.cs and Models/Person.cs
# Should find references where the method is called
ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs])
print(f"Found references: {refs}")
print(f"Reference files: {ref_files}")
# Check that we have references from both files
assert any("Program.cs" in ref_file for ref_file in ref_files), "Should find reference in Program.cs"
# Check that we have reference in Models/Person.cs where Calculator.Subtract is called
# Note: New Roslyn version doesn't include the definition itself as a reference (more correct behavior)
assert any(
os.path.join("Models", "Person.cs") in ref_file for ref_file in ref_files
), "Should find reference in Models/Person.cs where Calculator.Subtract is called"
assert len(refs) > 0, "Should find at least one reference"
# check for a second time, since the first call may trigger initialization and change the state of the LS
refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
assert refs_second_call == refs, "Second call to request_references should return the same results"
@pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True)
def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None:
"""Test that hover information is available and includes type information."""
file_path = os.path.join("Models", "Person.cs")
# Open the file first
language_server.open_file(file_path)
# Test 1: Hover over the Name property (line 6, column 23 - on "Name")
# Source: public string Name { get; set; }
hover_info = language_server.request_hover(file_path, 6, 23)
# Verify hover returns content
assert hover_info is not None, "Hover should return information for Name property"
assert isinstance(hover_info, dict), "Hover should be a dict"
assert "contents" in hover_info, "Hover should have contents"
contents = hover_info["contents"]
assert isinstance(contents, dict), "Hover contents should be a dict"
assert "value" in contents, "Hover contents should have value"
hover_text = contents["value"]
# Verify the hover contains property signature with type
assert "string" in hover_text, f"Hover should include 'string' type, got: {hover_text}"
assert "Name" in hover_text, f"Hover should include 'Name' property name, got: {hover_text}"
# Test 2: Hover over the IsAdult method (line 22, column 21 - on "IsAdult")
# Source: public bool IsAdult()
hover_method = language_server.request_hover(file_path, 22, 21)
# Verify method hover returns content
assert hover_method is not None, "Hover should return information for IsAdult method"
assert isinstance(hover_method, dict), "Hover should be a dict"
assert "contents" in hover_method, "Hover should have contents"
contents = hover_method["contents"]
assert isinstance(contents, dict), "Hover contents should be a dict"
assert "value" in contents, "Hover contents should have value"
method_hover_text = contents["value"]
# Verify the hover contains method signature with return type
assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}"
assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}"
@pytest.mark.csharp
class TestCSharpSolutionProjectOpening:
@@ -0,0 +1,290 @@
"""Tests for C# language server .NET runtime installation using official install scripts."""
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer
from solidlsp.settings import SolidLSPSettings
@pytest.mark.csharp
class TestDotNetInstallScript:
"""Test .NET runtime installation using Microsoft's official install scripts."""
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_install_dotnet_uses_bash_script_on_linux(
self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls
):
"""Test that Linux uses bash install script."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Linux"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_which.return_value = None # No system dotnet
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
# Create the file that urlretrieve would create
Path(path).touch()
# Also create the dotnet executable after script download
if "dotnet-install.sh" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_urlretrieve.side_effect = mock_retrieve
mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="")
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
_ = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Verify bash script was downloaded
assert mock_urlretrieve.called
script_url = mock_urlretrieve.call_args[0][0]
assert script_url == "https://dot.net/v1/dotnet-install.sh"
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_install_dotnet_uses_powershell_script_on_windows(
self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls
):
"""Test that Windows uses PowerShell install script."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Windows"
mock_platform_id.return_value = PlatformId.WIN_x64
mock_which.return_value = None # No system dotnet
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
# Create the file that urlretrieve would create
Path(path).touch()
# Also create the dotnet executable after script download
if "dotnet-install.ps1" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet.exe"
dotnet_exe.touch()
mock_urlretrieve.side_effect = mock_retrieve
mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="")
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
_ = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Verify PowerShell script was downloaded
assert mock_urlretrieve.called
script_url = mock_urlretrieve.call_args[0][0]
assert script_url == "https://dot.net/v1/dotnet-install.ps1"
def test_uses_system_dotnet_10_if_available(self):
"""Test that system .NET 10 is used if available instead of downloading."""
from solidlsp.ls_utils import PlatformId
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
# Mock dotnet --list-runtimes output showing .NET 10
mock_result = Mock()
mock_result.stdout = "Microsoft.NETCore.App 10.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
mock_subprocess.return_value = mock_result
with tempfile.TemporaryDirectory() as temp_dir:
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should have used system dotnet
assert dependency_provider._dotnet_path == "/usr/bin/dotnet"
def test_runtime_dependencies_no_longer_include_dotnet_downloads(self):
"""Test that _RUNTIME_DEPENDENCIES no longer includes manual .NET download entries."""
from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES
# Check that DotNetRuntime entries don't exist (we use install script instead)
dotnet_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "DotNetRuntime"]
# After our changes, there should be no DotNetRuntime entries
# (we'll use the install script instead)
assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script"
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_cached_dotnet_is_reused(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls):
"""Test that cached .NET installation is reused without re-downloading."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Linux"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_which.return_value = None
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
# Pre-create the cached dotnet executable
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should use cached dotnet without downloading install script
assert dependency_provider._dotnet_path == str(dotnet_exe)
assert not mock_urlretrieve.called, "Should not download install script when cached dotnet exists"
def test_rejects_dotnet_9_and_installs_dotnet_10(self):
"""Test that .NET 9 is rejected and .NET 10 install is triggered."""
from solidlsp.ls_utils import PlatformId
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_urlretrieve:
with patch("solidlsp.language_servers.csharp_language_server.platform.system") as mock_platform:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_platform.return_value = "Linux"
# Mock dotnet --list-runtimes output showing only .NET 9
mock_result = Mock()
mock_result.stdout = "Microsoft.NETCore.App 9.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
call_count = 0
def subprocess_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: dotnet --list-runtimes returns .NET 9
return mock_result
else:
# Second call: install script execution
return Mock(returncode=0, stdout="", stderr="")
mock_subprocess.side_effect = subprocess_side_effect
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
Path(path).touch()
if "dotnet-install.sh" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_urlretrieve.side_effect = mock_retrieve
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should have installed .NET 10 (not used system .NET 9)
assert dependency_provider._dotnet_path == str(Path(temp_dir) / "dotnet-runtime-10.0" / "dotnet")
assert mock_urlretrieve.called, "Should download install script when .NET 9 found"
def test_accepts_dotnet_11_and_higher(self):
"""Test that .NET 11+ versions are accepted for forward compatibility."""
from solidlsp.ls_utils import PlatformId
for version in ["11.0.1", "12.0.0", "15.3.2"]:
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
# Mock dotnet --list-runtimes output showing .NET 11+
mock_result = Mock()
mock_result.stdout = f"Microsoft.NETCore.App {version} [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
mock_subprocess.return_value = mock_result
with tempfile.TemporaryDirectory() as temp_dir:
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should use system dotnet for .NET 11+
assert dependency_provider._dotnet_path == "/usr/bin/dotnet", f"Should accept .NET {version}"
@@ -0,0 +1,118 @@
"""Tests for C# language server NuGet package download from NuGet.org."""
import tempfile
from unittest.mock import patch
import pytest
from solidlsp.language_servers.common import RuntimeDependency
from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer
from solidlsp.settings import SolidLSPSettings
@pytest.mark.csharp
class TestNuGetOrgDownload:
"""Test downloading Roslyn language server packages from NuGet.org."""
def test_download_nuget_package_uses_direct_url(self):
"""Test that _download_nuget_package uses the URL from RuntimeDependency directly."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create a RuntimeDependency with a NuGet.org URL
test_dependency = RuntimeDependency(
id="TestPackage",
description="Test package from NuGet.org",
package_name="roslyn-language-server.linux-x64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4",
platform_id="linux-x64",
archive_type="nupkg",
binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
extract_path="content/LanguageServer/linux-x64",
)
# Mock the dependency provider
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Mock urllib.request.urlretrieve to capture the URL being used
with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_retrieve:
with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"):
try:
dependency_provider._download_nuget_package(test_dependency)
except Exception:
# Expected to fail since we're mocking, but we want to check the URL
pass
# Verify that urlretrieve was called with the NuGet.org URL
assert mock_retrieve.called, "urlretrieve should be called"
called_url = mock_retrieve.call_args[0][0]
assert called_url == test_dependency.url, f"Should use URL from RuntimeDependency: {test_dependency.url}"
assert "nuget.org" in called_url, "Should use NuGet.org URL"
assert "azure" not in called_url.lower(), "Should not use Azure feed"
def test_runtime_dependencies_use_nuget_org_urls(self):
"""Test that _RUNTIME_DEPENDENCIES are configured with NuGet.org URLs."""
from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES
# Check language server dependencies
lang_server_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "CSharpLanguageServer"]
assert len(lang_server_deps) == 6, "Should have 6 language server platform variants"
for dep in lang_server_deps:
# Verify package name uses roslyn-language-server
assert dep.package_name is not None, f"Package name should be set for {dep.platform_id}"
assert dep.package_name.startswith(
"roslyn-language-server."
), f"Package name should start with 'roslyn-language-server.' but got: {dep.package_name}"
# Verify version is the newer NuGet.org version
assert dep.package_version == "5.5.0-2.26078.4", f"Should use NuGet.org version 5.5.0-2.26078.4, got: {dep.package_version}"
# Verify URL points to NuGet.org
assert dep.url is not None, f"URL should be set for {dep.platform_id}"
assert "nuget.org" in dep.url, f"URL should point to nuget.org, got: {dep.url}"
assert "azure" not in dep.url.lower(), f"URL should not point to Azure feed, got: {dep.url}"
def test_download_method_does_not_call_azure_feed(self):
"""Test that the new download method does not attempt to access Azure feed."""
with tempfile.TemporaryDirectory() as temp_dir:
test_dependency = RuntimeDependency(
id="TestPackage",
description="Test package",
package_name="roslyn-language-server.linux-x64",
package_version="5.5.0-2.26078.4",
url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4",
platform_id="linux-x64",
archive_type="nupkg",
binary_name="test.dll",
)
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Mock urllib.request.urlopen to track if Azure feed is accessed
with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlopen") as mock_urlopen:
with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve"):
with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"):
try:
dependency_provider._download_nuget_package(test_dependency)
except Exception:
pass
# Verify that urlopen was NOT called (no service index lookup)
assert not mock_urlopen.called, "Should not call urlopen for Azure service index lookup"
@@ -48,7 +48,7 @@ class TestRustAnalyzerDetection:
from solidlsp.language_servers.rust_analyzer import RustAnalyzer
# Mock rustup to be unavailable
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
# Mock common locations to NOT exist
with patch("os.path.isfile", return_value=False):
# Mock PATH to have rust-analyzer
@@ -60,7 +60,7 @@ class TestRustAnalyzerDetection:
return path == "/custom/bin/rust-analyzer"
with patch("os.path.isfile", side_effect=selective_isfile):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == "/custom/bin/rust-analyzer"
mock_which.assert_called_with("rust-analyzer")
@@ -86,11 +86,11 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == "/opt/homebrew/bin/rust-analyzer"
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == "/opt/homebrew/bin/rust-analyzer"
@@ -115,11 +115,11 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == "/usr/local/bin/rust-analyzer"
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == "/usr/local/bin/rust-analyzer"
@@ -146,11 +146,11 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == cargo_path
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == cargo_path
@@ -169,11 +169,11 @@ class TestRustAnalyzerDetection:
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(
RustAnalyzer,
RustAnalyzer.DependencyProvider,
"_get_rust_analyzer_via_rustup",
return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer",
):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert "rustup" in result or ".rustup" in result
@@ -193,10 +193,10 @@ class TestRustAnalyzerDetection:
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None):
with pytest.raises(RuntimeError) as exc_info:
RustAnalyzer._ensure_rust_analyzer_installed()
RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
error_message = str(exc_info.value)
# Error should list the locations that were searched (Unix paths)
@@ -222,11 +222,11 @@ class TestRustAnalyzerDetection:
rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer"
# Rustup has rust-analyzer, PATH also has it, common locations also exist
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=rustup_path):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=rustup_path):
with patch("shutil.which", return_value="/custom/path/rust-analyzer"):
with patch("os.path.isfile", return_value=True):
with patch("os.access", return_value=True):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
# Should use rustup version, NOT PATH or common locations
assert result == rustup_path
@@ -260,11 +260,11 @@ class TestRustAnalyzerDetection:
def mock_isfile_for_cargo(path):
return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")]
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile_for_cargo):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
# Should skip non-executable Homebrew and use cargo
assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer")
@@ -292,12 +292,12 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == scoop_path
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("platform.system", return_value="Windows"):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == scoop_path
@@ -324,12 +324,12 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == cargo_path
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch("platform.system", return_value="Windows"):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
with patch("os.access", side_effect=mock_access):
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == cargo_path
@@ -349,10 +349,10 @@ class TestRustAnalyzerDetection:
with patch("platform.system", return_value="Windows"):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None):
with pytest.raises(RuntimeError) as exc_info:
RustAnalyzer._ensure_rust_analyzer_installed()
RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
error_message = str(exc_info.value)
# Error should suggest Windows-specific package managers
@@ -377,13 +377,13 @@ class TestRustAnalyzerDetection:
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup") as mock_rustup_path:
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup") as mock_rustup_path:
# First call returns None (not installed), second returns path (after install)
mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"]
with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"
mock_run.assert_called_once()
@@ -407,14 +407,14 @@ class TestRustAnalyzerDetection:
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available"
)
with pytest.raises(RuntimeError) as exc_info:
RustAnalyzer._ensure_rust_analyzer_installed()
RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
error_message = str(exc_info.value)
# Error should provide helpful installation instructions
@@ -439,12 +439,12 @@ class TestRustAnalyzerDetection:
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", return_value=False):
with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with pytest.raises(RuntimeError) as exc_info:
RustAnalyzer._ensure_rust_analyzer_installed()
RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
error_message = str(exc_info.value)
# Error should indicate rust-analyzer is not available and provide install instructions
@@ -482,7 +482,7 @@ class TestRustAnalyzerDetectionIntegration:
if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths):
pytest.skip("rust-analyzer not installed on this system")
result = RustAnalyzer._ensure_rust_analyzer_installed()
result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed()
assert result is not None
assert os.path.isfile(result)