mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 16:18:03 +00:00
Merge branch 'main' into hover_budget
# Conflicts: # src/serena/config/serena_config.py # src/serena/symbol.py # src/serena/tools/symbol_tools.py # src/solidlsp/ls.py # test/serena/test_symbol.py
This commit is contained in:
commit
4086ce5e6e
110 files changed
+4445
-1188
No files matched your search
@@ -7,3 +7,9 @@ docs/.jupyter_cache
|
||||
docs/_build
|
||||
coverage.xml
|
||||
docker_build_and_run.sh
|
||||
|
||||
# Python artifacts (prevents Docker build conflicts when .venv exists locally)
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests on CI
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -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:
|
||||
|
||||
@@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
|
||||
* Language support:
|
||||
|
||||
* **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+)
|
||||
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
|
||||
* **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md))
|
||||
* **Add support for Julia** via LanguageServer.jl
|
||||
@@ -61,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).
|
||||
@@ -29,9 +29,13 @@ Some languages require additional installations or setup steps, as noted.
|
||||
|
||||
* **AL**
|
||||
* **Bash**
|
||||
* **C#**
|
||||
* **C#**
|
||||
(by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows;
|
||||
set language to `csharp_omnisharp` to use OmiSharp instead)
|
||||
* **C/C++**
|
||||
(you may experience issues with finding references, we are working on it)
|
||||
(by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`);
|
||||
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**
|
||||
@@ -47,7 +51,7 @@ Some languages require additional installations or setup steps, as noted.
|
||||
* **Go**
|
||||
(requires installation of `gopls`)
|
||||
* **Groovy**
|
||||
(requires local groovy-language-server.jar setup via GROOVY_LS_JAR_PATH or configuration)
|
||||
(requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration)
|
||||
* **Haskell**
|
||||
(automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects)
|
||||
* **Java**
|
||||
@@ -57,20 +61,21 @@ Some languages require additional installations or setup steps, as noted.
|
||||
(uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear)
|
||||
* **Lua**
|
||||
* **Markdown**
|
||||
(must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects)
|
||||
(must explicitly enable language `markdown`, primarily useful for documentation-heavy projects)
|
||||
* **Nix**
|
||||
(requires nixd installation)
|
||||
* **Pascal**
|
||||
(Free Pascal/Lazarus; automatically downloads pasls binary; set PP and FPCDIR environment variables for source navigation)
|
||||
* **Perl**
|
||||
* **Pascal**
|
||||
(uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation)
|
||||
* **Perl**
|
||||
(requires installation of Perl::LanguageServer)
|
||||
* **PHP**
|
||||
(uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features)
|
||||
(by default, uses the Intelephense language server (language `php`), set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features;
|
||||
we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+)
|
||||
* **Python**
|
||||
* **R**
|
||||
(requires installation of the `languageserver` R package)
|
||||
* **Ruby**
|
||||
(by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp), specify ruby_solargraph as your language to use the previous solargraph based implementation)
|
||||
(by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp) (language `ruby`); use language `ruby_solargraph` to use Solargraph instead.)
|
||||
* **Rust**
|
||||
(requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
|
||||
* **Scala**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -73,6 +73,66 @@ If you need to work on multiple projects in the same agent session, create a mon
|
||||
containing all the projects and open that folder in both Serena and your IDE.
|
||||
:::
|
||||
|
||||
## Advanced Usage and Configuration
|
||||
|
||||
### Using Serena with Multi-Module Projects
|
||||
|
||||
JetBrains IDEs support *multi-module projects*, where a project can reference other projects as modules.
|
||||
Serena, however, requires that a project is self-contained within a single root folder.
|
||||
There has to be a one-to-one relationship between the project root folder and the folder that is open in the IDE.
|
||||
|
||||
Therefore, to get a multi-module setup working with Serena, the recommended approach is to create a **monorepo folder**,
|
||||
i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder in both Serena and your IDE.
|
||||
|
||||
You do not necessarily need to physically move your projects into a common parent folder;
|
||||
you can also use symbolic links to achieve the same effect
|
||||
(i.e. use `mklink` on Windows or `ln` on Linux/macOS to link the project folders into a common parent folder).
|
||||
|
||||
### Using Serena with Windows Subsystem for Linux (WSL)
|
||||
|
||||
JetBrains IDEs have built-in support for WSL, allowing you to run the IDE on Windows while working with code in the WSL environment.
|
||||
The Serena JetBrains plugin works seamlessly in this setup as well. You have two options:
|
||||
|
||||
* **Windows-centric approach** (keeping things mainly in Windows)
|
||||
* run both Serena and the IDE on Windows
|
||||
* keep your project in the Windows file system, access it in WSL via `/mnt/`
|
||||
(optionally linking the folder where you need it to be in the WSL file system)
|
||||
|
||||
* **WSL-centric approach** (keeping things mainly in WSL)
|
||||
* run Serena in WSL (while the IDE runs in Windows)
|
||||
* make sure Serena in WSL can communicate with services running on Windows.
|
||||
This will normally be the case when using mirrored networking;
|
||||
otherwise, you can configure `jetbrains_plugin_server_address` in your [serena_config.yml](050_configuration) and,
|
||||
if necessary, configure the listen address of the Serena plugin in the IDE (Settings / Tools / Serena).
|
||||
|
||||
The WSL-centric approach has the downside that the WSL file system cannot be efficiently monitored by the IDE,
|
||||
which can result in slower performance, especially for larger projects. If this is a problem, consider
|
||||
* using the Windows-centric approach instead, or
|
||||
* disabling the automatic synchronisation in the plugin and manually triggering synchronisation when needed (see below).
|
||||
|
||||
## Serena Plugin Configuration Options
|
||||
|
||||
You can configure plugin options in the IDE under Settings / Tools / Serena.
|
||||
|
||||
* **Listen address** (default: `127.0.0.1`)
|
||||
the address the plugin's server listens on.
|
||||
The default will work as long as Serena is running on the same machine (or on a virtual machine using mirrored networking).
|
||||
But if the Serena MCP server is running on a different machine, configure the listen address to ensure that connections are possible.
|
||||
You can use `0.0.0.0` to listen on all interfaces (but be aware of the security implications of doing so).
|
||||
|
||||
* **Sync file system before every operation** (default: enabled)
|
||||
whether to synchronise the file system state before processing requests from Serena.
|
||||
This is important to ensure that the plugin does not read stale data, but it can have a performance impact,
|
||||
especially when using slow file systems (e.g. WSL file system while the IDE is running on Windows).
|
||||
Note, however, that without synchronisation being forced by the Serena plugin, you will have to ensure synchronisation yourself.
|
||||
Operations that apply changes to files in your project that are *not* made either in the IDE itself or by Serena may not be seen by the IDE.
|
||||
Normally, the IDE synchronises automatically when it has the focus, using file watchers to achieve this (though this may or may not work reliably for the WSL file system).
|
||||
Also, if you are working primarily in another application (e.g. AI chat), the IDE may not have the focus frequently.
|
||||
So when external changes are made to your project, you will have to either give the IDE the focus (if that works) or trigger a sync manually (right-click root folder / Reload from Disk).
|
||||
Further, note that even an edit made using, for example, Claude Code's internal editing tools would count as an external modification.
|
||||
Only Serena's editing tools are "JetBrains-aware" and will tell the IDE to update the state of the edited file.
|
||||
So if you are making AI-based edits using tools other than Serena's tools, do make sure that the lack of synchronisation is not a problem if you decide to disable this option.
|
||||
|
||||
## Usage with Other Editors
|
||||
|
||||
We realize that not everyone uses a JetBrains IDE as their main code editor.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,8 @@ Serena is configured in using a multi-layered approach:
|
||||
* **contexts and modes** for composable configuration, which can be enabled on a case-by-case basis (see below)
|
||||
* **command-line parameters** passed to the `start-mcp-server` server command (overriding/extending configured settings)
|
||||
See [MCP Server Command-Line Arguments](mcp-args) for further information.
|
||||
|
||||
|
||||
(global-config)=
|
||||
## Global Configuration
|
||||
|
||||
The global configuration file allows you to change general settings and defaults that will apply to all projects unless overridden.
|
||||
@@ -173,7 +174,7 @@ You can change this location by setting the `SERENA_HOME` environment variable t
|
||||
(ls-specific-settings)=
|
||||
### Language Server-Specific Settings
|
||||
|
||||
:::note
|
||||
:::{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.
|
||||
:::
|
||||
@@ -209,9 +210,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: `bash`, `clojure`, `cpp`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `toml`, `typescript`, `yaml`.
|
||||
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,14 @@
|
||||
# Logs
|
||||
|
||||
It can be vital to understand what is happening in Serena, especially when something goes wrong.
|
||||
|
||||
You can access Serena's live logs via
|
||||
* the [Serena dashboard](060_dashboard) (tab "Logs")
|
||||
* the [GUI tool](060_dashboard).
|
||||
|
||||
Additionally, logs are persisted in the Serena home directory, which, by default, is located at
|
||||
* `%USERPROFILE%\.serena\logs` on Windows
|
||||
* `~/.serena/logs` on Linux and macOS.
|
||||
|
||||
You can adjust the log level via the [global configuration](global-config).
|
||||
You additionally have the option of enabling full tracing of language server communication (mostly for development purposes).
|
||||
@@ -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
|
||||
@@ -63,5 +63,41 @@ These instructions cover the setup for projects that use sbt as the build tool,
|
||||
Notes:
|
||||
- Ensure you completed the manual or auto‑import steps so that the build is compiled and indexed; otherwise, code navigation and references may be incomplete until the first successful compile.
|
||||
|
||||
---
|
||||
## Running Multiple Metals Instances
|
||||
|
||||
Serena can run alongside other Metals instances (e.g., VS Code with Metals extension) on the same project. This is **fully supported** by Metals via H2 AUTO_SERVER mode.
|
||||
|
||||
### How It Works
|
||||
|
||||
Metals uses an H2 database (`.metals/metals.mv.db`) to cache semantic information. When multiple Metals instances run on the same project:
|
||||
|
||||
- **H2 AUTO_SERVER**: The first instance becomes the TCP server; subsequent instances connect as clients
|
||||
- **Bloop Build Server**: All instances share a single Bloop process (port 8212)
|
||||
- **Compilation Results**: Shared via Bloop — no duplicate compilation
|
||||
|
||||
### Stale Lock Detection
|
||||
|
||||
If a Metals process crashes without proper cleanup, it may leave a stale lock file (`.metals/metals.mv.db.lock.db`). This can prevent proper AUTO_SERVER coordination, causing new instances to fall back to in-memory database mode (degraded experience).
|
||||
|
||||
Serena automatically detects and handles stale locks based on your configuration:
|
||||
|
||||
```yaml
|
||||
# ~/.serena/serena_config.yml or .serena/project.yml
|
||||
ls_specific_settings:
|
||||
scala:
|
||||
on_stale_lock: "auto-clean" # auto-clean | warn | fail
|
||||
log_multi_instance_notice: true # Log info when another Metals detected
|
||||
```
|
||||
|
||||
#### Stale Lock Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `auto-clean` | **(Default, Recommended)** Automatically removes stale lock files and proceeds normally. |
|
||||
| `warn` | Logs a warning but proceeds. Metals may use in-memory database (slower). |
|
||||
| `fail` | Raises an error and refuses to start. Useful for debugging lock issues. |
|
||||
|
||||
---
|
||||
## Reference
|
||||
- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt)
|
||||
- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt)
|
||||
+9
-3
@@ -114,7 +114,11 @@ warn_redundant_casts = true
|
||||
warn_unreachable = true
|
||||
warn_unused_configs = true
|
||||
warn_unused_ignores = false
|
||||
exclude = "^build/|^docs/"
|
||||
exclude = "^build/|^docs/|^test/resources/"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "test.*"
|
||||
disallow_untyped_defs = false # untyped defs are OK in tests
|
||||
|
||||
[tool.poe.env]
|
||||
PYDEVD_DISABLE_FILE_VALIDATION = "1"
|
||||
@@ -137,8 +141,9 @@ _black_format = "black src scripts test"
|
||||
_ruff_format = "ruff check --fix src scripts test"
|
||||
lint = ["_black_check", "_ruff_check"]
|
||||
format = ["_ruff_format", "_black_format"]
|
||||
_mypy = "mypy src/serena src/solidlsp"
|
||||
type-check = ["_mypy"]
|
||||
_mypy_core = "mypy src/serena src/solidlsp"
|
||||
_mypy_test = "mypy --disable-error-code no-untyped-def test"
|
||||
type-check = ["_mypy_core", "_mypy_test"]
|
||||
# docs
|
||||
_autogen_rst = "python docs/autogen_rst.py"
|
||||
_sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going"
|
||||
@@ -313,6 +318,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+)",
|
||||
|
||||
+1
-1
@@ -522,7 +522,7 @@ class SerenaAgent:
|
||||
|
||||
:param mode_names: List of mode names or paths to use
|
||||
"""
|
||||
self._active_modes.apply(ModeSelectionDefinition(default_modes=mode_names))
|
||||
self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names)
|
||||
self._update_active_modes_and_tools()
|
||||
|
||||
log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}")
|
||||
|
||||
+20
-15
@@ -39,6 +39,7 @@ from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsO
|
||||
from serena.util.dataclass import get_dataclass_default
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
from solidlsp.util.subprocess_util import subprocess_kwargs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -55,15 +56,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 +86,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 +273,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)
|
||||
@@ -636,7 +638,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
lvl = logging.getLevelNamesMapping()[log_level.upper()]
|
||||
logging.configure(level=lvl)
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
proj = registered_project.get_project_instance()
|
||||
proj = registered_project.get_project_instance(serena_config=serena_config)
|
||||
click.echo(f"Indexing symbols in {proj} …")
|
||||
ls_mgr = proj.create_language_server_manager(
|
||||
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
|
||||
@@ -689,7 +691,8 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
:param path: The path to check.
|
||||
:param project: The path to the project directory, defaults to the current working directory.
|
||||
"""
|
||||
proj = Project.load(os.path.abspath(project))
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
|
||||
if os.path.isabs(path):
|
||||
path = os.path.relpath(path, start=proj.project_root)
|
||||
is_ignored = proj.is_ignored_path(path)
|
||||
@@ -711,7 +714,8 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
:param project: path to the project directory, defaults to the current working directory.
|
||||
:param verbose: if set, prints detailed information about the indexed symbols.
|
||||
"""
|
||||
proj = Project.load(os.path.abspath(project))
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
|
||||
if os.path.isabs(file):
|
||||
file = os.path.relpath(file, start=proj.project_root)
|
||||
if proj.is_ignored_path(file, ignore_non_source_files=True):
|
||||
@@ -748,7 +752,8 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
|
||||
logging.configure(level=logging.INFO)
|
||||
project_path = os.path.abspath(project)
|
||||
proj = Project.load(project_path)
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
proj = Project.load(project_path, serena_config=serena_config)
|
||||
|
||||
# Create log file with timestamp
|
||||
timestamp = datetime_tag()
|
||||
@@ -762,8 +767,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
|
||||
@@ -805,9 +812,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
return
|
||||
|
||||
# Extract suitable symbol (prefer class or function over variables)
|
||||
# LSP symbol kinds: 5=class, 12=function, 6=method, 9=constructor
|
||||
preferred_kinds = [5, 12, 6, 9] # class, function, method, constructor
|
||||
|
||||
preferred_kinds = {SymbolKind.Class.name, SymbolKind.Function.name, SymbolKind.Method.name, SymbolKind.Constructor.name}
|
||||
selected_symbol = None
|
||||
for symbol in overview_data:
|
||||
if symbol.get("kind") in preferred_kinds:
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Iterable, Iterator, Reversible
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Generic, Optional, TypeVar, cast
|
||||
|
||||
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol
|
||||
from solidlsp import SolidLanguageServer, ls_types
|
||||
from solidlsp.ls import LSPFileBuffer
|
||||
@@ -13,7 +14,6 @@ from solidlsp.ls_utils import PathUtils, TextUtils
|
||||
|
||||
from .constants import DEFAULT_SOURCE_FILE_ENCODING
|
||||
from .project import Project
|
||||
from .tools.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent import SerenaAgent
|
||||
@@ -81,8 +81,9 @@ class CodeEditor(Generic[TSymbol], ABC):
|
||||
|
||||
def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None:
|
||||
abs_path = os.path.join(self.project_root, edited_file.relative_path)
|
||||
new_contents = edited_file.get_contents()
|
||||
with open(abs_path, "w", encoding=self.encoding) as f:
|
||||
f.write(edited_file.get_contents())
|
||||
f.write(new_contents)
|
||||
|
||||
@abstractmethod
|
||||
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol:
|
||||
|
||||
@@ -8,7 +8,7 @@ import shutil
|
||||
from collections.abc import Iterator, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
@@ -415,12 +415,18 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi
|
||||
|
||||
|
||||
class RegisteredProject(ToStringMixin):
|
||||
def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
project_root: str,
|
||||
project_config: "ProjectConfig",
|
||||
project_instance: Optional["Project"] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Represents a registered project in the Serena configuration.
|
||||
|
||||
:param project_root: the root directory of the project
|
||||
:param project_config: the configuration of the project
|
||||
:param project_instance: an existing project instance (if already loaded)
|
||||
"""
|
||||
self.project_root = Path(project_root).resolve()
|
||||
self.project_config = project_config
|
||||
@@ -456,9 +462,9 @@ 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":
|
||||
def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project":
|
||||
"""
|
||||
Returns the project instance for this registered project, loading it if necessary.
|
||||
"""
|
||||
@@ -466,7 +472,11 @@ class RegisteredProject(ToStringMixin):
|
||||
from ..project import Project
|
||||
|
||||
with LogTime(f"Loading project instance for {self}", logger=log):
|
||||
self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config)
|
||||
self._project_instance = Project(
|
||||
project_root=str(self.project_root),
|
||||
project_config=self.project_config,
|
||||
serena_config=serena_config,
|
||||
)
|
||||
return self._project_instance
|
||||
|
||||
|
||||
@@ -511,6 +521,10 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
|
||||
ls_specific_settings: dict = field(default_factory=dict)
|
||||
"""Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info."""
|
||||
|
||||
ignored_paths: list[str] = field(default_factory=list)
|
||||
"""List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **.
|
||||
These patterns are merged additively with each project's own ignored_paths."""
|
||||
|
||||
hover_budget: float = 10.0
|
||||
"""
|
||||
Time budget (seconds) for LSP hover requests when tools request include_info.
|
||||
@@ -537,6 +551,19 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
|
||||
CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend"}
|
||||
|
||||
# *** methods ***
|
||||
@classmethod
|
||||
def get_config_file_creation_date(cls) -> datetime | None:
|
||||
"""
|
||||
:return: the creation date of the configuration file, or None if the configuration file does not exist
|
||||
"""
|
||||
config_file_path = cls._determine_config_file_path()
|
||||
if not os.path.exists(config_file_path):
|
||||
return None
|
||||
|
||||
# for unix systems st_ctime is the inode change time (change of metadata),
|
||||
# which is good enough for our purposes
|
||||
creation_timestamp = os.stat(config_file_path).st_ctime
|
||||
return datetime.fromtimestamp(creation_timestamp, UTC)
|
||||
|
||||
@property
|
||||
def config_file_path(self) -> str | None:
|
||||
@@ -737,7 +764,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
|
||||
if registered_project is None:
|
||||
return None
|
||||
else:
|
||||
return registered_project.get_project_instance()
|
||||
return registered_project.get_project_instance(serena_config=self)
|
||||
|
||||
def add_registered_project(self, registered_project: RegisteredProject) -> None:
|
||||
"""
|
||||
@@ -771,7 +798,12 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
|
||||
|
||||
project_config = ProjectConfig.load(project_root, autogenerate=True)
|
||||
|
||||
new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True)
|
||||
new_project = Project(
|
||||
project_root=str(project_root),
|
||||
project_config=project_config,
|
||||
is_newly_created=True,
|
||||
serena_config=self,
|
||||
)
|
||||
self.add_registered_project(RegisteredProject.from_project_instance(new_project))
|
||||
|
||||
return new_project
|
||||
|
||||
@@ -28,3 +28,6 @@ PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.y
|
||||
SERENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml")
|
||||
|
||||
SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
||||
|
||||
LOG_MESSAGES_BUFFER_SIZE = 2500
|
||||
"""The maximum number of log messages to keep in the buffer (for the dashboard)."""
|
||||
+18
-6
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||
from sensai.util import logging
|
||||
|
||||
from serena.analytics import ToolUsageStats
|
||||
from serena.config.serena_config import LanguageBackend, SerenaPaths
|
||||
from serena.config.serena_config import LanguageBackend, SerenaConfig, SerenaPaths
|
||||
from serena.constants import SERENA_DASHBOARD_DIR
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
@@ -324,12 +324,25 @@ class SerenaDashboardAPI:
|
||||
def _get_unread_news_ids() -> list[int]:
|
||||
all_news_files = (Path(SERENA_DASHBOARD_DIR) / "news").glob("*.html")
|
||||
all_news_ids = [int(f.stem) for f in all_news_files]
|
||||
"""News ids are ints of format YYYYMMDD (publication dates)"""
|
||||
|
||||
# Filter news items by installation date
|
||||
serena_config_creation_date = SerenaConfig.get_config_file_creation_date()
|
||||
if serena_config_creation_date is None:
|
||||
# should not normally happen, since config file should exist when the dashboard is started
|
||||
# We assume a fresh installation in this case
|
||||
log.error("Serena config file not found when starting the dashboard")
|
||||
return []
|
||||
serena_config_creation_date_int = int(serena_config_creation_date.strftime("%Y%m%d"))
|
||||
# Only include news items published on or after the installation date
|
||||
post_installation_news_ids = [news_id for news_id in all_news_ids if news_id >= serena_config_creation_date_int]
|
||||
|
||||
news_snippet_id_file = SerenaPaths().news_snippet_id_file
|
||||
if not os.path.exists(news_snippet_id_file):
|
||||
return all_news_ids
|
||||
return post_installation_news_ids
|
||||
with open(news_snippet_id_file, encoding="utf-8") as f:
|
||||
last_read_news_id = int(f.read().strip())
|
||||
return [news_id for news_id in all_news_ids if news_id > last_read_news_id]
|
||||
return [news_id for news_id in post_installation_news_ids if news_id > last_read_news_id]
|
||||
|
||||
try:
|
||||
unread_news_ids = _get_unread_news_ids()
|
||||
@@ -350,11 +363,10 @@ class SerenaDashboardAPI:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _get_log_messages(self, request_log: RequestLog) -> ResponseLog:
|
||||
all_messages = self._memory_log_handler.get_log_messages()
|
||||
requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else []
|
||||
messages = self._memory_log_handler.get_log_messages(from_idx=request_log.start_idx)
|
||||
project = self._agent.get_active_project()
|
||||
project_name = project.project_name if project else None
|
||||
return ResponseLog(messages=requested_messages, max_idx=len(all_messages) - 1, active_project=project_name)
|
||||
return ResponseLog(messages=messages.messages, max_idx=messages.max_idx, active_project=project_name)
|
||||
|
||||
def _get_tool_names(self) -> ResponseToolNames:
|
||||
return ResponseToolNames(tool_names=self._tool_names)
|
||||
|
||||
@@ -68,7 +68,7 @@ class GuiLogViewer:
|
||||
}
|
||||
|
||||
if memory_log_handler is not None:
|
||||
for msg in memory_log_handler.get_log_messages():
|
||||
for msg in memory_log_handler.get_log_messages().messages:
|
||||
self.message_queue.put(msg)
|
||||
memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg))
|
||||
|
||||
|
||||
+59
-28
@@ -12,10 +12,10 @@ import requests
|
||||
from requests import Response
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
import serena.tools.jetbrains_types as jb
|
||||
import serena.jetbrains.jetbrains_types as jb
|
||||
from serena.jetbrains.jetbrains_types import PluginStatusDTO
|
||||
from serena.project import Project
|
||||
from serena.text_utils import render_html
|
||||
from serena.tools.jetbrains_types import PluginStatusDTO
|
||||
from serena.util.version import Version
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -49,7 +49,7 @@ class PluginServerError(SerenaClientError):
|
||||
unrecoverable_error_prefix = (
|
||||
"The request failed due to an error on the Serena JetBrains plugin side. This may be caused "
|
||||
"by a bug or an incompatibility in the plugin. In that case, please report the error below together with "
|
||||
"your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues "
|
||||
"your setup (IDE + version, Serena plugin version, programming language) at https://github.com/oraios/serena/issues "
|
||||
"(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n"
|
||||
)
|
||||
super().__init__(unrecoverable_error_prefix + message)
|
||||
@@ -96,7 +96,7 @@ class JetBrainsPluginClient(ToStringMixin):
|
||||
except ConnectionError: # expected if no server is running at the port
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", e, port, exc_info=True)
|
||||
log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", port, e, exc_info=e)
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
@@ -118,43 +118,71 @@ class JetBrainsPluginClient(ToStringMixin):
|
||||
if client.matches(resolved_path):
|
||||
return client
|
||||
|
||||
plugin_paths_found = []
|
||||
for port in range(cls.BASE_PORT, cls.BASE_PORT + 20):
|
||||
client = JetBrainsPluginClient(port)
|
||||
if client.matches(resolved_path):
|
||||
log.info("Found matching %s", client)
|
||||
cls._last_port = port
|
||||
return client
|
||||
elif client._project_root is not None:
|
||||
plugin_paths_found.append(client._project_root)
|
||||
|
||||
log.warning(
|
||||
"Searched for Serena JetBrains plugin service for project at %s but found no matching service. "
|
||||
"Found plugin instances for the following project paths: %s",
|
||||
resolved_path,
|
||||
plugin_paths_found,
|
||||
)
|
||||
raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_wsl_path(path_str: str) -> Path:
|
||||
def _paths_match(resolved_serena_path: str, plugin_path: str) -> bool:
|
||||
"""
|
||||
Normalize WSL UNC paths to Linux paths for comparison.
|
||||
Checks whether the resolved Serena path matches the plugin path, accounting for possible prefixes
|
||||
in the plugin path, different file system perspectives, and case sensitivity.
|
||||
|
||||
When JetBrains IDE runs on Windows with a project opened from WSL,
|
||||
it returns paths like `//wsl.localhost/Ubuntu-24.04/home/user/project`
|
||||
or `//wsl$/Ubuntu/home/user/project`. This method converts such paths
|
||||
to standard Linux format `/home/user/project` for proper matching.
|
||||
Concrete aspects considered:
|
||||
- The plugin path may contain prefixes:
|
||||
- The plugin path may be a WSL UNC path, e.g. `//wsl.localhost/Ubuntu-24.04/home/user/project`
|
||||
or `//wsl$/Ubuntu/home/user/project` while Serena will just have `/home/user/project`
|
||||
- Other prefixes like `/workspaces/serena/C:/Users/user/projects/my-app`
|
||||
- One path may use a different file system perspective (particularly WSL vs Windows-native) but still
|
||||
point to the same location, e.g. `/mnt/c/` vs `C:/`
|
||||
- Case sensitivity
|
||||
|
||||
:param path_str: Path string that may be a WSL UNC path
|
||||
:return: Normalized Path object
|
||||
:param resolved_serena_path: The resolved project root path from Serena's perspective
|
||||
:param plugin_path: The project root path reported by the plugin (which may be a WSL UNC path)
|
||||
:return: True if the paths match, False otherwise
|
||||
"""
|
||||
path_str = str(path_str)
|
||||
# Match //wsl.localhost/<distro>/... or //wsl$/<distro>/...
|
||||
match = re.match(r"^//wsl(?:\.localhost|\$)/[^/]+(.*)$", path_str, re.IGNORECASE)
|
||||
if match:
|
||||
return Path(match.group(1))
|
||||
return Path(path_str)
|
||||
# try to resolve the plugin path, checking for a direct match
|
||||
# (this is robust against symlinks as long as there are no prefixes)
|
||||
try:
|
||||
resolved_plugin_path = str(Path(plugin_path).resolve())
|
||||
if resolved_plugin_path == resolved_serena_path:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
def normalise_wsl_mnt(path_str: str) -> str:
|
||||
# normalise WSL /mnt/c/ to c:/ for comparison
|
||||
return re.sub(r"/mnt/([a-z])/", r"\1:/", path_str, flags=re.IGNORECASE)
|
||||
|
||||
# standardise paths for comparison: normalise WSL /mnt/ to Windows paths and ignore case
|
||||
std_serena_path = normalise_wsl_mnt(str(resolved_serena_path)).lower()
|
||||
std_plugin_path = normalise_wsl_mnt(str(plugin_path)).lower()
|
||||
|
||||
# At this point, the plugin path may still contain prefixes, so we check if the Serena path is a suffix of the plugin path
|
||||
return std_plugin_path.endswith(std_serena_path)
|
||||
|
||||
def matches(self, resolved_path: Path) -> bool:
|
||||
"""
|
||||
:param resolved_path: the resolved project root path from Serena's perspective
|
||||
:return: whether this client instance matches the given project path
|
||||
"""
|
||||
if self._project_root is None:
|
||||
return False
|
||||
try:
|
||||
plugin_root = self._normalize_wsl_path(self._project_root)
|
||||
return plugin_root.resolve() == resolved_path
|
||||
except ConnectionError:
|
||||
return False
|
||||
return self._paths_match(str(resolved_path), self._project_root)
|
||||
|
||||
def is_version_at_least(self, *version_parts: int) -> bool:
|
||||
if self._plugin_version is None:
|
||||
@@ -252,11 +280,14 @@ class JetBrainsPluginClient(ToStringMixin):
|
||||
else:
|
||||
del symbol[key]
|
||||
|
||||
# convert documentation and quick info from HTML to plain text (if present)
|
||||
symbols = response_dict["symbols"]
|
||||
for s in symbols:
|
||||
convert_html("documentation", s)
|
||||
convert_html("quick_info", s)
|
||||
def convert_symbol_list(l: list) -> None:
|
||||
for s in l:
|
||||
convert_html("documentation", s)
|
||||
convert_html("quick_info", s)
|
||||
if "children" in s:
|
||||
convert_symbol_list(s["children"])
|
||||
|
||||
convert_symbol_list(response_dict["symbols"])
|
||||
|
||||
def find_symbol(
|
||||
self,
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
class PluginStatusDTO(TypedDict):
|
||||
@@ -30,6 +30,9 @@ class SymbolDTO(TypedDict):
|
||||
num_usages: NotRequired[int]
|
||||
|
||||
|
||||
SymbolDTOKey = Literal["name_path", "relative_path", "type", "body", "quick_info", "documentation", "text_range", "children", "num_usages"]
|
||||
|
||||
|
||||
class SymbolCollectionResponse(TypedDict):
|
||||
symbols: list[SymbolDTO]
|
||||
|
||||
@@ -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
|
||||
|
||||
+76
-27
@@ -1,10 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, 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
|
||||
@@ -16,6 +18,9 @@ from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_utils import FileUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from serena.config.serena_config import SerenaConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -54,7 +59,13 @@ class MemoriesManager:
|
||||
|
||||
|
||||
class Project(ToStringMixin):
|
||||
def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
project_root: str,
|
||||
project_config: ProjectConfig,
|
||||
is_newly_created: bool = False,
|
||||
serena_config: "SerenaConfig | None" = None,
|
||||
):
|
||||
self.project_root = project_root
|
||||
self.project_config = project_config
|
||||
self.memories_manager = MemoriesManager(project_root)
|
||||
@@ -69,27 +80,44 @@ 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._serena_config = serena_config
|
||||
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 global configuration, project configuration, and gitignore files
|
||||
global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else []
|
||||
ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths)
|
||||
if len(global_ignored_paths) > 0:
|
||||
log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.")
|
||||
log.debug(f"Global ignored paths: {list(global_ignored_paths)}")
|
||||
if len(self.project_config.ignored_paths) > 0:
|
||||
log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.")
|
||||
log.debug(f"Project ignored paths: {self.project_config.ignored_paths}")
|
||||
log.debug(f"Combined ignored patterns: {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 []
|
||||
@@ -102,12 +130,17 @@ class Project(ToStringMixin):
|
||||
return self.project_config.project_name
|
||||
|
||||
@classmethod
|
||||
def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project":
|
||||
def load(
|
||||
cls,
|
||||
project_root: str | Path,
|
||||
serena_config: "SerenaConfig | None",
|
||||
autogenerate: bool = True,
|
||||
) -> "Project":
|
||||
project_root = Path(project_root).resolve()
|
||||
if not project_root.exists():
|
||||
raise FileNotFoundError(f"Project root not found: {project_root}")
|
||||
project_config = ProjectConfig.load(project_root, autogenerate=autogenerate)
|
||||
return Project(project_root=str(project_root), project_config=project_config)
|
||||
return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config)
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""
|
||||
@@ -151,12 +184,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 +247,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:
|
||||
"""
|
||||
|
||||
@@ -213,13 +213,48 @@ body {
|
||||
.header-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: 200px;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-tab.active {
|
||||
color: var(--btn-primary);
|
||||
border-bottom-color: var(--btn-primary);
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
@@ -240,7 +275,8 @@ body {
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
top: 100%;
|
||||
margin-top: 6px;
|
||||
right: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -966,6 +1002,14 @@ body {
|
||||
|
||||
.header-nav {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,20 +30,24 @@
|
||||
</div>
|
||||
|
||||
<nav class="header-nav">
|
||||
<button id="theme-toggle" class="theme-toggle">
|
||||
<span id="theme-icon" style="height: 21px">🌙</span>
|
||||
<span id="theme-text">Dark</span>
|
||||
</button>
|
||||
<button id="menu-toggle" class="menu-button">
|
||||
<span>☰</span>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
|
||||
<a href="#" data-page="overview" class="active">Overview</a>
|
||||
<a href="#" data-page="logs">Logs</a>
|
||||
<a href="#" data-page="stats">Advanced Stats</a>
|
||||
<hr>
|
||||
<a href="#" id="menu-shutdown">Shutdown Server</a>
|
||||
<div class="header-actions">
|
||||
<button id="theme-toggle" class="theme-toggle">
|
||||
<span id="theme-icon" style="height: 21px">🌙</span>
|
||||
<span id="theme-text">Dark</span>
|
||||
</button>
|
||||
<button id="menu-toggle" class="menu-button">
|
||||
<span>☰</span>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
|
||||
<a href="#" data-page="stats">Advanced Stats</a>
|
||||
<hr>
|
||||
<a href="#" id="menu-shutdown">Shutdown Server</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-tabs">
|
||||
<a href="#" data-page="overview" class="header-tab active">Overview</a>
|
||||
<a href="#" data-page="logs" class="header-tab">Logs</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -8,10 +8,10 @@ project_name: "project_name"
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
@@ -34,8 +34,9 @@ encoding: "utf-8"
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in all projects
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
|
||||
@@ -63,6 +63,11 @@ trace_lsp_communication: False
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of paths to ignore across all projects.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# These patterns are merged additively with each project's own ignored_paths.
|
||||
ignored_paths: []
|
||||
|
||||
# timeout, in seconds, after which tool executions are terminated
|
||||
tool_timeout: 240
|
||||
|
||||
|
||||
+190
-51
@@ -5,11 +5,11 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Any, Self, Union
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar, Union
|
||||
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
import serena.tools.jetbrains_types as jb
|
||||
import serena.jetbrains.jetbrains_types as jb
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
|
||||
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
|
||||
@@ -213,14 +213,17 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
|
||||
return []
|
||||
|
||||
def _tostring_additional_entries(self) -> dict[str, Any]:
|
||||
return dict(name=self.name, kind=self.kind, num_children=len(self.symbol_root["children"]))
|
||||
return dict(name=self.name, kind=self.symbol_kind_name, num_children=len(self.symbol_root["children"]))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.symbol_root["name"]
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
def symbol_kind_name(self) -> str:
|
||||
"""
|
||||
:return: string representation of the symbol kind (name attribute of the `SymbolKind` enum item)
|
||||
"""
|
||||
return SymbolKind(self.symbol_kind).name
|
||||
|
||||
@property
|
||||
@@ -396,67 +399,99 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
|
||||
traverse(self)
|
||||
return result
|
||||
|
||||
class OutputDict(TypedDict):
|
||||
name_path: NotRequired[str]
|
||||
name: NotRequired[str]
|
||||
location: NotRequired[dict[str, Any]]
|
||||
relative_path: NotRequired[str | None]
|
||||
body_location: NotRequired[dict[str, Any]]
|
||||
body: NotRequired[str | None]
|
||||
kind: NotRequired[str]
|
||||
"""
|
||||
string representation of the symbol kind (name attribute of the `SymbolKind` enum item)
|
||||
"""
|
||||
children: NotRequired[list["LanguageServerSymbol.OutputDict"]]
|
||||
|
||||
OutputDictKey = Literal["name", "name_path", "relative_path", "location", "body_location", "body", "kind", "children"]
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
*,
|
||||
name_path: bool = True,
|
||||
name: bool = False,
|
||||
kind: bool = False,
|
||||
location: bool = False,
|
||||
depth: int = 0,
|
||||
include_body: bool = False,
|
||||
include_children_body: bool = False,
|
||||
include_relative_path: bool = True,
|
||||
body: bool = False,
|
||||
body_location: bool = False,
|
||||
children_body: bool = False,
|
||||
relative_path: bool = False,
|
||||
child_inclusion_predicate: Callable[[Self], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> OutputDict:
|
||||
"""
|
||||
Converts the symbol to a dictionary.
|
||||
|
||||
:param name_path: whether to include the name path of the symbol
|
||||
:param name: whether to include the name of the symbol
|
||||
:param kind: whether to include the kind of the symbol
|
||||
:param location: whether to include the location of the symbol
|
||||
:param depth: the depth up to which to include child symbols (0 = do not include children)
|
||||
:param include_body: whether to include the body of the top-level symbol.
|
||||
:param include_children_body: whether to also include the body of the children.
|
||||
:param body: whether to include the body of the top-level symbol.
|
||||
:param children_body: whether to also include the body of the children.
|
||||
Note that the body of the children is part of the body of the parent symbol,
|
||||
so there is usually no need to set this to True unless you want process the output
|
||||
and pass the children without passing the parent body to the LM.
|
||||
:param include_relative_path: whether to include the relative path of the symbol in the location
|
||||
entry. Relative paths of the symbol's children are always excluded.
|
||||
:param relative_path: whether to include the relative path of the symbol.
|
||||
If `location` is True, this defines whether to include the path in the location entry.
|
||||
If `location` is False, this defines whether to include the relative path as a top-level entry.
|
||||
Relative paths of the symbol's children are always excluded.
|
||||
:param child_inclusion_predicate: an optional predicate that decides whether a child symbol
|
||||
should be included.
|
||||
:return: a dictionary representation of the symbol
|
||||
"""
|
||||
result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()}
|
||||
result: LanguageServerSymbol.OutputDict = {}
|
||||
|
||||
if name_path:
|
||||
result["name_path"] = self.get_name_path()
|
||||
if name:
|
||||
result["name"] = self.name
|
||||
|
||||
if kind:
|
||||
result["kind"] = self.kind
|
||||
result["kind"] = self.symbol_kind_name
|
||||
|
||||
if location:
|
||||
result["location"] = self.location.to_dict(include_relative_path=include_relative_path)
|
||||
result["location"] = self.location.to_dict(include_relative_path=relative_path)
|
||||
elif relative_path:
|
||||
result["relative_path"] = self.relative_path
|
||||
|
||||
if body_location:
|
||||
body_start_line, body_end_line = self.get_body_line_numbers()
|
||||
result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line}
|
||||
|
||||
if include_body:
|
||||
body = self.body
|
||||
if body is None:
|
||||
log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.")
|
||||
result["body"] = body
|
||||
if body:
|
||||
result["body"] = self.body
|
||||
|
||||
if child_inclusion_predicate is None:
|
||||
child_inclusion_predicate = lambda s: True
|
||||
|
||||
def included_children(s: Self) -> list[dict[str, Any]]:
|
||||
def included_children(s: Self) -> list[LanguageServerSymbol.OutputDict]:
|
||||
children = []
|
||||
for c in s.iter_children():
|
||||
if not child_inclusion_predicate(c):
|
||||
continue
|
||||
children.append(
|
||||
c.to_dict(
|
||||
name_path=name_path,
|
||||
name=name,
|
||||
kind=kind,
|
||||
location=location,
|
||||
body_location=body_location,
|
||||
depth=depth - 1,
|
||||
child_inclusion_predicate=child_inclusion_predicate,
|
||||
include_body=include_children_body,
|
||||
include_children_body=include_children_body,
|
||||
body=children_body,
|
||||
children_body=children_body,
|
||||
# all children have the same relative path as the parent
|
||||
include_relative_path=False,
|
||||
relative_path=False,
|
||||
)
|
||||
)
|
||||
return children
|
||||
@@ -464,7 +499,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
|
||||
if depth > 0:
|
||||
children = included_children(self)
|
||||
if len(children) > 0:
|
||||
result["children"] = included_children(self)
|
||||
result["children"] = children
|
||||
|
||||
return result
|
||||
|
||||
@@ -716,8 +751,7 @@ class LanguageServerSymbolRetriever:
|
||||
include_rel_path = within_relative_path is not None
|
||||
raise ValueError(
|
||||
f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. "
|
||||
"They are: \n"
|
||||
+ json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2)
|
||||
"They are: \n" + json.dumps([s.to_dict(kind=True, relative_path=include_rel_path) for s in symbol_candidates], indent=2)
|
||||
)
|
||||
|
||||
def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
|
||||
@@ -801,36 +835,15 @@ class LanguageServerSymbolRetriever:
|
||||
|
||||
return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references]
|
||||
|
||||
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[dict]]:
|
||||
def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]:
|
||||
"""
|
||||
:param relative_path: the path of the file or directory for which to get the symbol overview
|
||||
:param depth: the depth up to which to include child symbols (0 = only top-level symbols)
|
||||
:return: a mapping from file paths to lists of symbol dictionaries.
|
||||
:return: a mapping from file paths to lists of symbols.
|
||||
For the case where a file is passed, the mapping will contain a single entry.
|
||||
"""
|
||||
lang_server = self.get_language_server(relative_path)
|
||||
path_to_unified_symbols = lang_server.request_overview(relative_path)
|
||||
|
||||
def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
|
||||
return not s.is_low_level()
|
||||
|
||||
result = {}
|
||||
for file_path, unified_symbols in path_to_unified_symbols.items():
|
||||
symbols_in_file = []
|
||||
for us in unified_symbols:
|
||||
symbol = LanguageServerSymbol(us)
|
||||
symbols_in_file.append(
|
||||
symbol.to_dict(
|
||||
depth=depth,
|
||||
kind=True,
|
||||
include_relative_path=False,
|
||||
location=False,
|
||||
child_inclusion_predicate=child_inclusion_predicate,
|
||||
)
|
||||
)
|
||||
result[file_path] = symbols_in_file
|
||||
|
||||
return result
|
||||
return {k: [LanguageServerSymbol(us) for us in v] for k, v in path_to_unified_symbols.items()}
|
||||
|
||||
|
||||
class JetBrainsSymbol(Symbol):
|
||||
@@ -887,3 +900,129 @@ class JetBrainsSymbol(Symbol):
|
||||
def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
|
||||
# NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way.
|
||||
return False
|
||||
|
||||
|
||||
TSymbolDict = TypeVar("TSymbolDict")
|
||||
GroupedSymbolDict = dict[str, list[dict] | dict[str, dict]]
|
||||
|
||||
|
||||
class SymbolDictGrouper(Generic[TSymbolDict], ABC):
|
||||
"""
|
||||
A utility class for grouping a list of symbol dictionaries by one or more specified keys.
|
||||
|
||||
If an instance is statically initialised (upon module import), then this establishes a guarantee
|
||||
that the specified keys are defined in the symbol dictionary type, ensuring at least basic type safety.
|
||||
The respective ValueError will immediately be apparent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
symbol_dict_type: type[TSymbolDict],
|
||||
children_key: Any,
|
||||
group_keys: list[Any],
|
||||
group_children_keys: list[Any],
|
||||
collapse_singleton: bool,
|
||||
) -> None:
|
||||
"""
|
||||
:param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped
|
||||
:param children_key: the key in the symbol dictionaries that contains the list of child symbols (for recursive grouping).
|
||||
:param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`.
|
||||
:param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`.
|
||||
:param collapse_singleton: whether to collapse dictionaries containing a single entry after regrouping to just the entry's value
|
||||
"""
|
||||
# check whether the type contains all the keys specified in `keys` and raise an error if not.
|
||||
if not hasattr(symbol_dict_type, "__annotations__"):
|
||||
raise ValueError(f"symbol_dict_type must be a TypedDict type, got {symbol_dict_type}")
|
||||
symbol_dict_keys = set(symbol_dict_type.__annotations__.keys())
|
||||
for key in group_keys + [children_key] + group_children_keys:
|
||||
if key not in symbol_dict_keys:
|
||||
raise ValueError(f"symbol_dict_type {symbol_dict_type} does not contain key '{key}'")
|
||||
|
||||
self._children_key = children_key
|
||||
self._group_keys = group_keys
|
||||
self._group_children_keys = group_children_keys
|
||||
self._collapse_singleton = collapse_singleton
|
||||
|
||||
def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str]) -> dict[str, Any]:
|
||||
assert len(keys) > 0, "keys must not be empty"
|
||||
# group by the first key
|
||||
grouped: dict[str, Any] = {}
|
||||
for item in l:
|
||||
key_value = item.pop(keys[0], "unknown")
|
||||
if key_value not in grouped:
|
||||
grouped[key_value] = []
|
||||
grouped[key_value].append(item)
|
||||
if len(keys) > 1:
|
||||
# continue grouping by the remaining keys
|
||||
for k, group in grouped.items():
|
||||
grouped[k] = self._group_by(group, keys[1:], children_keys)
|
||||
else:
|
||||
# grouping is complete; now group the children if necessary
|
||||
if children_keys:
|
||||
for k, group in grouped.items():
|
||||
for item in group:
|
||||
if self._children_key in item:
|
||||
children = item[self._children_key]
|
||||
item[self._children_key] = self._group_by(children, children_keys, children_keys)
|
||||
# post-process final group items
|
||||
grouped = {k: [self._transform_item(i) for i in v] for k, v in grouped.items()}
|
||||
return grouped
|
||||
|
||||
def _transform_item(self, item: dict) -> dict:
|
||||
"""
|
||||
Post-processes a final group item (which has been regrouped, i.e. some keys may have been removed),
|
||||
collapsing singleton items (and items containing only a single non-children key)
|
||||
"""
|
||||
if self._collapse_singleton:
|
||||
if len(item) == 1:
|
||||
# {"name": "foo"} -> "foo"
|
||||
# if there is only a single entry, collapse the dictionary to just the value of that entry
|
||||
return next(iter(item.values()))
|
||||
elif len(item) == 2 and self._children_key in item:
|
||||
# {"name": "foo", "children": {...}} -> {"foo": {...}}
|
||||
# if there are exactly two entries and one of them is the children key,
|
||||
# convert to {other_value: children}
|
||||
other_key = next(k for k in item.keys() if k != self._children_key)
|
||||
new_item = {item[other_key]: item[self._children_key]}
|
||||
return new_item
|
||||
return item
|
||||
|
||||
def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict:
|
||||
"""
|
||||
:param symbols: the symbols to group
|
||||
:return: dictionary with the symbols grouped as defined at construction
|
||||
"""
|
||||
return self._group_by(symbols, self._group_keys, self._group_children_keys) # type: ignore
|
||||
|
||||
|
||||
class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]):
|
||||
def __init__(
|
||||
self,
|
||||
group_keys: list[LanguageServerSymbol.OutputDictKey],
|
||||
group_children_keys: list[LanguageServerSymbol.OutputDictKey],
|
||||
collapse_singleton: bool = False,
|
||||
) -> None:
|
||||
super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys, collapse_singleton)
|
||||
|
||||
|
||||
class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]):
|
||||
def __init__(
|
||||
self,
|
||||
group_keys: list[jb.SymbolDTOKey],
|
||||
group_children_keys: list[jb.SymbolDTOKey],
|
||||
collapse_singleton: bool = False,
|
||||
map_name_path_to_name: bool = False,
|
||||
) -> None:
|
||||
super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys, collapse_singleton)
|
||||
self._map_name_path_to_name = map_name_path_to_name
|
||||
|
||||
def _transform_item(self, item: dict) -> dict:
|
||||
if self._map_name_path_to_name:
|
||||
# {"name_path: "Class/myMethod"} -> {"name: "myMethod"}
|
||||
new_item = dict(item)
|
||||
if "name_path" in item:
|
||||
name_path = new_item.pop("name_path")
|
||||
new_item["name"] = name_path.split("/")[-1]
|
||||
return super()._transform_item(new_item)
|
||||
else:
|
||||
return super()._transform_item(item)
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any, Literal
|
||||
|
||||
import serena.tools.jetbrains_types as jb
|
||||
import serena.jetbrains.jetbrains_types as jb
|
||||
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
from serena.symbol import JetBrainsSymbolDictGrouper
|
||||
from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead
|
||||
from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +91,8 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
|
||||
Finds symbols that reference the given symbol using the JetBrains backend
|
||||
"""
|
||||
|
||||
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True)
|
||||
|
||||
# TODO: (maybe) - add content snippets showing the references like in LS based version?
|
||||
def apply(
|
||||
self,
|
||||
@@ -116,10 +118,12 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
|
||||
response_dict = client.find_references(
|
||||
name_path=name_path,
|
||||
relative_path=relative_path,
|
||||
include_quick_info=include_info,
|
||||
include_quick_info=False, # TODO: Hotfix for serena-jetbrains-plugin/issues/13; revert once fixed
|
||||
)
|
||||
result = self._to_json(response_dict)
|
||||
return self._limit_length(result, max_answer_chars)
|
||||
symbol_dicts = response_dict["symbols"]
|
||||
result = self.symbol_dict_grouper.group(symbol_dicts)
|
||||
result_json = self._to_json(result)
|
||||
return self._limit_length(result_json, max_answer_chars)
|
||||
|
||||
|
||||
class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
|
||||
@@ -128,37 +132,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
|
||||
"""
|
||||
|
||||
USE_COMPACT_FORMAT = True
|
||||
|
||||
@staticmethod
|
||||
def _transform_symbols_to_compact_format(symbols: list[jb.SymbolDTO]) -> dict[str, list]:
|
||||
"""
|
||||
Transform symbol overview from verbose format to compact grouped format.
|
||||
|
||||
Groups symbols by kind and uses names instead of full symbol objects.
|
||||
For symbols with children, creates nested dictionaries.
|
||||
|
||||
The name_path can be inferred from the hierarchical structure:
|
||||
- Top-level symbols: name_path = name
|
||||
- Nested symbols: name_path = parent_name + "/" + name
|
||||
For example, "convert" under class "ProjectType" has name_path "ProjectType/convert".
|
||||
"""
|
||||
result = defaultdict(list)
|
||||
|
||||
for symbol in symbols:
|
||||
kind = symbol.get("type", "Unknown")
|
||||
name_path = symbol["name_path"]
|
||||
name = name_path.split("/")[-1]
|
||||
children = symbol.get("children", [])
|
||||
|
||||
if children:
|
||||
# Symbol has children: create nested dict {name: children_dict}
|
||||
children_dict = JetBrainsGetSymbolsOverviewTool._transform_symbols_to_compact_format(children)
|
||||
result[kind].append({name: children_dict})
|
||||
else:
|
||||
# Symbol has no children: just add the name
|
||||
result[kind].append(name) # type: ignore
|
||||
|
||||
return result
|
||||
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
@@ -187,7 +161,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
|
||||
)
|
||||
if self.USE_COMPACT_FORMAT:
|
||||
symbols = symbol_overview["symbols"]
|
||||
result: dict[str, Any] = {"symbols": self._transform_symbols_to_compact_format(symbols)}
|
||||
result: dict[str, Any] = {"symbols": self.symbol_dict_grouper.group(symbols)}
|
||||
documentation = symbol_overview.pop("documentation", None)
|
||||
if documentation:
|
||||
result["docstring"] = documentation
|
||||
|
||||
@@ -3,11 +3,9 @@ Language server-related tools
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from copy import copy
|
||||
from typing import Any
|
||||
|
||||
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper
|
||||
from serena.tools import (
|
||||
SUCCESS_RESULT,
|
||||
Tool,
|
||||
@@ -18,22 +16,6 @@ from serena.tools.tools_base import ToolMarkerOptional
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
|
||||
|
||||
def _sanitize_symbol_dict(symbol_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Sanitize a symbol dictionary inplace by removing unnecessary information.
|
||||
"""
|
||||
# We replace the location entry, which repeats line information already included in body_location
|
||||
# and has unnecessary information on column, by just the relative path.
|
||||
symbol_dict = copy(symbol_dict)
|
||||
s_relative_path = symbol_dict.get("location", {}).get("relative_path")
|
||||
if s_relative_path is not None:
|
||||
symbol_dict["relative_path"] = s_relative_path
|
||||
symbol_dict.pop("location", None)
|
||||
# also remove name, name_path should be enough
|
||||
symbol_dict.pop("name")
|
||||
return symbol_dict
|
||||
|
||||
|
||||
class RestartLanguageServerTool(Tool, ToolMarkerOptional):
|
||||
"""Restarts the language server, may be necessary when edits not through Serena happen."""
|
||||
|
||||
@@ -50,6 +32,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
|
||||
Gets an overview of the top-level symbols defined in a given file.
|
||||
"""
|
||||
|
||||
symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True)
|
||||
|
||||
def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str:
|
||||
"""
|
||||
Use this tool to get a high-level understanding of the code symbols in a file.
|
||||
@@ -65,11 +49,11 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
|
||||
:return: a JSON object containing symbols grouped by kind in a compact format.
|
||||
"""
|
||||
result = self.get_symbol_overview(relative_path, depth=depth)
|
||||
compact_result = self._transform_symbols_to_compact_format(result)
|
||||
compact_result = self.symbol_dict_grouper.group(result)
|
||||
result_json_str = self._to_json(compact_result)
|
||||
return self._limit_length(result_json_str, max_answer_chars)
|
||||
|
||||
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[dict]:
|
||||
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]:
|
||||
"""
|
||||
:param relative_path: relative path to a source file
|
||||
:param depth: the depth up to which descendants shall be retrieved
|
||||
@@ -85,37 +69,25 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
|
||||
if os.path.isdir(file_path):
|
||||
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
|
||||
|
||||
return symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path]
|
||||
symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path]
|
||||
|
||||
@staticmethod
|
||||
def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]:
|
||||
"""
|
||||
Transform symbol overview from verbose format to compact grouped format.
|
||||
|
||||
Groups symbols by kind and uses names instead of full symbol objects.
|
||||
For symbols with children, creates nested dictionaries.
|
||||
|
||||
The name_path can be inferred from the hierarchical structure:
|
||||
- Top-level symbols: name_path = name
|
||||
- Nested symbols: name_path = parent_name + "/" + name
|
||||
For example, "convert" under class "ProjectType" has name_path "ProjectType/convert".
|
||||
"""
|
||||
result = defaultdict(list)
|
||||
def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
|
||||
return not s.is_low_level()
|
||||
|
||||
symbol_dicts = []
|
||||
for symbol in symbols:
|
||||
kind = symbol.get("kind", "Unknown")
|
||||
name = symbol.get("name", "unknown")
|
||||
children = symbol.get("children", [])
|
||||
|
||||
if children:
|
||||
# Symbol has children: create nested dict {name: children_dict}
|
||||
children_dict = GetSymbolsOverviewTool._transform_symbols_to_compact_format(children)
|
||||
result[kind].append({name: children_dict})
|
||||
else:
|
||||
# Symbol has no children: just add the name
|
||||
result[kind].append(name)
|
||||
|
||||
return result
|
||||
symbol_dicts.append(
|
||||
symbol.to_dict(
|
||||
name_path=False,
|
||||
name=True,
|
||||
depth=depth,
|
||||
kind=True,
|
||||
relative_path=False,
|
||||
location=False,
|
||||
child_inclusion_predicate=child_inclusion_predicate,
|
||||
)
|
||||
)
|
||||
return symbol_dicts
|
||||
|
||||
|
||||
class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
|
||||
@@ -185,7 +157,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
|
||||
substring_matching=substring_matching,
|
||||
within_relative_path=relative_path,
|
||||
)
|
||||
symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols]
|
||||
symbol_dicts = [dict(s.to_dict(kind=True, relative_path=True, body_location=True, depth=depth, body=include_body)) for s in symbols]
|
||||
if not include_body and include_info:
|
||||
info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols)
|
||||
for s, s_dict in zip(symbols, symbol_dicts, strict=True):
|
||||
@@ -201,6 +173,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
||||
Finds symbols that reference the given symbol using the language server backend
|
||||
"""
|
||||
|
||||
symbol_dict_grouper = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True)
|
||||
|
||||
# noinspection PyDefaultArgument
|
||||
def apply(
|
||||
self,
|
||||
@@ -229,6 +203,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
||||
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
|
||||
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
|
||||
references_in_symbols = symbol_retriever.find_referencing_symbols(
|
||||
name_path,
|
||||
relative_file_path=relative_path,
|
||||
@@ -245,8 +220,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
||||
|
||||
reference_dicts = []
|
||||
for ref in references_in_symbols:
|
||||
ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body)
|
||||
ref_dict = _sanitize_symbol_dict(ref_dict)
|
||||
ref_dict_orig = ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)
|
||||
ref_dict = dict(ref_dict_orig)
|
||||
if not include_body:
|
||||
ref_relative_path = ref.symbol.location.relative_path
|
||||
assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug."
|
||||
@@ -257,8 +232,11 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
||||
)
|
||||
ref_dict["content_around_reference"] = content_around_ref.to_display_string()
|
||||
reference_dicts.append(ref_dict)
|
||||
result = self._to_json(reference_dicts)
|
||||
return self._limit_length(result, max_answer_chars)
|
||||
|
||||
result = self.symbol_dict_grouper.group(reference_dicts) # type: ignore
|
||||
|
||||
result_json = self._to_json(result)
|
||||
return self._limit_length(result_json, max_answer_chars)
|
||||
|
||||
|
||||
class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
|
||||
|
||||
@@ -53,7 +53,7 @@ class OnboardingTool(Tool):
|
||||
return self.prompt_factory.create_onboarding_prompt(system=system)
|
||||
|
||||
|
||||
class ThinkAboutCollectedInformationTool(Tool):
|
||||
class ThinkAboutCollectedInformationTool(Tool, ToolMarkerOptional):
|
||||
"""
|
||||
Thinking tool for pondering the completeness of collected information.
|
||||
"""
|
||||
@@ -67,7 +67,7 @@ class ThinkAboutCollectedInformationTool(Tool):
|
||||
return self.prompt_factory.create_think_about_collected_information()
|
||||
|
||||
|
||||
class ThinkAboutTaskAdherenceTool(Tool):
|
||||
class ThinkAboutTaskAdherenceTool(Tool, ToolMarkerOptional):
|
||||
"""
|
||||
Thinking tool for determining whether the agent is still on track with the current task.
|
||||
"""
|
||||
@@ -83,7 +83,7 @@ class ThinkAboutTaskAdherenceTool(Tool):
|
||||
return self.prompt_factory.create_think_about_task_adherence()
|
||||
|
||||
|
||||
class ThinkAboutWhetherYouAreDoneTool(Tool):
|
||||
class ThinkAboutWhetherYouAreDoneTool(Tool, ToolMarkerOptional):
|
||||
"""
|
||||
Thinking tool for determining whether the task is truly completed.
|
||||
"""
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sensai.util import logging
|
||||
|
||||
from serena.constants import SERENA_LOG_FORMAT
|
||||
from serena.constants import LOG_MESSAGES_BUFFER_SIZE, SERENA_LOG_FORMAT
|
||||
|
||||
lg = logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogMessages:
|
||||
messages: list[str]
|
||||
"""
|
||||
the list of log messages, ordered from oldest to newest
|
||||
"""
|
||||
max_idx: int
|
||||
"""
|
||||
the 0-based index of the last message in `messages` (in the full log history)
|
||||
"""
|
||||
|
||||
|
||||
class MemoryLogHandler(logging.Handler):
|
||||
def __init__(self, level: int = logging.NOTSET) -> None:
|
||||
def __init__(self, level: int = logging.NOTSET, max_messages: int | None = LOG_MESSAGES_BUFFER_SIZE) -> None:
|
||||
super().__init__(level=level)
|
||||
self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT))
|
||||
self._log_buffer = LogBuffer()
|
||||
self._log_buffer = LogBuffer(max_messages=max_messages)
|
||||
self._log_queue: queue.Queue[str] = queue.Queue()
|
||||
self._stop_event = threading.Event()
|
||||
self._emit_callbacks: list[Callable[[str], None]] = []
|
||||
@@ -48,26 +61,48 @@ class MemoryLogHandler(logging.Handler):
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
def get_log_messages(self) -> list[str]:
|
||||
return self._log_buffer.get_log_messages()
|
||||
def get_log_messages(self, from_idx: int = 0) -> LogMessages:
|
||||
return self._log_buffer.get_log_messages(from_idx=from_idx)
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""
|
||||
A thread-safe buffer for storing log messages.
|
||||
A thread-safe buffer for storing (an optionally limited number of) log messages.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, max_messages: int | None = None) -> None:
|
||||
self._max_messages = max_messages
|
||||
self._log_messages: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
self._max_idx = -1
|
||||
"""
|
||||
the 0-based index of the most recently added log message
|
||||
"""
|
||||
|
||||
def append(self, msg: str) -> None:
|
||||
with self._lock:
|
||||
self._log_messages.append(msg)
|
||||
self._max_idx += 1
|
||||
if self._max_messages is not None and len(self._log_messages) > self._max_messages:
|
||||
excess = len(self._log_messages) - self._max_messages
|
||||
self._log_messages = self._log_messages[excess:]
|
||||
|
||||
def get_log_messages(self) -> list[str]:
|
||||
def get_log_messages(self, from_idx: int = 0) -> LogMessages:
|
||||
"""
|
||||
:param from_idx: the 0-based index of the first log message to return.
|
||||
If from_idx is less than or equal to the index of the oldest message in the buffer,
|
||||
then all messages in the buffer will be returned.
|
||||
:return: the list of messages
|
||||
"""
|
||||
from_idx = max(from_idx, 0)
|
||||
with self._lock:
|
||||
return self._log_messages.copy()
|
||||
first_stored_idx = self._max_idx - len(self._log_messages) + 1
|
||||
if from_idx <= first_stored_idx:
|
||||
messages = self._log_messages.copy()
|
||||
else:
|
||||
start_idx = from_idx - first_stored_idx
|
||||
messages = self._log_messages[start_idx:].copy()
|
||||
return LogMessages(messages=messages, max_idx=self._max_idx)
|
||||
|
||||
|
||||
class SuspendedLoggersContext:
|
||||
|
||||
@@ -10,10 +10,15 @@ import shutil
|
||||
import threading
|
||||
|
||||
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
|
||||
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
|
||||
from solidlsp.ls import (
|
||||
DocumentSymbols,
|
||||
LanguageServerDependencyProvider,
|
||||
LanguageServerDependencyProviderSinglePath,
|
||||
LSPFileBuffer,
|
||||
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__)
|
||||
@@ -30,57 +35,63 @@ class BashLanguageServer(SolidLanguageServer):
|
||||
Creates a BashLanguageServer instance. This class is not meant to be instantiated directly.
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
bash_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
|
||||
super().__init__(
|
||||
config,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=bash_lsp_executable_path, cwd=repository_root_path),
|
||||
None,
|
||||
"bash",
|
||||
solidlsp_settings,
|
||||
)
|
||||
self.server_ready = threading.Event()
|
||||
self.initialize_searcher_command_available = threading.Event()
|
||||
|
||||
@classmethod
|
||||
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Bash Language Server and return the command to start the server.
|
||||
"""
|
||||
# Verify both node and npm are installed
|
||||
is_node_installed = shutil.which("node") is not None
|
||||
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
|
||||
is_npm_installed = shutil.which("npm") is not None
|
||||
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
deps = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="bash-language-server",
|
||||
description="bash-language-server package",
|
||||
command="npm install --prefix ./ bash-language-server@5.6.0",
|
||||
platform_id="any",
|
||||
),
|
||||
]
|
||||
)
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Bash Language Server and return the command to start the server.
|
||||
"""
|
||||
# Verify both node and npm are installed
|
||||
is_node_installed = shutil.which("node") is not None
|
||||
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
|
||||
is_npm_installed = shutil.which("npm") is not None
|
||||
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
|
||||
|
||||
# Install bash-language-server if not already installed
|
||||
bash_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "bash-lsp")
|
||||
bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server")
|
||||
|
||||
# Handle Windows executable extension
|
||||
if os.name == "nt":
|
||||
bash_executable_path += ".cmd"
|
||||
|
||||
if not os.path.exists(bash_executable_path):
|
||||
log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...")
|
||||
deps.install(bash_ls_dir)
|
||||
log.info("Bash language server dependencies installed successfully")
|
||||
|
||||
if not os.path.exists(bash_executable_path):
|
||||
raise FileNotFoundError(
|
||||
f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation."
|
||||
deps = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="bash-language-server",
|
||||
description="bash-language-server package",
|
||||
command="npm install --prefix ./ bash-language-server@5.6.0",
|
||||
platform_id="any",
|
||||
),
|
||||
]
|
||||
)
|
||||
return f"{bash_executable_path} start"
|
||||
|
||||
# Install bash-language-server if not already installed
|
||||
bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
|
||||
bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server")
|
||||
|
||||
# Handle Windows executable extension
|
||||
if os.name == "nt":
|
||||
bash_executable_path += ".cmd"
|
||||
|
||||
if not os.path.exists(bash_executable_path):
|
||||
log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...")
|
||||
deps.install(bash_ls_dir)
|
||||
log.info("Bash language server dependencies installed successfully")
|
||||
|
||||
if not os.path.exists(bash_executable_path):
|
||||
raise FileNotFoundError(
|
||||
f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation."
|
||||
)
|
||||
|
||||
return bash_executable_path
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path, "start"]
|
||||
|
||||
@staticmethod
|
||||
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
|
||||
@@ -148,7 +159,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 +193,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]:
|
||||
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()
|
||||
@@ -123,7 +123,7 @@ class ClojureLSP(SolidLanguageServer):
|
||||
os.chmod(clojurelsp_executable_path, 0o755)
|
||||
return clojurelsp_executable_path
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str] | str:
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path]
|
||||
|
||||
@staticmethod
|
||||
@@ -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,
|
||||
@@ -235,7 +306,7 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
self._repository_root_path = repository_root_path
|
||||
self._dotnet_path, self._language_server_path = self._ensure_server_installed()
|
||||
|
||||
def create_launch_command(self) -> list[str] | str:
|
||||
def create_launch_command(self) -> list[str]:
|
||||
# Find solution or project file
|
||||
solution_or_project = find_solution_or_project_file(self._repository_root_path)
|
||||
|
||||
@@ -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,94 @@ 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
|
||||
log.info("Running .NET install script: %s", cmd)
|
||||
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
|
||||
message = f"Failed to install .NET {version} runtime: {e}"
|
||||
if is_windows and isinstance(e, FileNotFoundError):
|
||||
message += (
|
||||
"; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system."
|
||||
)
|
||||
raise SolidLSPException(message) from e
|
||||
|
||||
def _get_initialize_params(self) -> InitializeParams:
|
||||
"""
|
||||
@@ -538,6 +614,8 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
)
|
||||
|
||||
def _start_server(self) -> None:
|
||||
indexing_complete = threading.Event()
|
||||
|
||||
def do_nothing(params: dict) -> None:
|
||||
return
|
||||
|
||||
@@ -645,7 +723,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 +777,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:
|
||||
"""
|
||||
|
||||
@@ -271,7 +271,7 @@ class EclipseJDTLS(SolidLanguageServer):
|
||||
intellisense_members_path=intellisense_members_path,
|
||||
)
|
||||
|
||||
def create_launch_command(self) -> list[str] | str:
|
||||
def create_launch_command(self) -> list[str]:
|
||||
# ws_dir is the workspace directory for the EclipseJDTLS server
|
||||
ws_dir = str(
|
||||
PurePath(
|
||||
@@ -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,14 +6,12 @@ Contains various configurations and settings specific to Markdown.
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import threading
|
||||
|
||||
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
|
||||
|
||||
from .common import RuntimeDependency, RuntimeDependencyCollection
|
||||
@@ -26,80 +24,83 @@ class Marksman(SolidLanguageServer):
|
||||
Provides Markdown specific instantiation of the LanguageServer class using marksman.
|
||||
"""
|
||||
|
||||
marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18"
|
||||
runtime_dependencies = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-linux-x64",
|
||||
platform_id="linux-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-linux-arm64",
|
||||
platform_id="linux-arm64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-macos",
|
||||
platform_id="osx-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-macos",
|
||||
platform_id="osx-arm64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman.exe",
|
||||
platform_id="win-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman.exe",
|
||||
),
|
||||
]
|
||||
)
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18"
|
||||
runtime_dependencies = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-linux-x64",
|
||||
platform_id="linux-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-linux-arm64",
|
||||
platform_id="linux-arm64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-macos",
|
||||
platform_id="osx-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman-macos",
|
||||
platform_id="osx-arm64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman",
|
||||
),
|
||||
RuntimeDependency(
|
||||
id="marksman",
|
||||
url=f"{marksman_releases}/marksman.exe",
|
||||
platform_id="win-x64",
|
||||
archive_type="binary",
|
||||
binary_name="marksman.exe",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
|
||||
"""Setup runtime dependencies for marksman and return the command to start the server."""
|
||||
deps = cls.runtime_dependencies
|
||||
dependency = deps.get_single_dep_for_current_platform()
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""Setup runtime dependencies for marksman and return the command to start the server."""
|
||||
deps = self.runtime_dependencies
|
||||
dependency = deps.get_single_dep_for_current_platform()
|
||||
|
||||
marksman_ls_dir = cls.ls_resources_dir(solidlsp_settings)
|
||||
marksman_executable_path = deps.binary_path(marksman_ls_dir)
|
||||
if not os.path.exists(marksman_executable_path):
|
||||
log.info(
|
||||
f"Downloading marksman from {dependency.url} to {marksman_ls_dir}",
|
||||
)
|
||||
deps.install(marksman_ls_dir)
|
||||
if not os.path.exists(marksman_executable_path):
|
||||
raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}")
|
||||
os.chmod(marksman_executable_path, 0o755)
|
||||
return marksman_executable_path
|
||||
marksman_ls_dir = self._ls_resources_dir
|
||||
marksman_executable_path = deps.binary_path(marksman_ls_dir)
|
||||
if not os.path.exists(marksman_executable_path):
|
||||
log.info(
|
||||
f"Downloading marksman from {dependency.url} to {marksman_ls_dir}",
|
||||
)
|
||||
deps.install(marksman_ls_dir)
|
||||
if not os.path.exists(marksman_executable_path):
|
||||
raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}")
|
||||
os.chmod(marksman_executable_path, 0o755)
|
||||
return marksman_executable_path
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path, "server"]
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
"""
|
||||
Creates a Marksman instance. This class is not meant to be instantiated directly.
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
marksman_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=f"{marksman_executable_path} server", cwd=repository_root_path),
|
||||
None,
|
||||
"markdown",
|
||||
solidlsp_settings,
|
||||
)
|
||||
self.server_ready = 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:
|
||||
@@ -181,5 +182,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()
|
||||
@@ -38,7 +38,7 @@ import platform
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
from typing import Any, Union, cast
|
||||
from typing import Any, cast
|
||||
|
||||
import requests
|
||||
|
||||
@@ -339,7 +339,7 @@ class MatlabLanguageServer(SolidLanguageServer):
|
||||
self._matlab_path = matlab_path
|
||||
return matlab_path
|
||||
|
||||
def create_launch_command(self) -> Union[str, list[str]]:
|
||||
def create_launch_command(self) -> list[str]:
|
||||
# Verify node is installed
|
||||
node_path = shutil.which("node")
|
||||
if node_path is None:
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Provides PHP specific instantiation of the LanguageServer class using Phpactor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from overrides import override
|
||||
|
||||
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
|
||||
from solidlsp.ls_config import Language, LanguageServerConfig
|
||||
from solidlsp.ls_utils import FileUtils
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PHPACTOR_VERSION = "2025.12.21.1"
|
||||
PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar"
|
||||
|
||||
|
||||
class PhpactorServer(SolidLanguageServer):
|
||||
"""
|
||||
Provides PHP specific instantiation of the LanguageServer class using Phpactor.
|
||||
|
||||
Phpactor is an open-source (MIT) PHP language server that requires PHP 8.1+ on the system.
|
||||
It is an alternative to Intelephense, which is the default PHP language server.
|
||||
|
||||
You can pass the following entries in ls_specific_settings["php_phpactor"]:
|
||||
- ignore_vendor: whether to ignore directories named "vendor" (default: true)
|
||||
"""
|
||||
|
||||
@override
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames
|
||||
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Phpactor and return the path to the PHAR file.
|
||||
"""
|
||||
# Verify PHP is installed
|
||||
php_path = shutil.which("php")
|
||||
assert (
|
||||
php_path is not None
|
||||
), "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again."
|
||||
|
||||
# Check PHP version (Phpactor requires PHP 8.1+)
|
||||
result = subprocess.run(["php", "--version"], capture_output=True, text=True, check=False)
|
||||
php_version_output = result.stdout.strip()
|
||||
log.info(f"PHP version: {php_version_output}")
|
||||
version_match = re.search(r"PHP (\d+)\.(\d+)", php_version_output)
|
||||
if version_match:
|
||||
major, minor = int(version_match.group(1)), int(version_match.group(2))
|
||||
if major < 8 or (major == 8 and minor < 1):
|
||||
raise RuntimeError(f"PHP {major}.{minor} detected, but Phpactor requires PHP 8.1+. Please upgrade PHP.")
|
||||
else:
|
||||
log.warning("Could not parse PHP version from output. Continuing anyway.")
|
||||
|
||||
phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar")
|
||||
if not os.path.exists(phpactor_phar_path):
|
||||
os.makedirs(self._ls_resources_dir, exist_ok=True)
|
||||
log.info(f"Downloading phpactor PHAR from {PHPACTOR_PHAR_URL}")
|
||||
FileUtils.download_and_extract_archive(PHPACTOR_PHAR_URL, phpactor_phar_path, "binary")
|
||||
|
||||
assert os.path.exists(phpactor_phar_path), f"phpactor PHAR not found at {phpactor_phar_path}, download may have failed."
|
||||
|
||||
# Ensure the PHAR is executable
|
||||
current_mode = os.stat(phpactor_phar_path).st_mode
|
||||
os.chmod(phpactor_phar_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
return phpactor_phar_path
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return ["php", core_path, "language-server"]
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
super().__init__(config, repository_root_path, None, "php", solidlsp_settings)
|
||||
# Override internal language enum for correct file matching
|
||||
self.language = Language.PHP_PHPACTOR
|
||||
|
||||
self._ignored_dirnames = {"node_modules", "cache"}
|
||||
if self._custom_settings.get("ignore_vendor", True):
|
||||
self._ignored_dirnames.add("vendor")
|
||||
log.info(f"Ignoring the following directories for PHP (Phpactor): {', '.join(sorted(self._ignored_dirnames))}")
|
||||
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
|
||||
"""
|
||||
Returns the initialization params for the Phpactor Language Server.
|
||||
"""
|
||||
root_uri = pathlib.Path(repository_absolute_path).as_uri()
|
||||
initialize_params = {
|
||||
"processId": os.getpid(),
|
||||
"rootPath": repository_absolute_path,
|
||||
"rootUri": root_uri,
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"synchronization": {"didSave": True, "dynamicRegistration": True},
|
||||
"definition": {"dynamicRegistration": True},
|
||||
"documentSymbol": {
|
||||
"hierarchicalDocumentSymbolSupport": True,
|
||||
"symbolKind": {"valueSet": list(range(1, 27))},
|
||||
},
|
||||
},
|
||||
"workspace": {
|
||||
"workspaceFolders": True,
|
||||
"didChangeConfiguration": {"dynamicRegistration": True},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{
|
||||
"uri": root_uri,
|
||||
"name": os.path.basename(repository_absolute_path),
|
||||
}
|
||||
],
|
||||
"initializationOptions": {
|
||||
"language_server_phpstan.enabled": False,
|
||||
"language_server_psalm.enabled": False,
|
||||
"language_server_php_cs_fixer.enabled": False,
|
||||
},
|
||||
}
|
||||
return initialize_params # type: ignore
|
||||
|
||||
def _start_server(self) -> None:
|
||||
"""Start Phpactor server process."""
|
||||
|
||||
def register_capability_handler(params: dict) -> None:
|
||||
return
|
||||
|
||||
def window_log_message(msg: dict) -> None:
|
||||
log.info(f"LSP: window/logMessage: {msg}")
|
||||
|
||||
def do_nothing(params: dict) -> None:
|
||||
return
|
||||
|
||||
self.server.on_request("client/registerCapability", register_capability_handler)
|
||||
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 Phpactor server process")
|
||||
self.server.start()
|
||||
initialize_params = self._get_initialize_params(self.repository_root_path)
|
||||
|
||||
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
|
||||
init_response = self.server.send.initialize(initialize_params)
|
||||
log.info("After sent initialize params")
|
||||
|
||||
# Verify server capabilities
|
||||
assert "capabilities" in init_response
|
||||
assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support"
|
||||
|
||||
self.server.notify.initialized({})
|
||||
@@ -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]:
|
||||
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()
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
|
||||
from overrides import override
|
||||
|
||||
@@ -23,16 +24,99 @@ if not PlatformUtils.get_platform_id().value.startswith("win"):
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Default configuration constants
|
||||
DEFAULT_METALS_VERSION = "1.6.4"
|
||||
DEFAULT_CLIENT_NAME = "Serena"
|
||||
DEFAULT_ON_STALE_LOCK = "auto-clean"
|
||||
DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True
|
||||
|
||||
|
||||
class StaleLockMode(Enum):
|
||||
"""Mode for handling stale Metals H2 database locks."""
|
||||
|
||||
AUTO_CLEAN = "auto-clean"
|
||||
"""Automatically remove stale lock files (default, recommended)."""
|
||||
|
||||
WARN = "warn"
|
||||
"""Log a warning but proceed; may result in degraded experience."""
|
||||
|
||||
FAIL = "fail"
|
||||
"""Raise an error and refuse to start."""
|
||||
|
||||
|
||||
def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]:
|
||||
"""
|
||||
Extract Scala-specific settings with defaults applied.
|
||||
|
||||
Returns a dictionary with keys:
|
||||
- metals_version: str
|
||||
- client_name: str
|
||||
- on_stale_lock: StaleLockMode
|
||||
- log_multi_instance_notice: bool
|
||||
"""
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
defaults: dict[str, object] = {
|
||||
"metals_version": DEFAULT_METALS_VERSION,
|
||||
"client_name": DEFAULT_CLIENT_NAME,
|
||||
"on_stale_lock": StaleLockMode.AUTO_CLEAN,
|
||||
"log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE,
|
||||
}
|
||||
|
||||
if not solidlsp_settings.ls_specific_settings:
|
||||
return defaults
|
||||
|
||||
scala_settings = solidlsp_settings.get_ls_specific_settings(Language.SCALA)
|
||||
|
||||
# Parse stale lock mode with validation
|
||||
on_stale_lock_str = scala_settings.get("on_stale_lock", DEFAULT_ON_STALE_LOCK)
|
||||
try:
|
||||
on_stale_lock = StaleLockMode(on_stale_lock_str)
|
||||
except ValueError:
|
||||
log.warning(f"Invalid on_stale_lock value '{on_stale_lock_str}', using '{DEFAULT_ON_STALE_LOCK}'")
|
||||
on_stale_lock = StaleLockMode.AUTO_CLEAN
|
||||
|
||||
return {
|
||||
"metals_version": scala_settings.get("metals_version", DEFAULT_METALS_VERSION),
|
||||
"client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME),
|
||||
"on_stale_lock": on_stale_lock,
|
||||
"log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE),
|
||||
}
|
||||
|
||||
|
||||
class ScalaLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala.
|
||||
Provides Scala specific instantiation of the LanguageServer class.
|
||||
Contains various configurations and settings specific to Scala.
|
||||
|
||||
Configurable options in ls_specific_settings (in serena_config.yml):
|
||||
|
||||
ls_specific_settings:
|
||||
scala:
|
||||
# Stale lock handling: auto-clean | warn | fail
|
||||
on_stale_lock: 'auto-clean'
|
||||
# Log notice when another Metals instance is detected
|
||||
log_multi_instance_notice: true
|
||||
# Metals version to bootstrap (default: DEFAULT_METALS_VERSION)
|
||||
metals_version: '1.6.4'
|
||||
# Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME)
|
||||
client_name: 'Serena'
|
||||
|
||||
Multi-instance support:
|
||||
Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple
|
||||
concurrent instances sharing the same database. Running Serena's Metals
|
||||
alongside VS Code's Metals is designed to work. The only issue is stale
|
||||
locks from crashed processes, which this class can detect and clean up.
|
||||
"""
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
"""
|
||||
Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
|
||||
Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly.
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
# Check for stale locks before setting up dependencies (fail-fast)
|
||||
self._check_metals_db_status(repository_root_path, solidlsp_settings)
|
||||
|
||||
scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
|
||||
super().__init__(
|
||||
config,
|
||||
@@ -42,6 +126,62 @@ class ScalaLanguageServer(SolidLanguageServer):
|
||||
solidlsp_settings,
|
||||
)
|
||||
|
||||
def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None:
|
||||
"""
|
||||
Check the Metals H2 database status and handle stale locks.
|
||||
|
||||
This method is called before setting up runtime dependencies to fail-fast
|
||||
if there's a stale lock that the user has configured to fail on.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from solidlsp.ls_exceptions import MetalsStaleLockError
|
||||
from solidlsp.util.metals_db_utils import (
|
||||
MetalsDbStatus,
|
||||
check_metals_db_status,
|
||||
cleanup_stale_lock,
|
||||
)
|
||||
|
||||
project_path = Path(repository_root_path)
|
||||
status, lock_info = check_metals_db_status(project_path)
|
||||
|
||||
# Get settings using the shared helper function
|
||||
settings = _get_scala_settings(solidlsp_settings)
|
||||
on_stale_lock: StaleLockMode = settings["on_stale_lock"] # type: ignore[assignment]
|
||||
log_multi_instance_notice: bool = settings["log_multi_instance_notice"] # type: ignore[assignment]
|
||||
|
||||
if status == MetalsDbStatus.ACTIVE_INSTANCE:
|
||||
if log_multi_instance_notice and lock_info:
|
||||
log.info(
|
||||
f"Another Metals instance detected (PID: {lock_info.pid}). "
|
||||
"This is fine - Metals supports multiple instances via H2 AUTO_SERVER. "
|
||||
"Both instances will share the database and Bloop build server."
|
||||
)
|
||||
|
||||
elif status == MetalsDbStatus.STALE_LOCK:
|
||||
lock_path = lock_info.lock_path if lock_info else project_path / ".metals" / "metals.mv.db.lock.db"
|
||||
lock_path_str = str(lock_path)
|
||||
|
||||
if on_stale_lock == StaleLockMode.AUTO_CLEAN:
|
||||
log.info(f"Stale Metals lock detected, cleaning up: {lock_path_str}")
|
||||
cleanup_success = cleanup_stale_lock(lock_path)
|
||||
if not cleanup_success:
|
||||
log.warning(
|
||||
f"Failed to clean up stale lock at {lock_path_str}. "
|
||||
"Metals may fall back to in-memory database (degraded experience)."
|
||||
)
|
||||
|
||||
elif on_stale_lock == StaleLockMode.WARN:
|
||||
log.warning(
|
||||
f"Stale Metals lock detected at {lock_path_str}. "
|
||||
"A previous Metals process may have crashed. "
|
||||
"Metals will fall back to in-memory database (degraded experience). "
|
||||
"Consider removing the lock file manually or setting on_stale_lock='auto-clean'."
|
||||
)
|
||||
|
||||
elif on_stale_lock == StaleLockMode.FAIL:
|
||||
raise MetalsStaleLockError(lock_path_str)
|
||||
|
||||
@override
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
return super().is_ignored_dirname(dirname) or dirname in [
|
||||
@@ -57,7 +197,10 @@ class ScalaLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
assert shutil.which("java") is not None, "JDK is not installed or not in PATH."
|
||||
|
||||
metals_version = "1.6.4"
|
||||
# Get settings using the shared helper function
|
||||
settings = _get_scala_settings(solidlsp_settings)
|
||||
metals_version: str = settings["metals_version"] # type: ignore[assignment]
|
||||
client_name: str = settings["client_name"] # type: ignore[assignment]
|
||||
|
||||
metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp")
|
||||
os.makedirs(metals_home, exist_ok=True)
|
||||
@@ -98,7 +241,7 @@ class ScalaLanguageServer(SolidLanguageServer):
|
||||
"--java-opt",
|
||||
"-Xms100m",
|
||||
"--java-opt",
|
||||
"-Dmetals.client=Serena",
|
||||
f"-Dmetals.client={client_name}",
|
||||
artifact,
|
||||
"-o",
|
||||
metals_executable,
|
||||
|
||||
@@ -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,18 +11,16 @@ import platform
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
# Download timeout in seconds (prevents indefinite hangs)
|
||||
DOWNLOAD_TIMEOUT_SECONDS = 120
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerConfig
|
||||
from solidlsp.ls_utils import PathUtils
|
||||
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__)
|
||||
@@ -120,116 +118,120 @@ class TaploServer(SolidLanguageServer):
|
||||
Creates a TaploServer instance. This class is not meant to be instantiated directly.
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
taplo_executable_path = self._setup_runtime_dependencies(solidlsp_settings)
|
||||
super().__init__(
|
||||
config,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=f"{taplo_executable_path} lsp stdio", cwd=repository_root_path),
|
||||
None,
|
||||
"toml",
|
||||
solidlsp_settings,
|
||||
)
|
||||
self.server_ready = threading.Event()
|
||||
|
||||
@classmethod
|
||||
def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Taplo and return the command to start the server.
|
||||
"""
|
||||
# First check if taplo is already installed system-wide
|
||||
system_taplo = shutil.which("taplo")
|
||||
if system_taplo:
|
||||
log.info(f"Using system-installed Taplo at: {system_taplo}")
|
||||
return system_taplo
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
# Setup local installation directory
|
||||
taplo_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "taplo")
|
||||
os.makedirs(taplo_dir, exist_ok=True)
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Taplo and return the command to start the server.
|
||||
"""
|
||||
# First check if taplo is already installed system-wide
|
||||
system_taplo = shutil.which("taplo")
|
||||
if system_taplo:
|
||||
log.info(f"Using system-installed Taplo at: {system_taplo}")
|
||||
return system_taplo
|
||||
|
||||
_, executable_name = _get_taplo_download_url()
|
||||
taplo_executable = os.path.join(taplo_dir, executable_name)
|
||||
# Setup local installation directory
|
||||
taplo_dir = os.path.join(self._ls_resources_dir, "taplo")
|
||||
os.makedirs(taplo_dir, exist_ok=True)
|
||||
|
||||
if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK):
|
||||
log.info(f"Using cached Taplo at: {taplo_executable}")
|
||||
return taplo_executable
|
||||
_, executable_name = _get_taplo_download_url()
|
||||
taplo_executable = os.path.join(taplo_dir, executable_name)
|
||||
|
||||
# Download and install Taplo
|
||||
log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...")
|
||||
cls._download_taplo(taplo_dir, taplo_executable)
|
||||
if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK):
|
||||
log.info(f"Using cached Taplo at: {taplo_executable}")
|
||||
return taplo_executable
|
||||
|
||||
if not os.path.exists(taplo_executable):
|
||||
raise FileNotFoundError(
|
||||
f"Taplo executable not found at {taplo_executable}. "
|
||||
"Installation may have failed. Try installing manually: cargo install taplo-cli --locked"
|
||||
)
|
||||
# Download and install Taplo
|
||||
log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...")
|
||||
self._download_taplo(taplo_dir, taplo_executable)
|
||||
|
||||
return taplo_executable
|
||||
|
||||
@classmethod
|
||||
def _download_taplo(cls, install_dir: str, executable_path: str) -> None:
|
||||
"""Download and extract Taplo binary with SHA256 verification."""
|
||||
# TODO: consider using existing download utilities in SolidLSP instead of the custom logic here
|
||||
download_url, _ = _get_taplo_download_url()
|
||||
archive_filename = os.path.basename(download_url)
|
||||
|
||||
try:
|
||||
log.info(f"Downloading Taplo from: {download_url}")
|
||||
archive_path = os.path.join(install_dir, archive_filename)
|
||||
|
||||
# Download the archive with timeout to prevent indefinite hangs
|
||||
old_timeout = socket.getdefaulttimeout()
|
||||
try:
|
||||
socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS)
|
||||
urllib.request.urlretrieve(download_url, archive_path)
|
||||
finally:
|
||||
socket.setdefaulttimeout(old_timeout)
|
||||
|
||||
# Verify SHA256 checksum
|
||||
expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename)
|
||||
if expected_hash:
|
||||
if not _verify_sha256(archive_path, expected_hash):
|
||||
os.remove(archive_path)
|
||||
raise RuntimeError(
|
||||
f"SHA256 checksum verification failed for {archive_filename}. "
|
||||
"The downloaded file may be corrupted or tampered with. "
|
||||
"Try installing manually: cargo install taplo-cli --locked"
|
||||
)
|
||||
log.info(f"SHA256 checksum verified for {archive_filename}")
|
||||
else:
|
||||
log.warning(
|
||||
f"No SHA256 checksum available for {archive_filename}. "
|
||||
"Skipping verification - consider installing manually: cargo install taplo-cli --locked"
|
||||
if not os.path.exists(taplo_executable):
|
||||
raise FileNotFoundError(
|
||||
f"Taplo executable not found at {taplo_executable}. "
|
||||
"Installation may have failed. Try installing manually: cargo install taplo-cli --locked"
|
||||
)
|
||||
|
||||
# Extract based on format
|
||||
if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"):
|
||||
# Single file gzip
|
||||
with gzip.open(archive_path, "rb") as f_in:
|
||||
with open(executable_path, "wb") as f_out:
|
||||
f_out.write(f_in.read())
|
||||
elif archive_path.endswith(".zip"):
|
||||
import zipfile
|
||||
return taplo_executable
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
||||
# Security: Validate paths to prevent zip slip vulnerability
|
||||
for member in zip_ref.namelist():
|
||||
member_path = os.path.normpath(os.path.join(install_dir, member))
|
||||
if not member_path.startswith(os.path.normpath(install_dir)):
|
||||
raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory")
|
||||
zip_ref.extractall(install_dir)
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path, "lsp", "stdio"]
|
||||
|
||||
# Make executable on Unix systems
|
||||
if os.name != "nt":
|
||||
os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
@classmethod
|
||||
def _download_taplo(cls, install_dir: str, executable_path: str) -> None:
|
||||
"""Download and extract Taplo binary with SHA256 verification."""
|
||||
# TODO: consider using existing download utilities in SolidLSP instead of the custom logic here
|
||||
download_url, _ = _get_taplo_download_url()
|
||||
archive_filename = os.path.basename(download_url)
|
||||
|
||||
# Clean up archive
|
||||
os.remove(archive_path)
|
||||
log.info(f"Taplo installed successfully at: {executable_path}")
|
||||
try:
|
||||
log.info(f"Downloading Taplo from: {download_url}")
|
||||
archive_path = os.path.join(install_dir, archive_filename)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download Taplo: {e}")
|
||||
raise RuntimeError(
|
||||
f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked"
|
||||
) from e
|
||||
# Download the archive with timeout to prevent indefinite hangs
|
||||
old_timeout = socket.getdefaulttimeout()
|
||||
try:
|
||||
socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS)
|
||||
urllib.request.urlretrieve(download_url, archive_path)
|
||||
finally:
|
||||
socket.setdefaulttimeout(old_timeout)
|
||||
|
||||
# Verify SHA256 checksum
|
||||
expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename)
|
||||
if expected_hash:
|
||||
if not _verify_sha256(archive_path, expected_hash):
|
||||
os.remove(archive_path)
|
||||
raise RuntimeError(
|
||||
f"SHA256 checksum verification failed for {archive_filename}. "
|
||||
"The downloaded file may be corrupted or tampered with. "
|
||||
"Try installing manually: cargo install taplo-cli --locked"
|
||||
)
|
||||
log.info(f"SHA256 checksum verified for {archive_filename}")
|
||||
else:
|
||||
log.warning(
|
||||
f"No SHA256 checksum available for {archive_filename}. "
|
||||
"Skipping verification - consider installing manually: cargo install taplo-cli --locked"
|
||||
)
|
||||
|
||||
# Extract based on format
|
||||
if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"):
|
||||
# Single file gzip
|
||||
with gzip.open(archive_path, "rb") as f_in:
|
||||
with open(executable_path, "wb") as f_out:
|
||||
f_out.write(f_in.read())
|
||||
elif archive_path.endswith(".zip"):
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
||||
# Security: Validate paths to prevent zip slip vulnerability
|
||||
for member in zip_ref.namelist():
|
||||
member_path = os.path.normpath(os.path.join(install_dir, member))
|
||||
if not member_path.startswith(os.path.normpath(install_dir)):
|
||||
raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory")
|
||||
zip_ref.extractall(install_dir)
|
||||
|
||||
# Make executable on Unix systems
|
||||
if os.name != "nt":
|
||||
os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# Clean up archive
|
||||
os.remove(archive_path)
|
||||
log.info(f"Taplo installed successfully at: {executable_path}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download Taplo: {e}")
|
||||
raise RuntimeError(
|
||||
f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
|
||||
@@ -308,8 +310,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,14 +7,12 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
|
||||
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__)
|
||||
@@ -47,56 +45,61 @@ class YamlLanguageServer(SolidLanguageServer):
|
||||
Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly.
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
yaml_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
|
||||
super().__init__(
|
||||
config,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=yaml_lsp_executable_path, cwd=repository_root_path),
|
||||
None,
|
||||
"yaml",
|
||||
solidlsp_settings,
|
||||
)
|
||||
self.server_ready = threading.Event()
|
||||
|
||||
@classmethod
|
||||
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for YAML Language Server and return the command to start the server.
|
||||
"""
|
||||
# Verify both node and npm are installed
|
||||
is_node_installed = shutil.which("node") is not None
|
||||
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
|
||||
is_npm_installed = shutil.which("npm") is not None
|
||||
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
deps = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="yaml-language-server",
|
||||
description="yaml-language-server package (Red Hat)",
|
||||
command="npm install --prefix ./ yaml-language-server@1.19.2",
|
||||
platform_id="any",
|
||||
),
|
||||
]
|
||||
)
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for YAML Language Server and return the command to start the server.
|
||||
"""
|
||||
# Verify both node and npm are installed
|
||||
is_node_installed = shutil.which("node") is not None
|
||||
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
|
||||
is_npm_installed = shutil.which("npm") is not None
|
||||
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
|
||||
|
||||
# Install yaml-language-server if not already installed
|
||||
yaml_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "yaml-lsp")
|
||||
yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server")
|
||||
|
||||
# Handle Windows executable extension
|
||||
if os.name == "nt":
|
||||
yaml_executable_path += ".cmd"
|
||||
|
||||
if not os.path.exists(yaml_executable_path):
|
||||
log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...")
|
||||
deps.install(yaml_ls_dir)
|
||||
log.info("YAML language server dependencies installed successfully")
|
||||
|
||||
if not os.path.exists(yaml_executable_path):
|
||||
raise FileNotFoundError(
|
||||
f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation."
|
||||
deps = RuntimeDependencyCollection(
|
||||
[
|
||||
RuntimeDependency(
|
||||
id="yaml-language-server",
|
||||
description="yaml-language-server package (Red Hat)",
|
||||
command="npm install --prefix ./ yaml-language-server@1.19.2",
|
||||
platform_id="any",
|
||||
),
|
||||
]
|
||||
)
|
||||
return f"{yaml_executable_path} --stdio"
|
||||
|
||||
# Install yaml-language-server if not already installed
|
||||
yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp")
|
||||
yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server")
|
||||
|
||||
# Handle Windows executable extension
|
||||
if os.name == "nt":
|
||||
yaml_executable_path += ".cmd"
|
||||
|
||||
if not os.path.exists(yaml_executable_path):
|
||||
log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...")
|
||||
deps.install(yaml_ls_dir)
|
||||
log.info("YAML language server dependencies installed successfully")
|
||||
|
||||
if not os.path.exists(yaml_executable_path):
|
||||
raise FileNotFoundError(
|
||||
f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation."
|
||||
)
|
||||
|
||||
return yaml_executable_path
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path, "--stdio"]
|
||||
|
||||
@staticmethod
|
||||
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
|
||||
@@ -184,5 +187,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")
|
||||
|
||||
+142
-59
@@ -18,13 +18,14 @@ from typing import Self, Union, cast
|
||||
|
||||
import pathspec
|
||||
from sensai.util.pickle import getstate, load_pickle
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
from serena.text_utils import MatchedConsecutiveLines
|
||||
from serena.util.file_system import match_path
|
||||
from solidlsp import ls_types
|
||||
from solidlsp.ls_config import Language, LanguageServerConfig
|
||||
from solidlsp.ls_exceptions import SolidLSPException
|
||||
from solidlsp.ls_handler import SolidLanguageServerHandler
|
||||
from solidlsp.ls_process import LanguageServerProcess
|
||||
from solidlsp.ls_types import UnifiedSymbolInformation
|
||||
from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils
|
||||
from solidlsp.lsp_protocol_handler import lsp_types
|
||||
@@ -67,14 +68,90 @@ 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,
|
||||
abs_path: Path,
|
||||
uri: str,
|
||||
encoding: str,
|
||||
version: int,
|
||||
language_id: str,
|
||||
ref_count: int,
|
||||
language_server: "SolidLanguageServer",
|
||||
open_in_ls: bool = True,
|
||||
) -> None:
|
||||
self.abs_path = abs_path
|
||||
self.language_server = language_server
|
||||
self.uri = uri
|
||||
self.contents = contents
|
||||
self._read_file_modified_date: float | None = None
|
||||
self._contents: str | None = None
|
||||
self.version = version
|
||||
self.language_id = language_id
|
||||
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 contents(self) -> str:
|
||||
file_modified_date = self.abs_path.stat().st_mtime
|
||||
|
||||
# if contents are cached, check if they are stale (file modification since last read) and invalidate if so
|
||||
if self._contents is not None:
|
||||
assert self._read_file_modified_date is not None
|
||||
if file_modified_date > self._read_file_modified_date:
|
||||
self._contents = None
|
||||
|
||||
if self._contents is None:
|
||||
self._read_file_modified_date = file_modified_date
|
||||
self._contents = FileUtils.read_file(str(self.abs_path), self.encoding)
|
||||
self._content_hash = None
|
||||
|
||||
return self._contents
|
||||
|
||||
@contents.setter
|
||||
def contents(self, new_contents: str) -> None:
|
||||
"""
|
||||
Sets new contents for the file buffer (in-memory change only).
|
||||
Persistence of the change to disk must be handled separately.
|
||||
|
||||
:param new_contents: the new contents to set
|
||||
"""
|
||||
self._contents = new_contents
|
||||
self._content_hash = None
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
@@ -87,7 +164,7 @@ class LSPFileBuffer:
|
||||
return self.contents.split("\n")
|
||||
|
||||
|
||||
class SymbolBody:
|
||||
class SymbolBody(ToStringMixin):
|
||||
"""
|
||||
Representation of the body of a symbol, which allows the extraction of the symbol's text
|
||||
from the lines of the file it is defined in.
|
||||
@@ -104,14 +181,21 @@ class SymbolBody:
|
||||
self._end_line = end_line
|
||||
self._end_col = end_col
|
||||
|
||||
def _tostring_excludes(self) -> list[str]:
|
||||
return ["_lines"]
|
||||
|
||||
def get_text(self) -> str:
|
||||
# extract relevant lines
|
||||
symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1])
|
||||
|
||||
# remove leading indentation
|
||||
# remove leading content from the first line
|
||||
symbol_body = symbol_body[self._start_col :]
|
||||
|
||||
# TODO: handle end_col properly (this was never implemented)
|
||||
# remove trailing content from the last line
|
||||
last_line = self._lines[self._end_line]
|
||||
trailing_length = len(last_line) - self._end_col
|
||||
if trailing_length > 0:
|
||||
symbol_body = symbol_body[: -(len(last_line) - self._end_col)]
|
||||
|
||||
return symbol_body
|
||||
|
||||
@@ -190,13 +274,12 @@ class LanguageServerDependencyProvider(ABC):
|
||||
self._ls_resources_dir = ls_resources_dir
|
||||
|
||||
@abstractmethod
|
||||
def create_launch_command(self) -> list[str] | str:
|
||||
def create_launch_command(self) -> list[str]:
|
||||
"""
|
||||
Creates the launch command for this language server, potentially downloading and installing dependencies
|
||||
beforehand.
|
||||
|
||||
:return: the launch command as a list containing the executable and its arguments (preferred for robustness)
|
||||
or the entire command in a single string.
|
||||
:return: the launch command as a list containing the executable and its arguments
|
||||
"""
|
||||
|
||||
def create_launch_command_env(self) -> dict[str, str]:
|
||||
@@ -228,7 +311,7 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide
|
||||
:return: the core dependency's path (e.g. executable, jar, etc.)
|
||||
"""
|
||||
|
||||
def create_launch_command(self) -> Union[str, list[str]]:
|
||||
def create_launch_command(self) -> list[str]:
|
||||
path = self._custom_settings.get("ls_path", None)
|
||||
if path is not None:
|
||||
core_path = path
|
||||
@@ -237,11 +320,10 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide
|
||||
return self._create_launch_command(core_path)
|
||||
|
||||
@abstractmethod
|
||||
def _create_launch_command(self, core_path: str) -> list[str] | str:
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
"""
|
||||
:param core_path: path to the core dependency
|
||||
:return: the launch command as a list containing the executable and its arguments (preferred for robustness)
|
||||
or the entire command in a single string.
|
||||
:return: the launch command as a list containing the executable and its arguments
|
||||
"""
|
||||
|
||||
|
||||
@@ -412,7 +494,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:
|
||||
@@ -428,7 +509,7 @@ class SolidLanguageServer(ABC):
|
||||
self._dependency_provider = self._create_dependency_provider()
|
||||
process_launch_info = self._create_process_launch_info()
|
||||
log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}")
|
||||
self.server = SolidLanguageServerHandler(
|
||||
self.server = LanguageServerProcess(
|
||||
process_launch_info,
|
||||
language=self.language,
|
||||
determine_log_level=self._determine_log_level,
|
||||
@@ -614,70 +695,72 @@ 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")
|
||||
raise SolidLSPException("Language Server not started")
|
||||
|
||||
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
|
||||
uri = pathlib.Path(absolute_file_path).as_uri()
|
||||
absolute_file_path = Path(self.repository_root_path, relative_file_path)
|
||||
uri = 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(
|
||||
abs_path=absolute_file_path,
|
||||
uri=uri,
|
||||
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:
|
||||
@@ -865,14 +948,14 @@ 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
|
||||
|
||||
t0 = perf_counter() if _debug_enabled else 0.0
|
||||
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
|
||||
t0 = perf_counter() if _debug_enabled else 0.0
|
||||
try:
|
||||
response = self._send_references_request(relative_file_path, line=line, column=column)
|
||||
except Exception as e:
|
||||
@@ -1023,7 +1106,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}
|
||||
@@ -1152,7 +1234,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)
|
||||
@@ -1170,6 +1252,7 @@ class SolidLanguageServer(ABC):
|
||||
log.debug("perf: document_symbols_cache STALE path=%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:
|
||||
@@ -2142,7 +2225,7 @@ class SolidLanguageServer(ABC):
|
||||
return self
|
||||
|
||||
@property
|
||||
def handler(self) -> SolidLanguageServerHandler:
|
||||
def handler(self) -> LanguageServerProcess:
|
||||
"""Access the underlying language server handler.
|
||||
|
||||
Useful for advanced operations like sending custom commands
|
||||
|
||||
@@ -41,6 +41,7 @@ class Language(str, Enum):
|
||||
RUBY = "ruby"
|
||||
DART = "dart"
|
||||
CPP = "cpp"
|
||||
CPP_CCLS = "cpp_ccls"
|
||||
PHP = "php"
|
||||
R = "r"
|
||||
PERL = "perl"
|
||||
@@ -87,6 +88,10 @@ class Language(str, Enum):
|
||||
"""Solargraph language server for Ruby (legacy, experimental).
|
||||
Use Language.RUBY (ruby-lsp) for better performance and modern LSP features.
|
||||
"""
|
||||
PHP_PHPACTOR = "php_phpactor"
|
||||
"""Phpactor language server for PHP (instead of Intelephense, which is the default).
|
||||
Requires PHP 8.1+ on the system. Fully open-source (MIT license).
|
||||
"""
|
||||
MARKDOWN = "markdown"
|
||||
"""Marksman language server for Markdown (experimental).
|
||||
Must be explicitly specified as the main language, not auto-detected.
|
||||
@@ -120,10 +125,12 @@ class Language(str, Enum):
|
||||
self.PYTHON_JEDI,
|
||||
self.CSHARP_OMNISHARP,
|
||||
self.RUBY_SOLARGRAPH,
|
||||
self.PHP_PHPACTOR,
|
||||
self.MARKDOWN,
|
||||
self.YAML,
|
||||
self.TOML,
|
||||
self.GROOVY,
|
||||
self.CPP_CCLS,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -170,13 +177,13 @@ 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")
|
||||
case self.DART:
|
||||
return FilenameMatcher("*.dart")
|
||||
case self.PHP:
|
||||
case self.PHP | self.PHP_PHPACTOR:
|
||||
return FilenameMatcher("*.php")
|
||||
case self.R:
|
||||
return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw")
|
||||
@@ -304,10 +311,18 @@ 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
|
||||
|
||||
return Intelephense
|
||||
case self.PHP_PHPACTOR:
|
||||
from solidlsp.language_servers.phpactor import PhpactorServer
|
||||
|
||||
return PhpactorServer
|
||||
case self.PERL:
|
||||
from solidlsp.language_servers.perl_language_server import PerlLanguageServer
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class SolidLSPException(Exception):
|
||||
:return: True if the exception is caused by the language server having terminated as indicated
|
||||
by the causing exception being an instance of LanguageServerTerminatedException.
|
||||
"""
|
||||
from .ls_handler import LanguageServerTerminatedException
|
||||
from .ls_process import LanguageServerTerminatedException
|
||||
|
||||
return isinstance(self.cause, LanguageServerTerminatedException)
|
||||
|
||||
@@ -32,7 +32,7 @@ class SolidLSPException(Exception):
|
||||
"""
|
||||
:return: the affected language for the case where the exception is caused by the language server having terminated
|
||||
"""
|
||||
from .ls_handler import LanguageServerTerminatedException
|
||||
from .ls_process import LanguageServerTerminatedException
|
||||
|
||||
if isinstance(self.cause, LanguageServerTerminatedException):
|
||||
return self.cause.language
|
||||
@@ -50,3 +50,25 @@ class SolidLSPException(Exception):
|
||||
s += " "
|
||||
s += f"(caused by {self.cause})"
|
||||
return s
|
||||
|
||||
|
||||
class MetalsStaleLockError(SolidLSPException):
|
||||
"""
|
||||
Raised when a stale Metals H2 database lock is detected and the user
|
||||
has configured fail-on-stale-lock behavior.
|
||||
|
||||
A stale lock occurs when a previous Metals process crashed without
|
||||
cleaning up its lock file, which can prevent proper AUTO_SERVER
|
||||
coordination with new instances.
|
||||
"""
|
||||
|
||||
def __init__(self, lock_path: str, message: str | None = None) -> None:
|
||||
self.lock_path = lock_path
|
||||
if message is None:
|
||||
message = (
|
||||
f"Stale Metals lock file detected at {lock_path}. "
|
||||
"A previous Metals process may have crashed. "
|
||||
"To resolve: remove the lock file manually, or set "
|
||||
"on_stale_lock='auto-clean' in ls_specific_settings.scala."
|
||||
)
|
||||
super().__init__(message)
|
||||
@@ -22,7 +22,6 @@ from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes
|
||||
from solidlsp.lsp_protocol_handler.server import (
|
||||
ENCODING,
|
||||
LSPError,
|
||||
MessageType,
|
||||
PayloadLike,
|
||||
ProcessLaunchInfo,
|
||||
StringDict,
|
||||
@@ -93,11 +92,10 @@ class Request(ToStringMixin):
|
||||
raise e
|
||||
|
||||
|
||||
class SolidLanguageServerHandler:
|
||||
class LanguageServerProcess:
|
||||
"""
|
||||
This class provides the implementation of Python client for the Language Server Protocol.
|
||||
A class that launches the language server and communicates with it
|
||||
using the Language Server Protocol (LSP).
|
||||
Represents a language server process and provides methods for communicating with it using the
|
||||
Language Server Protocol (LSP).
|
||||
|
||||
It provides methods for sending requests, responses, and notifications to the server
|
||||
and for registering handlers for requests and notifications from the server.
|
||||
@@ -117,7 +115,7 @@ class SolidLanguageServerHandler:
|
||||
that handle requests from the server.
|
||||
on_notification_handlers: A dictionary that maps method names to callback functions
|
||||
that handle notifications from the server.
|
||||
logger: An optional function that takes two strings (source and destination) and
|
||||
_trace_log_fn: An optional function that takes two strings (source and destination) and
|
||||
a payload dictionary, and logs the communication between the client and the server.
|
||||
tasks: A dictionary that maps task ids to asyncio.Task objects that represent
|
||||
the asynchronous tasks created by the handler.
|
||||
@@ -152,7 +150,7 @@ class SolidLanguageServerHandler:
|
||||
self._pending_requests: dict[Any, Request] = {}
|
||||
self.on_request_handlers: dict[str, Callable[[Any], Any]] = {}
|
||||
self.on_notification_handlers: dict[str, Callable[[Any], None]] = {}
|
||||
self.logger = logger
|
||||
self._trace_log_fn = logger
|
||||
self.tasks: dict[int, Any] = {}
|
||||
self.task_counter = 0
|
||||
self.loop = None
|
||||
@@ -301,19 +299,19 @@ class SolidLanguageServerHandler:
|
||||
Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit
|
||||
"""
|
||||
self._is_shutting_down = True
|
||||
self._log("Sending shutdown request to server")
|
||||
log.info("Sending shutdown request to server")
|
||||
self.send.shutdown()
|
||||
self._log("Received shutdown response from server")
|
||||
self._log("Sending exit notification to server")
|
||||
log.info("Received shutdown response from server")
|
||||
log.info("Sending exit notification to server")
|
||||
self.notify.exit()
|
||||
self._log("Sent exit notification to server")
|
||||
log.info("Sent exit notification to server")
|
||||
|
||||
def _log(self, message: str | StringDict) -> None:
|
||||
def _trace(self, src: str, dest: str, message: str | StringDict) -> None:
|
||||
"""
|
||||
Create a log message
|
||||
Traces LS communication by logging the message with the source and destination of the message
|
||||
"""
|
||||
if self.logger is not None:
|
||||
self.logger("client", "logger", message)
|
||||
if self._trace_log_fn is not None:
|
||||
self._trace_log_fn(src, dest, message)
|
||||
|
||||
def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: # type: ignore
|
||||
"""Read exactly num_bytes from process stdout"""
|
||||
@@ -402,18 +400,17 @@ class SolidLanguageServerHandler:
|
||||
try:
|
||||
self._receive_payload(json.loads(body))
|
||||
except OSError as ex:
|
||||
self._log(f"malformed {ENCODING}: {ex}")
|
||||
log.error(f"Error processing payload: {ex}", exc_info=ex)
|
||||
except UnicodeDecodeError as ex:
|
||||
self._log(f"malformed {ENCODING}: {ex}")
|
||||
log.error(f"Decoding error for encoding={ENCODING}: {ex}")
|
||||
except json.JSONDecodeError as ex:
|
||||
self._log(f"malformed JSON: {ex}")
|
||||
log.error(f"JSON decoding error: {ex}")
|
||||
|
||||
def _receive_payload(self, payload: StringDict) -> None:
|
||||
"""
|
||||
Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler
|
||||
"""
|
||||
if self.logger:
|
||||
self.logger("server", "client", payload)
|
||||
self._trace("ls", "solidlsp", payload)
|
||||
try:
|
||||
if "method" in payload:
|
||||
if "id" in payload:
|
||||
@@ -423,9 +420,9 @@ class SolidLanguageServerHandler:
|
||||
elif "id" in payload:
|
||||
self._response_handler(payload)
|
||||
else:
|
||||
self._log(f"Unknown payload type: {payload}")
|
||||
log.error(f"Unknown payload type: {payload}")
|
||||
except Exception as err:
|
||||
self._log(f"Error handling server payload: {err}")
|
||||
log.error(f"Error handling server payload: {err}")
|
||||
|
||||
def send_notification(self, method: str, params: dict | None = None) -> None:
|
||||
"""
|
||||
@@ -443,7 +440,6 @@ class SolidLanguageServerHandler:
|
||||
"""
|
||||
Send error response to the given request id to the server with the given error
|
||||
"""
|
||||
# Use lock to prevent race conditions on tasks and task_counter
|
||||
self._send_payload(make_error_response(request_id, err))
|
||||
|
||||
def _cancel_pending_requests(self, exception: Exception) -> None:
|
||||
@@ -473,15 +469,14 @@ class SolidLanguageServerHandler:
|
||||
|
||||
self._send_payload(make_request(method, request_id, params))
|
||||
|
||||
self._log(f"Waiting for response to request {method} with params:\n{params}")
|
||||
log.debug("Waiting for response to request %s with params:\n%s", method, params)
|
||||
result = request.get_result(timeout=self._request_timeout)
|
||||
log.debug("Completed: %s", request)
|
||||
|
||||
self._log("Processing result")
|
||||
if result.is_error():
|
||||
raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error
|
||||
|
||||
self._log(f"Returning non-error result, which is:\n{result.payload}")
|
||||
log.debug("Returning result:\n%s", result.payload)
|
||||
return result.payload
|
||||
|
||||
def _send_payload(self, payload: StringDict) -> None:
|
||||
@@ -490,7 +485,7 @@ class SolidLanguageServerHandler:
|
||||
"""
|
||||
if not self.process or not self.process.stdin:
|
||||
return
|
||||
self._log(payload)
|
||||
self._trace("solidlsp", "ls", payload)
|
||||
msg = create_message(payload)
|
||||
|
||||
# Use lock to prevent concurrent writes to stdin that cause buffer corruption
|
||||
@@ -500,8 +495,7 @@ class SolidLanguageServerHandler:
|
||||
self.process.stdin.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
||||
# Log the error but don't raise to prevent cascading failures
|
||||
if self.logger:
|
||||
self.logger("client", "logger", f"Failed to write to stdin: {e}")
|
||||
log.error(f"Failed to write to stdin: {e}")
|
||||
return
|
||||
|
||||
def on_request(self, method: str, cb: Callable[[Any], Any]) -> None:
|
||||
@@ -569,23 +563,12 @@ class SolidLanguageServerHandler:
|
||||
params = response.get("params")
|
||||
handler = self.on_notification_handlers.get(method)
|
||||
if not handler:
|
||||
self._log(f"unhandled {method}")
|
||||
log.warning("Unhandled method '%s'", method)
|
||||
return
|
||||
try:
|
||||
handler(params)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as ex:
|
||||
if (not self._is_shutting_down) and self.logger:
|
||||
self.logger(
|
||||
"client",
|
||||
"logger",
|
||||
str(
|
||||
{
|
||||
"type": MessageType.error,
|
||||
"message": str(ex),
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
),
|
||||
)
|
||||
if not self._is_shutting_down:
|
||||
log.error("Error handling notification for method '%s': %s", method, ex, exc_info=ex)
|
||||
@@ -3,11 +3,11 @@ from typing import TYPE_CHECKING, Any, Union
|
||||
from solidlsp.lsp_protocol_handler import lsp_types
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ls_handler import SolidLanguageServerHandler
|
||||
from .ls_process import LanguageServerProcess
|
||||
|
||||
|
||||
class LanguageServerRequest:
|
||||
def __init__(self, handler: "SolidLanguageServerHandler"):
|
||||
def __init__(self, handler: "LanguageServerProcess"):
|
||||
self.handler = handler
|
||||
|
||||
def _send_request(self, method: str, params: Any | None = None) -> Any:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Utilities for detecting and managing Scala Metals H2 database state.
|
||||
|
||||
This module provides functions to detect existing Metals LSP instances by checking
|
||||
the H2 database lock file, and to clean up stale locks from crashed processes.
|
||||
|
||||
Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent
|
||||
instances sharing the same database. However, if a Metals process crashes without
|
||||
proper cleanup, it can leave a stale lock file that prevents proper AUTO_SERVER
|
||||
coordination, causing new instances to fall back to in-memory database mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetalsDbStatus(Enum):
|
||||
"""Status of the Metals H2 database for a project."""
|
||||
|
||||
NO_DATABASE = "no_database"
|
||||
"""No .metals directory or database exists (fresh project)."""
|
||||
|
||||
NO_LOCK = "no_lock"
|
||||
"""Database exists but no lock file (safe to start)."""
|
||||
|
||||
ACTIVE_INSTANCE = "active_instance"
|
||||
"""Lock held by a running process (will share via AUTO_SERVER)."""
|
||||
|
||||
STALE_LOCK = "stale_lock"
|
||||
"""Lock held by a dead process (needs cleanup)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetalsLockInfo:
|
||||
"""Information extracted from an H2 database lock file."""
|
||||
|
||||
pid: int | None
|
||||
"""Process ID that holds the lock, if parseable."""
|
||||
|
||||
port: int | None
|
||||
"""TCP port for AUTO_SERVER connection, if parseable."""
|
||||
|
||||
lock_path: Path
|
||||
"""Path to the lock file."""
|
||||
|
||||
is_stale: bool
|
||||
"""True if the owning process is no longer running."""
|
||||
|
||||
raw_content: str
|
||||
"""Raw content of the lock file for debugging."""
|
||||
|
||||
|
||||
def parse_h2_lock_file(lock_path: Path) -> MetalsLockInfo | None:
|
||||
"""
|
||||
Parse an H2 database lock file to extract connection information.
|
||||
|
||||
The H2 lock file format varies by version but typically contains
|
||||
server connection information. Common formats include:
|
||||
- Text format: "server:localhost:9092" or similar
|
||||
- Binary format with embedded PID
|
||||
|
||||
Args:
|
||||
lock_path: Path to the .lock.db file
|
||||
|
||||
Returns:
|
||||
MetalsLockInfo if the file can be parsed, None if file doesn't exist
|
||||
or is completely unparsable.
|
||||
|
||||
"""
|
||||
if not lock_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try reading as text first (most common for H2 AUTO_SERVER)
|
||||
content = lock_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
log.debug(f"Could not read lock file {lock_path}: {e}")
|
||||
return None
|
||||
|
||||
pid: int | None = None
|
||||
port: int | None = None
|
||||
|
||||
# Try to extract port from common H2 lock file formats
|
||||
# Format 1: "server:localhost:PORT"
|
||||
server_match = re.search(r"server:[\w.]+:(\d+)", content, re.IGNORECASE)
|
||||
if server_match:
|
||||
port = int(server_match.group(1))
|
||||
|
||||
# Format 2: Look for standalone port numbers (H2 uses ports in 9000+ range typically)
|
||||
if port is None:
|
||||
port_match = re.search(r"\b(9\d{3})\b", content)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
|
||||
# Try to extract PID - H2 may embed this in various formats
|
||||
pid_match = re.search(r"pid[=:]?\s*(\d+)", content, re.IGNORECASE)
|
||||
if pid_match:
|
||||
pid = int(pid_match.group(1))
|
||||
|
||||
# Check if the process is still alive
|
||||
is_stale = False
|
||||
if pid is not None:
|
||||
is_stale = not is_metals_process_alive(pid)
|
||||
elif port is not None:
|
||||
# If we have a port but no PID, try to find a Metals process using that port
|
||||
is_stale = not _is_port_in_use_by_metals(port)
|
||||
else:
|
||||
# Can't determine - assume stale if lock exists but we can't parse it
|
||||
# and no Metals processes are running for this project
|
||||
log.debug(f"Could not parse PID or port from lock file: {lock_path}")
|
||||
is_stale = True # Conservative: treat unparsable as stale
|
||||
|
||||
return MetalsLockInfo(
|
||||
pid=pid,
|
||||
port=port,
|
||||
lock_path=lock_path,
|
||||
is_stale=is_stale,
|
||||
raw_content=content[:200], # Truncate for logging
|
||||
)
|
||||
|
||||
|
||||
def is_metals_process_alive(pid: int) -> bool:
|
||||
"""
|
||||
Check if a process with the given PID is alive and is a Metals process.
|
||||
|
||||
Args:
|
||||
pid: Process ID to check
|
||||
|
||||
Returns:
|
||||
True if the process exists and appears to be a Metals LSP server.
|
||||
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
if not proc.is_running():
|
||||
return False
|
||||
|
||||
# Check if this is actually a Metals process
|
||||
cmdline = " ".join(proc.cmdline()).lower()
|
||||
return _is_metals_cmdline(cmdline)
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
return False
|
||||
except Exception as e:
|
||||
log.debug(f"Error checking process {pid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _is_metals_cmdline(cmdline: str) -> bool:
|
||||
"""Check if a command line string appears to be a Metals LSP server."""
|
||||
cmdline_lower = cmdline.lower()
|
||||
# Metals is a Scala/Java application
|
||||
if "java" not in cmdline_lower:
|
||||
return False
|
||||
# Look for Metals-specific identifiers
|
||||
return any(
|
||||
marker in cmdline_lower
|
||||
for marker in [
|
||||
"metals",
|
||||
"org.scalameta",
|
||||
"-dmetals.client",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _is_port_in_use_by_metals(port: int) -> bool:
|
||||
"""Check if the given port is in use by a Metals process."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for conn in psutil.net_connections(kind="tcp"):
|
||||
if conn.laddr.port == port and conn.status == "LISTEN":
|
||||
try:
|
||||
proc = psutil.Process(conn.pid)
|
||||
cmdline = " ".join(proc.cmdline()).lower()
|
||||
if _is_metals_cmdline(cmdline):
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
return False
|
||||
except (psutil.AccessDenied, OSError) as e:
|
||||
# On some systems, net_connections requires elevated privileges
|
||||
log.debug(f"Could not check port {port}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_metals_db_status(project_path: Path) -> tuple[MetalsDbStatus, MetalsLockInfo | None]:
|
||||
"""
|
||||
Check the status of the Metals H2 database for a project.
|
||||
|
||||
This function determines whether it's safe to start a new Metals instance
|
||||
and whether any cleanup is needed.
|
||||
|
||||
Args:
|
||||
project_path: Path to the project root directory
|
||||
|
||||
Returns:
|
||||
A tuple of (status, lock_info) where lock_info is populated for
|
||||
ACTIVE_INSTANCE and STALE_LOCK statuses.
|
||||
|
||||
"""
|
||||
metals_dir = project_path / ".metals"
|
||||
db_path = metals_dir / "metals.mv.db"
|
||||
lock_path = metals_dir / "metals.mv.db.lock.db"
|
||||
|
||||
if not metals_dir.exists():
|
||||
log.debug(f"No .metals directory found at {metals_dir}")
|
||||
return MetalsDbStatus.NO_DATABASE, None
|
||||
|
||||
if not db_path.exists():
|
||||
log.debug(f"No Metals database found at {db_path}")
|
||||
return MetalsDbStatus.NO_DATABASE, None
|
||||
|
||||
if not lock_path.exists():
|
||||
log.debug(f"Metals database exists but no lock file at {lock_path}")
|
||||
return MetalsDbStatus.NO_LOCK, None
|
||||
|
||||
# Lock file exists - parse it to determine status
|
||||
lock_info = parse_h2_lock_file(lock_path)
|
||||
|
||||
if lock_info is None:
|
||||
# Lock file exists but couldn't be read - treat as stale
|
||||
log.warning(f"Could not read lock file at {lock_path}, treating as stale")
|
||||
return MetalsDbStatus.STALE_LOCK, MetalsLockInfo(
|
||||
pid=None,
|
||||
port=None,
|
||||
lock_path=lock_path,
|
||||
is_stale=True,
|
||||
raw_content="<unreadable>",
|
||||
)
|
||||
|
||||
if lock_info.is_stale:
|
||||
log.debug(f"Stale Metals lock detected: {lock_info}")
|
||||
return MetalsDbStatus.STALE_LOCK, lock_info
|
||||
else:
|
||||
log.debug(f"Active Metals instance detected: {lock_info}")
|
||||
return MetalsDbStatus.ACTIVE_INSTANCE, lock_info
|
||||
|
||||
|
||||
def cleanup_stale_lock(lock_path: Path) -> bool:
|
||||
"""
|
||||
Remove a stale H2 database lock file.
|
||||
|
||||
This should only be called when we've verified the owning process is dead.
|
||||
Removing a lock file from a running process could cause database corruption.
|
||||
|
||||
Args:
|
||||
lock_path: Path to the .lock.db file to remove
|
||||
|
||||
Returns:
|
||||
True if cleanup succeeded, False otherwise.
|
||||
|
||||
"""
|
||||
if not lock_path.exists():
|
||||
log.debug(f"Lock file already removed: {lock_path}")
|
||||
return True
|
||||
|
||||
try:
|
||||
lock_path.unlink()
|
||||
log.info(f"Cleaned up stale Metals lock file: {lock_path}")
|
||||
return True
|
||||
except PermissionError as e:
|
||||
log.warning(f"Permission denied removing stale lock file {lock_path}: {e}")
|
||||
return False
|
||||
except OSError as e:
|
||||
log.warning(f"Could not remove stale lock file {lock_path}: {e}")
|
||||
return False
|
||||
+35
-3
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil as _sh
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
@@ -34,8 +36,17 @@ class LanguageParamRequest:
|
||||
param: Language
|
||||
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
|
||||
def get_repo_path(language: Language) -> Path:
|
||||
return Path(__file__).parent / "resources" / "repos" / language / "test_repo"
|
||||
repo_language = _LANGUAGE_REPO_ALIASES.get(language, language)
|
||||
return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo"
|
||||
|
||||
|
||||
def _create_ls(
|
||||
@@ -52,7 +63,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,
|
||||
@@ -102,7 +117,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer
|
||||
|
||||
def _create_default_project(language: Language) -> Project:
|
||||
repo_path = str(get_repo_path(language))
|
||||
return Project.load(repo_path)
|
||||
return Project.load(repo_path, serena_config=None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -195,6 +210,8 @@ is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
|
||||
Flag indicating whether the tests are running in the GitHub CI environment.
|
||||
"""
|
||||
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
|
||||
def _determine_disabled_languages() -> list[Language]:
|
||||
"""
|
||||
@@ -212,6 +229,21 @@ 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)
|
||||
|
||||
# Disable PHP_PHPACTOR tests if php is not available
|
||||
php_tests_enabled = _sh.which("php") is not None
|
||||
if not php_tests_enabled:
|
||||
result.append(Language.PHP_PHPACTOR)
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,261 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
|
||||
from serena.project import Project
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
|
||||
def _create_test_project(
|
||||
project_root: Path,
|
||||
project_ignored_paths: list[str] | None = None,
|
||||
global_ignored_paths: list[str] | None = None,
|
||||
) -> Project:
|
||||
"""Helper to create a Project with the given ignored paths configuration."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=project_ignored_paths or [],
|
||||
ignore_all_files_in_gitignore=False,
|
||||
)
|
||||
serena_config: SerenaConfig | None = None
|
||||
if global_ignored_paths:
|
||||
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths)
|
||||
return Project(
|
||||
project_root=str(project_root),
|
||||
project_config=config,
|
||||
serena_config=serena_config,
|
||||
)
|
||||
|
||||
|
||||
class TestGlobalIgnoredPaths:
|
||||
"""Tests for system-global ignored_paths feature."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.project_path = Path(self.test_dir)
|
||||
# Create some test files and directories
|
||||
(self.project_path / "main.py").write_text("print('hello')")
|
||||
os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True)
|
||||
(self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}")
|
||||
os.makedirs(self.project_path / "build", exist_ok=True)
|
||||
(self.project_path / "build" / "output.js").write_text("compiled")
|
||||
os.makedirs(self.project_path / "src", exist_ok=True)
|
||||
(self.project_path / "src" / "app.py").write_text("def app(): pass")
|
||||
(self.project_path / "debug.log").write_text("log data")
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_global_ignored_paths_are_applied(self) -> None:
|
||||
"""Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path()."""
|
||||
project = _create_test_project(
|
||||
self.project_path,
|
||||
global_ignored_paths=["node_modules"],
|
||||
)
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
|
||||
assert not project.is_ignored_path(str(self.project_path / "src" / "app.py"))
|
||||
|
||||
def test_additive_merge_of_global_and_project_patterns(self) -> None:
|
||||
"""Global + project patterns are merged additively (both applied)."""
|
||||
project = _create_test_project(
|
||||
self.project_path,
|
||||
project_ignored_paths=["build"],
|
||||
global_ignored_paths=["node_modules"],
|
||||
)
|
||||
# Global pattern should be applied
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
|
||||
# Project pattern should also be applied
|
||||
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
|
||||
# Non-ignored files should not be affected
|
||||
assert not project.is_ignored_path(str(self.project_path / "src" / "app.py"))
|
||||
|
||||
def test_empty_global_ignored_paths_has_no_effect(self) -> None:
|
||||
"""Empty global ignored_paths (default) has no effect on existing behavior."""
|
||||
project = _create_test_project(
|
||||
self.project_path,
|
||||
project_ignored_paths=["build"],
|
||||
global_ignored_paths=[],
|
||||
)
|
||||
# Project pattern still works
|
||||
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
|
||||
# Non-ignored files still accessible
|
||||
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
|
||||
|
||||
def test_default_global_ignored_paths_backward_compatible(self) -> None:
|
||||
"""Project created without global_ignored_paths parameter works as before."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=["build"],
|
||||
ignore_all_files_in_gitignore=False,
|
||||
)
|
||||
project = Project(
|
||||
project_root=str(self.project_path),
|
||||
project_config=config,
|
||||
)
|
||||
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
|
||||
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
|
||||
|
||||
def test_duplicate_patterns_across_global_and_project(self) -> None:
|
||||
"""Duplicate patterns across global and project do not cause errors."""
|
||||
project = _create_test_project(
|
||||
self.project_path,
|
||||
project_ignored_paths=["node_modules", "build"],
|
||||
global_ignored_paths=["node_modules", "build"],
|
||||
)
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
|
||||
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
|
||||
assert not project.is_ignored_path(str(self.project_path / "src" / "app.py"))
|
||||
|
||||
def test_glob_patterns_in_global_ignored_paths(self) -> None:
|
||||
"""Global ignored_paths support gitignore-style glob patterns."""
|
||||
project = _create_test_project(
|
||||
self.project_path,
|
||||
global_ignored_paths=["*.log"],
|
||||
)
|
||||
assert project.is_ignored_path(str(self.project_path / "debug.log"))
|
||||
assert not project.is_ignored_path(str(self.project_path / "main.py"))
|
||||
|
||||
|
||||
class TestRegisteredProjectGlobalIgnoredPaths:
|
||||
"""RegisteredProject.get_project_instance() correctly passes global patterns to Project."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.project_path = Path(self.test_dir).resolve()
|
||||
(self.project_path / "main.py").write_text("print('hello')")
|
||||
os.makedirs(self.project_path / "node_modules", exist_ok=True)
|
||||
(self.project_path / "node_modules" / "pkg.js").write_text("module")
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_get_project_instance_passes_global_ignored_paths(self) -> None:
|
||||
"""RegisteredProject.get_project_instance() passes global_ignored_paths to Project."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=[],
|
||||
ignore_all_files_in_gitignore=False,
|
||||
)
|
||||
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"])
|
||||
registered = RegisteredProject(
|
||||
project_root=str(self.project_path),
|
||||
project_config=config,
|
||||
)
|
||||
project = registered.get_project_instance(serena_config=serena_config)
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
|
||||
|
||||
def test_get_project_instance_without_global_ignored_paths(self) -> None:
|
||||
"""RegisteredProject without global_ignored_paths defaults to empty."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=[],
|
||||
ignore_all_files_in_gitignore=False,
|
||||
)
|
||||
registered = RegisteredProject(
|
||||
project_root=str(self.project_path),
|
||||
project_config=config,
|
||||
)
|
||||
project = registered.get_project_instance(serena_config=None)
|
||||
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
|
||||
|
||||
def test_from_project_root_passes_global_ignored_paths(self) -> None:
|
||||
"""RegisteredProject.from_project_root() threads global_ignored_paths to Project."""
|
||||
# Create a minimal project.yml so from_project_root can load config
|
||||
serena_dir = self.project_path / ".serena"
|
||||
serena_dir.mkdir(exist_ok=True)
|
||||
(serena_dir / "project.yml").write_text(
|
||||
'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n'
|
||||
)
|
||||
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"])
|
||||
registered = RegisteredProject.from_project_root(
|
||||
str(self.project_path),
|
||||
)
|
||||
project = registered.get_project_instance(serena_config=serena_config)
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
|
||||
|
||||
def test_from_project_instance_passes_global_ignored_paths(self) -> None:
|
||||
"""RegisteredProject.from_project_instance() threads global_ignored_paths to Project."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=[],
|
||||
ignore_all_files_in_gitignore=False,
|
||||
)
|
||||
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"])
|
||||
project = Project(
|
||||
project_root=str(self.project_path),
|
||||
project_config=config,
|
||||
serena_config=serena_config,
|
||||
)
|
||||
registered = RegisteredProject.from_project_instance(project)
|
||||
# The registered project already has a project_instance, so get_project_instance() returns it directly
|
||||
retrieved = registered.get_project_instance(serena_config=serena_config)
|
||||
assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
|
||||
|
||||
|
||||
class TestGlobalIgnoredPathsWithGitignore:
|
||||
"""Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.project_path = Path(self.test_dir).resolve()
|
||||
# Create test files
|
||||
(self.project_path / "main.py").write_text("print('hello')")
|
||||
os.makedirs(self.project_path / "node_modules", exist_ok=True)
|
||||
(self.project_path / "node_modules" / "pkg.js").write_text("module")
|
||||
os.makedirs(self.project_path / "dist", exist_ok=True)
|
||||
(self.project_path / "dist" / "bundle.js").write_text("bundled")
|
||||
os.makedirs(self.project_path / "build", exist_ok=True)
|
||||
(self.project_path / "build" / "output.js").write_text("compiled")
|
||||
# Create .gitignore that ignores dist/
|
||||
(self.project_path / ".gitignore").write_text("dist/\n")
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_three_way_merge_global_project_and_gitignore(self) -> None:
|
||||
"""Global patterns, project patterns, and .gitignore patterns are all applied together."""
|
||||
config = ProjectConfig(
|
||||
project_name="test_project",
|
||||
languages=[Language.PYTHON],
|
||||
ignored_paths=["build"],
|
||||
ignore_all_files_in_gitignore=True,
|
||||
)
|
||||
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"])
|
||||
project = Project(
|
||||
project_root=str(self.project_path),
|
||||
project_config=config,
|
||||
serena_config=serena_config,
|
||||
)
|
||||
# Global pattern: node_modules
|
||||
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
|
||||
# Project pattern: build
|
||||
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
|
||||
# Gitignore pattern: dist/
|
||||
assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js"))
|
||||
# Non-ignored file
|
||||
assert not project.is_ignored_path(str(self.project_path / "main.py"))
|
||||
|
||||
|
||||
class TestSerenaConfigIgnoredPaths:
|
||||
"""Config loading with ignored_paths in serena_config.yml works correctly."""
|
||||
|
||||
def test_serena_config_default_ignored_paths(self) -> None:
|
||||
"""SerenaConfig defaults to empty ignored_paths."""
|
||||
config = SerenaConfig(gui_log_window=False, web_dashboard=False)
|
||||
assert config.ignored_paths == []
|
||||
|
||||
def test_serena_config_with_ignored_paths(self) -> None:
|
||||
"""SerenaConfig can be created with explicit ignored_paths."""
|
||||
config = SerenaConfig(
|
||||
gui_log_window=False,
|
||||
web_dashboard=False,
|
||||
ignored_paths=["node_modules", "*.log", "build"],
|
||||
)
|
||||
assert config.ignored_paths == ["node_modules", "*.log", "build"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
from serena.constants import REPO_ROOT
|
||||
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
|
||||
|
||||
class TestSerenaJetBrainsPluginClient:
|
||||
@pytest.mark.parametrize(
|
||||
"serena_path, plugin_path",
|
||||
[
|
||||
(REPO_ROOT, REPO_ROOT),
|
||||
("/home/user/project", "/home/user/project"),
|
||||
("/home/user/project", "//wsl.localhost/Ubuntu-24.04/home/user/project"),
|
||||
("/home/user/project", "//wsl$/Ubuntu/home/user/project"),
|
||||
("/home/user/project", "//wsl$/Ubuntu/home/user/project"),
|
||||
("/mnt/c/Users/user/projects/my-app", "/workspaces/serena/C:/Users/user/projects/my-app"),
|
||||
],
|
||||
)
|
||||
def test_path_matching(self, serena_path, plugin_path) -> None:
|
||||
assert JetBrainsPluginClient._paths_match(serena_path, plugin_path)
|
||||
@@ -15,7 +15,7 @@ from serena.project import Project
|
||||
from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
from test.conftest import get_repo_path, language_tests_enabled
|
||||
from test.conftest import get_repo_path, is_ci, language_tests_enabled
|
||||
from test.solidlsp import clojure as clj
|
||||
|
||||
|
||||
@@ -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,13 +119,20 @@ 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"],
|
||||
)
|
||||
def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str):
|
||||
# skip flaky tests in CI
|
||||
# TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039
|
||||
flaky_languages = {Language.FSHARP, Language.RUST}
|
||||
if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci:
|
||||
pytest.skip("Test is flaky and thus skipped in CI environment.")
|
||||
|
||||
agent = serena_agent
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, include_info=True)
|
||||
result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True)
|
||||
|
||||
symbols = json.loads(result)
|
||||
assert any(
|
||||
@@ -186,15 +194,22 @@ 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"],
|
||||
)
|
||||
def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
|
||||
# skip flaky tests in CI
|
||||
# TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039
|
||||
flaky_languages = {Language.TYPESCRIPT}
|
||||
if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci:
|
||||
pytest.skip("Test is flaky and thus skipped in CI environment.")
|
||||
|
||||
agent = serena_agent
|
||||
|
||||
# Find the symbol location first
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, relative_path=def_file)
|
||||
result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file)
|
||||
|
||||
time.sleep(1)
|
||||
symbols = json.loads(result)
|
||||
@@ -203,12 +218,26 @@ class TestSerenaAgent:
|
||||
|
||||
# Now find references
|
||||
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
|
||||
result = find_refs_tool.apply_ex(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"])
|
||||
result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"])
|
||||
|
||||
def contains_ref_with_relative_path(refs, relative_path):
|
||||
"""
|
||||
Checks for reference to relative path, regardless of output format (grouped an ungrouped)
|
||||
"""
|
||||
if isinstance(refs, list):
|
||||
for ref in refs:
|
||||
if contains_ref_with_relative_path(ref, relative_path):
|
||||
return True
|
||||
elif isinstance(refs, dict):
|
||||
if relative_path in refs:
|
||||
return True
|
||||
for value in refs.values():
|
||||
if contains_ref_with_relative_path(value, relative_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
refs = json.loads(result)
|
||||
assert any(
|
||||
ref["relative_path"] == ref_file for ref in refs
|
||||
), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
|
||||
assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
|
||||
@@ -446,7 +475,6 @@ class TestSerenaAgent:
|
||||
needle = r'console.log("WebSocketManager initializing\nStatus OK");'
|
||||
repl = r'console.log("WebSocketManager initialized\nAll systems go!");'
|
||||
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
|
||||
mode: Literal["literal", "regex"]
|
||||
with project_file_modification_context(serena_agent, relative_path):
|
||||
result = replace_content_tool.apply(
|
||||
needle=re.escape(needle) if mode == "regex" else needle,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for SerenaAgent.set_modes() to verify that mode switching works correctly."""
|
||||
|
||||
import logging
|
||||
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.config.serena_config import ModeSelectionDefinition, SerenaConfig
|
||||
|
||||
|
||||
class TestSetModes:
|
||||
"""Test that set_modes correctly changes active modes."""
|
||||
|
||||
def _create_agent(self, modes: ModeSelectionDefinition | None = None) -> SerenaAgent:
|
||||
config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
return SerenaAgent(serena_config=config, modes=modes)
|
||||
|
||||
def test_set_modes_changes_active_modes(self) -> None:
|
||||
"""Test that calling set_modes actually changes the active modes."""
|
||||
agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"]))
|
||||
|
||||
initial_mode_names = sorted(m.name for m in agent.get_active_modes())
|
||||
assert "editing" in initial_mode_names
|
||||
assert "interactive" in initial_mode_names
|
||||
|
||||
# Switch to planning mode
|
||||
agent.set_modes(["planning", "interactive"])
|
||||
|
||||
new_mode_names = sorted(m.name for m in agent.get_active_modes())
|
||||
assert "planning" in new_mode_names
|
||||
assert "interactive" in new_mode_names
|
||||
assert "editing" not in new_mode_names
|
||||
|
||||
def test_set_modes_overrides_config_defaults(self) -> None:
|
||||
"""Test that set_modes takes precedence over config defaults."""
|
||||
config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
config.default_modes = ["editing", "interactive"]
|
||||
agent = SerenaAgent(serena_config=config)
|
||||
|
||||
# Verify config defaults are active
|
||||
initial_mode_names = [m.name for m in agent.get_active_modes()]
|
||||
assert "editing" in initial_mode_names
|
||||
|
||||
# Switch modes — should override config defaults
|
||||
agent.set_modes(["planning", "one-shot"])
|
||||
|
||||
new_mode_names = [m.name for m in agent.get_active_modes()]
|
||||
assert "planning" in new_mode_names
|
||||
assert "one-shot" in new_mode_names
|
||||
assert "editing" not in new_mode_names
|
||||
|
||||
def test_set_modes_persists_after_repeated_calls(self) -> None:
|
||||
"""Test that set_modes result persists (modes don't revert)."""
|
||||
agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing"]))
|
||||
|
||||
agent.set_modes(["planning"])
|
||||
mode_names_1 = [m.name for m in agent.get_active_modes()]
|
||||
assert "planning" in mode_names_1
|
||||
|
||||
# Call get_active_modes again — should still be planning
|
||||
mode_names_2 = [m.name for m in agent.get_active_modes()]
|
||||
assert mode_names_1 == mode_names_2
|
||||
|
||||
def test_set_modes_can_switch_back(self) -> None:
|
||||
"""Test that modes can be switched back to original after switching away."""
|
||||
agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"]))
|
||||
|
||||
# Switch away
|
||||
agent.set_modes(["planning", "one-shot"])
|
||||
assert "planning" in [m.name for m in agent.get_active_modes()]
|
||||
|
||||
# Switch back
|
||||
agent.set_modes(["editing", "interactive"])
|
||||
mode_names = [m.name for m in agent.get_active_modes()]
|
||||
assert "editing" in mode_names
|
||||
assert "interactive" in mode_names
|
||||
assert "planning" not in mode_names
|
||||
@@ -2,7 +2,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.symbol import LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher
|
||||
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey
|
||||
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
@@ -242,6 +243,27 @@ class TestLanguageServerSymbolRetriever:
|
||||
assert "Create a new user and store it" in create_user_method_symbol_info
|
||||
|
||||
|
||||
class TestSymbolDictTypes:
|
||||
@staticmethod
|
||||
def check_key_type(dict_type: type, key_type: type):
|
||||
"""
|
||||
:param dict_type: a TypedDict type
|
||||
:param key_type: the corresponding key type (Literal[...]) that the dict should have for keys
|
||||
"""
|
||||
dict_type_keys = dict_type.__annotations__.keys()
|
||||
assert len(dict_type_keys) == len(
|
||||
key_type.__args__ # type: ignore
|
||||
), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" # type: ignore
|
||||
for expected_key in key_type.__args__: # type: ignore
|
||||
assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}"
|
||||
|
||||
def test_ls_symbol_dict_type(self):
|
||||
self.check_key_type(LanguageServerSymbol.OutputDict, LanguageServerSymbol.OutputDictKey)
|
||||
|
||||
def test_jb_symbol_dict_type(self):
|
||||
self.check_key_type(SymbolDTO, SymbolDTOKey)
|
||||
|
||||
|
||||
def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.py") -> list[MagicMock]:
|
||||
symbols: list[MagicMock] = []
|
||||
for i in range(count):
|
||||
|
||||
@@ -218,7 +218,7 @@ class EditingTest(ABC):
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def run_test(self, content_after_ground_truth: str | SnapshotAssertion) -> None:
|
||||
def run_test(self, content_after_ground_truth: SnapshotAssertion) -> None:
|
||||
with self._setup() as symbol_retriever:
|
||||
content_before = self._read_file(self.rel_path)
|
||||
code_editor = LanguageServerCodeEditor(symbol_retriever)
|
||||
|
||||
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"
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -10,6 +11,7 @@ from solidlsp import SolidLanguageServer
|
||||
from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
from test.conftest import is_ci
|
||||
|
||||
|
||||
@pytest.mark.fsharp
|
||||
@@ -87,6 +89,7 @@ class TestFSharpLanguageServer:
|
||||
for expected in expected_symbols:
|
||||
assert expected in symbol_names, f"{expected} not found in Person.fs symbols"
|
||||
|
||||
@pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039
|
||||
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
|
||||
def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test finding references to Calculator functions across files."""
|
||||
@@ -122,6 +125,7 @@ class TestFSharpLanguageServer:
|
||||
# We should get at least some definitions
|
||||
assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)"
|
||||
|
||||
@pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039
|
||||
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
|
||||
def test_hover_information(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test hover information functionality."""
|
||||
@@ -140,14 +144,14 @@ class TestFSharpLanguageServer:
|
||||
file_path = os.path.join("Program.fs")
|
||||
|
||||
# Use threading for cross-platform timeout (signal.SIGALRM is Unix-only)
|
||||
result = [None]
|
||||
exception = [None]
|
||||
result: dict[str, Any] = dict(value=None)
|
||||
exception: dict[str, Any] = dict(value=None)
|
||||
|
||||
def run_completion():
|
||||
try:
|
||||
result[0] = language_server.request_completions(file_path, 15, 10)
|
||||
result["value"] = language_server.request_completions(file_path, 15, 10)
|
||||
except Exception as e:
|
||||
exception[0] = e
|
||||
exception["value"] = e
|
||||
|
||||
thread = threading.Thread(target=run_completion, daemon=True)
|
||||
thread.start()
|
||||
@@ -158,10 +162,10 @@ class TestFSharpLanguageServer:
|
||||
# The important thing is that the language server doesn't crash
|
||||
return
|
||||
|
||||
if exception[0]:
|
||||
raise exception[0]
|
||||
if exception["value"]:
|
||||
raise exception["value"]
|
||||
|
||||
assert isinstance(result[0], list), "Completions should be a list"
|
||||
assert isinstance(result["value"], list), "Completions should be a list"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
|
||||
def test_diagnostics(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -12,13 +12,14 @@ from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
@pytest.mark.groovy
|
||||
class TestGroovyLanguageServer:
|
||||
language_server: SolidLanguageServer | None = None
|
||||
test_repo_path: Path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo"
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
"""
|
||||
Set up test class with Groovy test repository.
|
||||
"""
|
||||
cls.test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo"
|
||||
|
||||
if not cls.test_repo_path.exists():
|
||||
pytest.skip("Groovy test repository not found")
|
||||
|
||||
@@ -56,10 +57,11 @@ class TestGroovyLanguageServer:
|
||||
"""
|
||||
Clean up language server.
|
||||
"""
|
||||
if hasattr(cls, "language_server"):
|
||||
if cls.language_server is not None:
|
||||
cls.language_server.stop()
|
||||
|
||||
def test_find_symbol(self) -> None:
|
||||
assert self.language_server is not None
|
||||
symbols = self.language_server.request_full_symbol_tree()
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree"
|
||||
@@ -67,6 +69,7 @@ class TestGroovyLanguageServer:
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree"
|
||||
|
||||
def test_find_referencing_class_symbols(self) -> None:
|
||||
assert self.language_server is not None
|
||||
file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy")
|
||||
refs = self.language_server.request_references(file_path, 3, 6)
|
||||
assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy"
|
||||
@@ -93,6 +96,7 @@ class TestGroovyLanguageServer:
|
||||
assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}"
|
||||
|
||||
def test_overview_methods(self) -> None:
|
||||
assert self.language_server is not None
|
||||
symbols = self.language_server.request_full_symbol_tree()
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview"
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user