mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 20:15:56 +00:00
Merge branch 'feature/support-phpactor' of ssh://github.com/takeokunn/serena into feature/support-phpactor
# Conflicts: # test/conftest.py
This commit is contained in:
69 files changed
+1685
-552
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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":
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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!
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "b.hpp"
|
||||
|
||||
int main() {
|
||||
int x = add(3, 4);
|
||||
return x;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#include "b.hpp"
|
||||
|
||||
int add(int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Whitespace-only changes.
@@ -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,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)
|
||||
|
||||
Reference in new issue
Block a user