Merge branch 'main' into feature/cross-package-typescript-references

# Conflicts:
#	CHANGELOG.md
#	src/solidlsp/ls.py
This commit is contained in:
Michael Panchenko committed 2026-05-04 14:26:20 +02:00
commit 64e604630e
216 files changed
+7868 -1189

No files matched your search

+6 -2
View File
@@ -224,6 +224,10 @@ jobs:
with:
r-version: '4.4.2'
use-public-rspm: true
- name: Install R sysdeps (libuv for fs package)
if: runner.os == 'Linux'
shell: bash
run: sudo apt-get update && sudo apt-get install -y libuv1-dev
- name: Install R language server
shell: bash
run: |
@@ -231,10 +235,10 @@ jobs:
- name: Set up Julia
uses: julia-actions/setup-julia@v2
with:
version: '1.10'
version: '1.12.6'
- name: Install Julia LanguageServer
shell: bash
run: julia -e 'using Pkg; Pkg.add("LanguageServer")'
run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))'
- name: Setup Haskell toolchain
if: runner.os != 'Windows'
uses: haskell/ghcup-setup@v1
+3 -1
View File
@@ -79,6 +79,8 @@ downloads/
eggs/
.eggs/
lib/
!test/resources/repos/dart/test_repo/lib/
!test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart
lib64/
parts/
sdist/
@@ -271,4 +273,4 @@ cabal.project.local*
zz-misc/
vue-implementation/
news/news.json
news/news.json
+26 -49
View File
@@ -3,15 +3,18 @@ project_name: "serena"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog 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.)
@@ -28,6 +31,7 @@ project_name: "serena"
languages:
- python
- typescript
- clojure
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
@@ -44,49 +48,12 @@ read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -121,14 +88,17 @@ encoding: utf-8
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
# Set this to [] to not use the default modes defined in the global config for this project.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# time budget (seconds) per tool call for the retrieval of additional symbol information
@@ -166,3 +136,10 @@ ignored_memory_patterns: []
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of mode names to be activated additionally for this project.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# Otherwise, this setting overrides the global configuration.
# Set this to a list of mode names to always include the respective modes for this project.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
+39 -7
View File
@@ -2,13 +2,35 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Hooks:
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
* General:
- Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`.
Instead, they can define `added_modes` to add modes on top of base and default modes.
See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes).
- Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`.
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
* JetBrains:
- Add new tools:
- `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group
- `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results
* LSP Tools:
- Add new tools:
- `find_declaration`: Finds the declaration/definition of a symbol
- `find_implementations`: Finds the implementations of an interface or abstract method
- `get_diagnostics_for_file`: Retrieves diagnostics for a specific file (errors, warnings, etc.)
- `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol
* Language Servers:
- Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415
# v1.2.0 (2026-04-27)
* General:
- Add cross-package reference support via `additional_workspace_folders` project setting (currently implemented for TypeScript).
Configure additional workspace folder paths in `project.yml` to enable `find_referencing_symbols` and `request_references`
to discover symbols across package boundaries in monorepos. The base `SolidLanguageServer` class provides the workspace
folder resolution and activation framework; other language servers can enable support by overriding
`_find_representative_source_file()`. #750
- Support `serena --version` CLI command for displaying the current version #1347
- Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350
- `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`)
- Fix: Duplicate comments in re-saved YAML configuration files #1285
@@ -36,6 +58,13 @@ Status of the `main` branch. Changes prior to the next official version change w
The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean.
- Add `serena_info` tool for on-demand retrieval of usage information
* CLI:
- Support `serena --version` CLI command for displaying the current version #1347
- Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`, improve `list` subcommand
* Clients:
- Document workaround to make Claude Code use Serena's tools after recent degradations caused by changes in CC harness and Opus 4.7 release.
* JetBrains:
- Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
@@ -55,11 +84,14 @@ Status of the `main` branch. Changes prior to the next official version change w
- Add JSON language server support via `vscode-json-languageserver` (experimental) #1391
- Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397
Dashboard:
* Dashboard:
- Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS
- Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389
* Hooks:
- Adjusted wording of startup hook, improving project activation instructions #1401.
# v1.1.2 (2026-04-14)
* General:
+4 -3
View File
@@ -4,7 +4,7 @@
</p>
<h3 align="center">
Serena is the IDE for your coding agent.
The IDE for Your Coding Agent
</h3>
<div align="center">
@@ -145,9 +145,10 @@ without reading entire files.
| find referencing symbols | yes | yes |
| search in project dependencies | -- | yes |
| type hierarchy | -- | yes |
| find declaration | -- | yes |
| find implementations | -- | yes |
| find declaration | yes | yes |
| find implementations | yes | yes |
| query external projects | yes | yes |
| diagnostics/inspections | yes | yes |
### Refactoring
+29 -5
View File
@@ -101,8 +101,32 @@ You can do this in Tools / GitHub Copilot / Chat, where at the bottom you can cl
## Claude Code
Serena is a great way to make Claude Code both cheaper and more powerful!
To add Serena to Claude Code, you can simply run `serena setup claude-code`. Alternatively, follow the instructions below.
Serena is a great way to make Claude Code both more efficient and more powerful!
To set up the Serena MCP server for Claude Code, you can simply run this command:
serena setup claude-code
Find manual setup instructions as well as workarounds for Claude Code's recent regressions pertaining to (external) tool use below.
:::{attention}
Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically reduced
adherence to instructions pertaining to Serena's tools.
After extensive analysis, we identified part of the reason to be very long and detailed
tool descriptions for built-in tools and parts of the default system prompt.
The descriptions of CC's system tools take almost 16k tokens, cannot be adjusted by the user,
and introduce a very strong bias towards internal tools, making it almost impossible to convince Opus 4.7 to use Serena.
As a workaround, we crafted a system prompt that counteracts this bias.
When using Serena, we highly recommend that you start CC as
```shell
claude --system-prompt="$(serena prompts print-cc-system-prompt-override)"
```
You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files,
but the effect be insufficient for counteracting Claude Code's bias towards internal tools.
:::
**Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag:
@@ -194,9 +218,9 @@ The hooks will:
`grep` or `read_file` calls without using any Serena tools in between.
- **`activate`**: Prompt the agent to activate the project and read Serena's instructions at session start.
- **`cleanup`**: Clean up hook session data when the session ends.
- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in `acceptEdits` mode,
so blanket edit approvals cover Serena's destructive tools (e.g. `replace_symbol_body`,
`rename_symbol`) instead of prompting on every call.
- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in a permissive
permission mode (`acceptEdits` or `auto`), so blanket approvals cover Serena's destructive
tools (e.g. `replace_symbol_body`, `rename_symbol`) instead of prompting on every call.
For more details on Claude Code's hook system, see the
[Claude Code hooks documentation](https://code.claude.com/docs/en/hooks).
+12 -9
View File
@@ -18,20 +18,22 @@ setting up a project with Serena typically involves the following steps:
Project creation is the process of defining fundamental project settings that are relevant to Serena's operation.
You can create a project either
* explicitly, using the project creation command (see below), or
* implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section).
* explicitly, using the project creation command, or
### Explicit Project Creation
To explicitly create a project, use the following command while in the project directory:
serena project create [options]
serena project create [options] [project directory]
* For an empty project, you will need to specify the programming language
(e.g., `--language python`).
* For an existing project, the main programming language will be detected automatically,
but you can choose to explicitly specify multiple languages by passing the `--language` parameter
multiple times (e.g. `--language python --language typescript`).
* The project directory defaults to the current directory if not specified.
* For an existing project, the programming languages will be detected based on
the source files present, and the main language will be activated automatically.
If multiple languages are detected, you will be prompted whether you want to enable them.
* For an empty project, you can optionally specify one or more languages
to be activated explicitly via the `--language` parameter
(e.g. `--language python --language typescript`).
* You can optionally specify a custom project name with `--name my-name`.
* You can immediately index the project after creation with `--index`.
@@ -42,6 +44,7 @@ After creation, you can adjust the project settings in the generated `.serena/pr
within the project directory.
The file allows you to configure ...
* the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project)
* the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin)
Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard).
* the [language backend](per-project-language-backend) to use for this project (overriding the global setting)
@@ -50,8 +53,8 @@ The file allows you to configure ...
* write access
* [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos
* an initial prompt that shall be passed to the LLM whenever the project is activated
* the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project)
* the set of tools and modes to use by default
* the set of tools and modes to use for the project
* and some other settings.
For detailed information on the parameters and possible settings, see the
[template file](https://github.com/oraios/serena/blob/main/src/serena/resources/project.template.yml).
+66 -28
View File
@@ -118,32 +118,26 @@ Examples of built-in modes include:
Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes).
Active modes are configured in (from lowest to highest precedence):
The modes to be activated are configured in:
* the global configuration file (`serena_config.yml`)
- defines `base_modes`, which are always included
- defines `default_modes`, which can be overridden by projects or command line parameters
* the project configuration file (`project.yml`)
- defines `default_modes` (overriding the default modes in the global configuration)
- defines `added_modes`, which are added on top
* at startup via command-line parameters
- can override default modes with `--mode`
- can define modes to be added on top with `--add-mode`
The two former sources define both **base modes** and **default modes**.
Ultimately, the active modes are the union of base modes and default modes (after applying all overrides).
Command-line parameters override default modes but not base modes.
Base modes should thus be used to define modes that you always want to be active, regardless of command-line parameters.
Ultimately, the active modes are given by the union of
* `base_modes` defined in the global configuration (always active)
* `default_modes` (defined in the global configuration, optionally overridden by the project/CLI)
* `added_modes` (defined in the project configuration/via CLI parameters)
Command-line parameters for overriding default modes:
When launching the MCP sever, specify modes using `--mode <mode-name>`; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`.
:::{important}
By default, Serena activates the two modes `interactive` and `editing` (as defined in the global configuration).
As soon as you start to specify modes via the command line, only the modes you explicitly specify will be active, however.
Therefore, if you want to keep the default modes, you must specify them as well.
For example, to add mode `no-memories` to the default behaviour, specify
```shell
--mode interactive --mode editing --mode no-memories
```
If you want to keep certain modes as always active, regardless of command-line parameters,
define them as *base modes* in the global or project configuration.
:::
So you should
* define modes you definitely always want to use in `base_modes`,
* define modes that you typically want to use but sometimes want to override in `default_modes`,
* use `added_modes` to add modes that you need only for specific projects/sessions.
:::{note}
**Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`).
@@ -504,28 +498,72 @@ ls_specific_settings:
#### Java (`eclipse.jdt.ls`)
Java support has two installation modes:
1. **Default vscode-java VSIX mode** (no extra config required): Serena downloads the platform-specific
vscode-java VSIX (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution and
IntelliCode VSIX from public hosts on first use.
2. **Upstream JDTLS mode** (offline-friendly): Activated by setting both `jdtls_path` and `lombok_path`.
Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded.
Recommended for restricted-network/corporate environments.
**When to use which mode:**
- **Default vscode-java VSIX mode** — recommended for most users. No setup required;
Serena downloads everything on first use.
- **Upstream JDTLS mode** — recommended when:
- you cannot reach `github.com`, `services.gradle.org` or `marketplace.visualstudio.com`
from the host (corporate proxy, air-gapped network);
- you want a smaller on-disk footprint (~100 MB vs ~500 MB);
- you already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor);
- your security policy prohibits per-project runtime downloads.
**JDK 21+ is required** in upstream mode. Serena resolves the JDK in this order:
`ls_specific_settings.java.java_home` → `JAVA_HOME` env var → first `java` on `PATH`.
The resolved JVM is interrogated and rejected if its `java.specification.version` is below 21.
The following settings are supported for the Java language server:
| Setting | Default | Description |
|---|---|---|
| `jdtls_path` | `null` | Activates upstream JDTLS mode. Path to upstream JDTLS root (containing `plugins/` and `config_<platform>/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from <https://download.eclipse.org/jdtls/snapshots/>. Must be set together with `lombok_path`. |
| `lombok_path` | `null` | Path to the Lombok jar. Activates upstream JDTLS mode together with `jdtls_path`. Get from `~/.m2/repository/org/projectlombok/lombok/<ver>/lombok-<ver>.jar` or download from <https://projectlombok.org/downloads/>. |
| `java_home` | `null` | (upstream-jdtls mode only) Path to JDK 21+ home directory used to launch JDTLS. Falls back to `JAVA_HOME` env var, then `which java`. |
| `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` |
| `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory |
| `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. |
| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. |
| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. |
| `gradle_version` | `8.14.2` | Override the Gradle distribution version Serena downloads by default. |
| `vscode_java_version` | `1.42.0-561` | Override the bundled `vscode-java` runtime bundle version Serena downloads by default. |
| `intellicode_version` | `1.2.30` | Override the IntelliCode VSIX version Serena downloads by default. |
| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. |
| `vscode_java_version` | `1.42.0-561` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. |
| `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. |
| `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. |
| `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. |
| `intellicode_xmx` | `1G` | Maximum heap size for the IntelliCode embedded JVM. |
| `intellicode_xms` | `100m` | Initial heap size for the IntelliCode embedded JVM. |
| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. |
| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. |
Note:
Notes:
- When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal
directory layout and file names as the bundled default version.
- In upstream-jdtls mode, IntelliCode is not loaded (it's an ML completions ranker that is irrelevant to Serena's
symbol-tools workflow), and Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e.
Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's
default discovery rules.
- In upstream-jdtls mode the `gradle_version`, `vscode_java_version`, `intellicode_version`,
`intellicode_xmx`, `intellicode_xms` settings are silently ignored — they only apply to the
vscode-java VSIX mode.
Example for a project with custom Gradle plugins and JDK requirements:
Example: upstream-jdtls mode (offline / corporate network):
```yaml
ls_specific_settings:
java:
jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"
lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar"
# java_home: "/opt/homebrew/opt/openjdk@21" # optional
```
Example: default vscode-java VSIX mode for a project with custom Gradle plugins:
```yaml
ls_specific_settings:
+27
View File
@@ -0,0 +1,27 @@
<div class="news-item">
<h3>Serena v1.2.0</h3>
<p class="date">April 27, 2026</p>
<p>
<b>Interactive Debugging.</b>
With the <a target="_blank" href="https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html">Serena JetBrains plugin</a>,
your agent can set now set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger.
Our REPL-based interface is highly flexible, allowing all of this to be provided via a single tool.
</p>
<p>
<b>Counteracting Claude Code Regressions.</b>
We analysed the issues causing Claude Code to use external tools increasingly suboptimally and implemented
another workaround (in addition to the existing hooks): a system prompt override.
Read our updated <a target="_blank" href="https://oraios.github.io/serena/02-usage/030_clients.html#claude-code">Claude code usage instructions</a> for more information.
</p>
<p>
The update also adds JSON as a new supported "language" for the LSP backend,
improvements in token efficiency/prompt provision, as well as a multitude of general improvements and fixes.
See our <a target="_blank" href="https://github.com/oraios/serena/blob/main/CHANGELOG.md">change log</a> for details.<br>
</p>
<p>
<b>Update Now.</b>
If you are using the uv tool installation of Serena, upgrade as follows:<br>
uv tool upgrade serena-agent --prerelease=allow
</p>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ requires = ["hatchling"]
[project]
name = "serena-agent"
version = "1.1.2"
version = "1.2.0"
description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent"
authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }]
readme = "README.md"
+4
View File
@@ -0,0 +1,4 @@
from serena.cli import top_level
if __name__ == "__main__":
top_level(["--help"])
+176
View File
@@ -0,0 +1,176 @@
"""
Demonstrates diagnostics tools and edit-tool diagnostic reporting on the Serena repo itself.
The script creates a temporary Python file inside this repository, introduces one warning,
shows file and symbol diagnostics, then introduces another warning and verifies that the
second edit reports only the newly introduced warning.
"""
import json
import shutil
import tempfile
from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.project import Project
from serena.tools import (
CreateTextFileTool,
EditingToolWithDiagnostics,
GetDiagnosticsForFileTool,
GetDiagnosticsForSymbolTool,
ReplaceContentTool,
)
from solidlsp.ls_config import Language
SEPARATOR = "=" * 80
REPO_PATH = Path(REPO_ROOT)
EDIT_RESULT_PREFIX = "Edit introduced new warning-or-higher diagnostics: "
def make_agent() -> SerenaAgent:
"""Create an LSP-backed Serena agent for the Serena repository."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
project = Project(
project_root=str(REPO_PATH),
project_config=ProjectConfig(
project_name="demo_serena_repo",
languages=[Language.PYTHON],
ignored_paths=[],
excluded_tools=[],
read_only=False,
ignore_all_files_in_gitignore=True,
initial_prompt="",
encoding="utf-8",
),
serena_config=serena_config,
)
serena_config.projects = [RegisteredProject.from_project_instance(project)]
return SerenaAgent(project="demo_serena_repo", serena_config=serena_config)
def print_section(title: str) -> None:
"""Print a visibly separated section header."""
print(f"\n{SEPARATOR}")
print(title)
print(SEPARATOR)
def parse_json_result(result: str) -> object:
"""Parse and pretty-print JSON tool output."""
parsed = json.loads(result)
pprint(parsed, width=200)
return parsed
def parse_edit_diagnostics_result(result: str) -> dict:
"""Extract the grouped diagnostics payload from an edit-tool result."""
assert result.startswith(EDIT_RESULT_PREFIX), result
return json.loads(result[len(EDIT_RESULT_PREFIX) :])
if __name__ == "__main__":
EditingToolWithDiagnostics.ENABLE_DIAGNOSTICS = True
temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH))
temp_file = temp_dir / "demo_temp_diagnostics.py"
relative_path = temp_file.relative_to(REPO_PATH).as_posix()
initial_content = """def demo_existing_issue() -> int:
value = 1
return value
"""
agent = make_agent()
try:
# letting the language server finish startup
agent.execute_task(lambda: None)
create_text_file_tool = agent.get_tool(CreateTextFileTool)
replace_content_tool = agent.get_tool(ReplaceContentTool)
get_diagnostics_for_file_tool = agent.get_tool(GetDiagnosticsForFileTool)
get_diagnostics_for_symbol_tool = agent.get_tool(GetDiagnosticsForSymbolTool)
# creating a clean temporary file
print_section("Create Temporary File")
create_result = agent.execute_task(lambda: create_text_file_tool.apply(relative_path=relative_path, content=initial_content))
print(create_result)
# showing file diagnostics before introducing any warning
print_section("Initial File Diagnostics")
initial_diagnostics_result = agent.execute_task(
lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2)
)
initial_diagnostics = parse_json_result(initial_diagnostics_result)
assert initial_diagnostics == {}, initial_diagnostics
# introducing the first warning
print_section("First Edit Result")
first_edit_result = agent.execute_task(
lambda: replace_content_tool.apply(
relative_path=relative_path,
needle="value = 1",
repl="value = missing_one",
mode="literal",
)
)
print(first_edit_result)
first_edit_diagnostics = parse_edit_diagnostics_result(first_edit_result)
pprint(first_edit_diagnostics, width=200)
assert "missing_one" in json.dumps(first_edit_diagnostics), first_edit_diagnostics
# showing the file- and symbol-level diagnostics after the first warning
print_section("File Diagnostics After First Edit")
diagnostics_after_first_edit_result = agent.execute_task(
lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2)
)
diagnostics_after_first_edit = parse_json_result(diagnostics_after_first_edit_result)
assert "missing_one" in json.dumps(diagnostics_after_first_edit), diagnostics_after_first_edit
print_section("Symbol Diagnostics After First Edit")
symbol_diagnostics_result = agent.execute_task(
lambda: get_diagnostics_for_symbol_tool.apply(
name_path="demo_existing_issue",
reference_file=relative_path,
min_severity=2,
)
)
symbol_diagnostics = parse_json_result(symbol_diagnostics_result)
assert "missing_one" in json.dumps(symbol_diagnostics), symbol_diagnostics
# introducing a second warning while keeping the first one unchanged
print_section("Second Edit Result")
second_edit_result = agent.execute_task(
lambda: replace_content_tool.apply(
relative_path=relative_path,
needle=" return value\n",
repl=" other = missing_two\n return value + other\n",
mode="literal",
)
)
print(second_edit_result)
second_edit_diagnostics = parse_edit_diagnostics_result(second_edit_result)
pprint(second_edit_diagnostics, width=200)
second_edit_json = json.dumps(second_edit_diagnostics)
assert "missing_two" in second_edit_json, second_edit_diagnostics
assert "missing_one" not in second_edit_json, second_edit_diagnostics
print("\nVerified: the second edit result reports only the newly introduced warning.")
# showing the complete file diagnostics after both warnings exist
print_section("File Diagnostics After Second Edit")
diagnostics_after_second_edit_result = agent.execute_task(
lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2)
)
diagnostics_after_second_edit = parse_json_result(diagnostics_after_second_edit_result)
diagnostics_after_second_edit_json = json.dumps(diagnostics_after_second_edit)
assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit
assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
agent.shutdown()
+112
View File
@@ -0,0 +1,112 @@
"""
Demonstrates both defining-symbol tools on the Python test repository.
"""
import json
import re
from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.project import Project
from serena.tools import FindDeclarationTool
from solidlsp.ls_config import Language
SEPARATOR = "=" * 80
PYTHON_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "python" / "test_repo"
SERVICES_FILE = Path("test_repo") / "services.py"
def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent:
"""Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
project = Project(
project_root=str(project_root),
project_config=ProjectConfig(
project_name=project_name,
languages=[language],
ignored_paths=[],
excluded_tools=[],
read_only=False,
ignore_all_files_in_gitignore=True,
initial_prompt="",
encoding="utf-8",
),
serena_config=serena_config,
)
serena_config.projects = [RegisteredProject.from_project_instance(project)]
return SerenaAgent(project=project_name, serena_config=serena_config)
def print_section(title: str) -> None:
"""Print a visibly separated section header."""
print(f"\n{SEPARATOR}")
print(title)
print(SEPARATOR)
def find_identifier_occurrence_position(file_path: Path, identifier: str, occurrence_index: int = 0) -> tuple[int, int]:
"""Find the 0-based position of an identifier occurrence in a file."""
pattern = re.compile(r"\b" + re.escape(identifier) + r"\b")
current_occurrence_index = 0
with file_path.open(encoding="utf-8") as f:
for line_index, line in enumerate(f):
for match in pattern.finditer(line):
if current_occurrence_index == occurrence_index:
return line_index, match.start()
current_occurrence_index += 1
raise ValueError(f"Could not find occurrence {occurrence_index} of {identifier!r} in {file_path}")
if __name__ == "__main__":
agent = make_agent(PYTHON_TEST_REPO, Language.PYTHON, "demo_python_test_repo")
try:
# letting the language server finish startup
agent.execute_task(lambda: None)
relative_path = SERVICES_FILE.as_posix()
services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE
# resolving via regex over the full file
find_by_regex_tool = agent.get_tool(FindDeclarationTool)
regex_result = agent.execute_task(
lambda: find_by_regex_tool.apply(
regex=r"from \.models import Item, (User)",
relative_path=relative_path,
include_info=True,
)
)
print_section("FindDefiningSymbolTool (File Regex)")
regex_symbol = json.loads(regex_result)
pprint(regex_symbol, width=200)
# resolving via regex restricted to one containing symbol body
contained_regex_result = agent.execute_task(
lambda: find_by_regex_tool.apply(
regex=r"=\s+(User)\(",
relative_path=relative_path,
containing_symbol_name_path="UserService/create_user",
include_info=True,
)
)
print_section("FindDefiningSymbolTool (Contained Regex)")
contained_regex_symbol = json.loads(contained_regex_result)
pprint(contained_regex_symbol, width=200)
# validating the demonstrated result
for symbol in [regex_symbol, contained_regex_symbol]:
assert symbol is not None, "Expected a defining symbol result"
assert symbol.get("relative_path") is not None
assert "models.py" in symbol["relative_path"], symbol
assert "User" in json.dumps(symbol), symbol
print("\nVerified definition target: User in models.py")
finally:
agent.shutdown()
+76
View File
@@ -0,0 +1,76 @@
"""
Demonstrates FindImplementationsTool on the Go test repository.
"""
import json
from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.project import Project
from serena.tools import FindImplementationsTool
from solidlsp.ls_config import Language
SEPARATOR = "=" * 80
GO_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "go" / "test_repo"
def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent:
"""Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
project = Project(
project_root=str(project_root),
project_config=ProjectConfig(
project_name=project_name,
languages=[language],
ignored_paths=[],
excluded_tools=[],
read_only=False,
ignore_all_files_in_gitignore=True,
initial_prompt="",
encoding="utf-8",
),
serena_config=serena_config,
)
serena_config.projects = [RegisteredProject.from_project_instance(project)]
return SerenaAgent(project=project_name, serena_config=serena_config)
def print_section(title: str) -> None:
"""Print a visibly separated section header."""
print(f"\n{SEPARATOR}")
print(title)
print(SEPARATOR)
if __name__ == "__main__":
agent = make_agent(GO_TEST_REPO, Language.GO, "demo_go_test_repo")
try:
# letting the language server finish startup
agent.execute_task(lambda: None)
# running the implementation lookup
find_implementations_tool = agent.get_tool(FindImplementationsTool)
result = agent.execute_task(
lambda: find_implementations_tool.apply(
name_path="Greeter/FormatGreeting",
relative_path="main.go",
include_info=True,
)
)
print_section("Find Implementations Result")
implementations = json.loads(result)
pprint(implementations, width=200)
# validating the demonstrated result
assert any(implementation["name_path"] == "(ConsoleGreeter).FormatGreeting" for implementation in implementations), result
print("\nVerified implementation target: (ConsoleGreeter).FormatGreeting")
finally:
agent.shutdown()
+8 -4
View File
@@ -8,14 +8,16 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.constants import REPO_ROOT
from serena.tools import (
FindFileTool,
FindReferencingSymbolsTool,
GetDiagnosticsForFileTool,
JetBrainsFindSymbolTool,
JetBrainsGetSymbolsOverviewTool,
JetBrainsInlineSymbol,
JetBrainsRunInspectionsTool,
JetBrainsSafeDeleteTool,
SearchForPatternTool,
)
@@ -23,6 +25,7 @@ from serena.tools import (
if __name__ == "__main__":
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
# project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy"
project = Path(REPO_ROOT)
agent = SerenaAgent(project=str(project), serena_config=serena_config)
@@ -35,12 +38,13 @@ if __name__ == "__main__":
overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool)
safe_delete_tool = agent.get_tool(JetBrainsSafeDeleteTool)
inline_symbol = agent.get_tool(JetBrainsInlineSymbol)
diagnostics_in_file_tool = agent.get_tool(GetDiagnosticsForFileTool)
jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool)
result = agent.execute_task(
lambda: overview_tool.apply(
lambda: diagnostics_in_file_tool.apply(
# name_path_pattern="SerenaAgent",
relative_path="src/serena/agent.py",
depth=2,
relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj",
# keep_definition=True,
)
)
+3
View File
@@ -30,6 +30,9 @@ class JinjaTemplate(ParameterizedTemplateInterface):
parsed_content = self._template.environment.parse(self._template_string)
self._parameters = sorted(jinja2.meta.find_undeclared_variables(parsed_content))
def get_template_string(self) -> str:
return self._template_string
def render(self, **params: Any) -> str:
"""Renders the template with the given kwargs. You can find out which parameters are required by calling get_parameter_names()."""
return self._template.render(**params)
+8 -3
View File
@@ -12,13 +12,17 @@ log = logging.getLogger(__name__)
class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface):
def __init__(self, name: str, jinja_template_string: str) -> None:
def __init__(self, name: str, jinja_template_string: str, path: str) -> None:
self.name = name
self.path = path
self._jinja_template = JinjaTemplate(jinja_template_string.strip())
def _tostring_exclude_private(self) -> bool:
return True
def get_template_string(self) -> str:
return self._jinja_template.get_template_string()
def render(self, **params: Any) -> str:
return self._jinja_template.render(**params)
@@ -249,6 +253,7 @@ class MultiLangPromptCollection:
self,
name: str,
template_str: str,
path: str,
lang_code: str = DEFAULT_LANG_CODE,
on_name_collision: Literal["skip", "overwrite", "raise"] = "raise",
) -> None:
@@ -259,7 +264,7 @@ class MultiLangPromptCollection:
:param on_name_collision: how to deal with name/lang_code collisions
"""
allow_overwrite = False
prompt_template = PromptTemplate(name, template_str)
prompt_template = PromptTemplate(name, template_str, path=path)
mlpt = self._multi_lang_prompt_templates.get(name)
if mlpt is None:
mlpt = MultiLangPromptTemplate(name)
@@ -327,7 +332,7 @@ class MultiLangPromptCollection:
self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision)
elif isinstance(prompt_template_or_list, str):
self._add_prompt_template(
prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision
prompt_name, prompt_template_or_list, lang_code=lang_code, path=path, on_name_collision=on_name_collision
)
else:
raise ValueError(
+15 -5
View File
@@ -2,7 +2,7 @@ import logging
import os
from typing import Any
from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList
from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList, PromptTemplate
log = logging.getLogger(__name__)
@@ -23,9 +23,18 @@ class PromptFactoryBase:
self.lang_code = lang_code
self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode)
def get_prompt_names(self) -> list[str]:
return self._prompt_collection.get_prompt_template_names()
def get_prompt_template(self, prompt_name: str) -> PromptTemplate:
return self._prompt_collection.get_prompt_template(prompt_name, lang_code=self.lang_code)
def get_prompt_template_string(self, prompt_name: str) -> str:
return self.get_prompt_template(prompt_name).get_template_string()
def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str:
del params["self"]
return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code)
return self.get_prompt_template(prompt_name).render(**params)
def _get_prompt_list(self, prompt_name: str) -> PromptList:
return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code)
@@ -41,14 +50,12 @@ def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str
:param prompts_dir: the directory containing the prompt templates and prompt lists
:param target_module_path: the path to the target module file (.py). Important: The module will be overwritten!
"""
generated_code = """
# ruff: noqa
generated_code = """# ruff: noqa
# black: skip
# mypy: ignore-errors
# NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually!
from interprompt.multilang_prompt import PromptList
from interprompt.prompt_factory import PromptFactoryBase
from typing import Any
@@ -68,6 +75,9 @@ class PromptFactory(PromptFactoryBase):
else:
method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters])
generated_code += f"""
def get_{template_name}_template_string(self) -> str:
return self.get_prompt_template_string('{template_name}')
def create_{template_name}(self{method_params_str}) -> str:
return self._render_prompt('{template_name}', locals())
"""
+1 -1
View File
@@ -1,4 +1,4 @@
__version__ = "1.1.2"
__version__ = "1.2.0"
import logging
+35 -26
View File
@@ -31,6 +31,8 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
LanguageBackend,
ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes,
ModeSelectionDefinitionWithBaseModes,
NamedToolInclusionDefinition,
RegisteredProject,
SerenaConfig,
@@ -206,18 +208,36 @@ class ActiveModes:
def __init__(self) -> None:
self._configured_base_modes: Sequence[str] | None = None
self._configured_default_modes: Sequence[str] | None = None
self._added_modes: set[str] = set()
self._dynamically_activated_mode_names: set[str] = set()
"""
the subset of active mode names that are dynamically activated (not necessarily enabled after project change)
"""
self._active_mode_names: Sequence[str] = []
"""
the full list of active mode names
"""
def apply(self, mode_selection: ModeSelectionDefinition) -> None:
log.debug("Applying mode selection definition %s", mode_selection)
# apply overrides
log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes)
if mode_selection.base_modes is not None:
self._configured_base_modes = mode_selection.base_modes
if isinstance(mode_selection, ModeSelectionDefinitionWithBaseModes):
if mode_selection.base_modes is not None:
self._configured_base_modes = mode_selection.base_modes
if mode_selection.default_modes is not None:
self._configured_default_modes = mode_selection.default_modes
log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._configured_base_modes, self._configured_default_modes)
self._active_mode_names = sorted(set(self._configured_base_modes or []) | set(self._configured_default_modes or []))
# apply added modes (if any)
if isinstance(mode_selection, ModeSelectionDefinitionWithAddedModes):
if mode_selection.added_modes:
log.debug("Adding modes: %s", mode_selection.added_modes)
self._added_modes.update(mode_selection.added_modes)
log.debug("Current added modes: %s", self._added_modes)
self._dynamically_activated_mode_names = set(self._configured_default_modes or []) | self._added_modes
self._active_mode_names = sorted(set(self._configured_base_modes or []) | self._dynamically_activated_mode_names)
def get_mode_names(self) -> Sequence[str]:
return self._active_mode_names
@@ -231,8 +251,8 @@ class ActiveModes:
def get_modes(self) -> Sequence[SerenaAgentMode]:
return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names]
def get_default_modes(self) -> Sequence[SerenaAgentMode]:
return [self.get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []]
def get_dynamically_activated_modes(self) -> Sequence[SerenaAgentMode]:
return [self.get_mode_instance(mode_name) for mode_name in self._dynamically_activated_mode_names]
def get_base_modes(self) -> Sequence[SerenaAgentMode]:
return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []]
@@ -511,8 +531,7 @@ class SerenaAgent:
:param serena_config: the Serena configuration or None to read the configuration from the default location.
:param context: the context in which the agent is operating, None for default context.
The context may adjust prompts, tool availability, and tool descriptions.
:param modes: list of modes in which the agent is operating (they will be combined), None for default modes.
The modes may adjust prompts, tool availability, and tool descriptions.
:param modes: mode selection definition to apply for this session
:param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created
if necessary.
"""
@@ -521,7 +540,7 @@ class SerenaAgent:
self._gui_log_viewer: Optional["GuiLogViewer"] = None
self._dashboard_manager: DashboardManager | None = None
self._project_prompt_status = ProjectPromptProvisionStatus()
self._mode_overrides = modes
self._session_mode_selection_definition = modes
self.version = serena_version()
# obtain serena configuration using the decoupled factory function
@@ -712,9 +731,11 @@ class SerenaAgent:
# * base modes: These cannot be changed, so they are fully applied
for base_mode in modes.get_base_modes():
tool_inclusion_definitions.append(base_mode)
# * default modes: When not in a single-project context, these modes are dynamic (can later be turned off),
# so we consider only their inclusions (but not their exclusions, because these must not be hard)
for mode in modes.get_default_modes():
# * dynamically activated modes:
# - When not in a single-project context, these modes can later be turned off,
# so we consider only their inclusions (but not their exclusions, because these must not be hard).
# - In a single-project context, we can consider them fully.
for mode in modes.get_dynamically_activated_modes():
if is_single_project:
tool_inclusion_definitions.append(mode)
else:
@@ -869,18 +890,6 @@ class SerenaAgent:
raise ValueError("No active project. Please activate a project first.")
return project
def set_modes(self, mode_names: list[str]) -> None:
"""
Set the current mode configurations.
:param mode_names: List of mode names or paths to use
"""
self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names)
self._update_active_modes()
self._update_active_tools()
log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}")
def get_active_modes(self) -> list[SerenaAgentMode]:
"""
:return: the list of active modes
@@ -998,8 +1007,8 @@ class SerenaAgent:
self._active_modes.apply(self.serena_config)
if self._active_project:
self._active_modes.apply(self._active_project.project_config)
if self._mode_overrides:
self._active_modes.apply(self._mode_overrides)
if self._session_mode_selection_definition:
self._active_modes.apply(self._session_mode_selection_definition)
if log_message:
active_mode_names = self._active_modes.get_mode_names()
log.info(f"Active modes ({len(active_mode_names)}): {', '.join(active_mode_names)}")
+84 -18
View File
@@ -18,12 +18,12 @@ from sensai.util.string import dict_string
from tqdm import tqdm
from serena import serena_version
from serena.agent import SerenaAgent
from serena.config.client_setup import client_setup_handlers
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
LanguageBackend,
ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes,
ProjectConfig,
RegisteredProject,
SerenaConfig,
@@ -36,11 +36,8 @@ from serena.constants import (
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
)
from serena.mcp import SerenaMCPFactory
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry
from serena.prompt_factory import SerenaPromptFactory
from serena.util.cli_util import AutoRegisteringGroup
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
@@ -49,15 +46,17 @@ from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
_MAX_CONTENT_WIDTH = 200
_MODES_EXPLANATION = f"""\b\nBuilt-in mode names or paths to custom mode YAMLs with which to
override the default modes defined in the global Serena configuration or
_MODES_EXPLANATION = """\b\nBuilt-in mode names or paths to custom mode YAMLs with which to
override the default_modes defined in the global Serena configuration or
the active project.
For details on mode configuration, see
https://oraios.github.io/serena/02-usage/050_configuration.html#modes.
If no configuration changes were made, the base defaults are:
{get_dataclass_default(SerenaConfig, "default_modes")}.
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."""
"""
_ADD_MODES_EXPLANATION = """\b\nMode names or paths to custom mode YAMLs which shall
be added on top of the other modes specified by the global/project configuration.
For details on mode configuration, see
https://oraios.github.io/serena/02-usage/050_configuration.html#modes.
"""
def find_project_root(root: str | Path | None = None) -> str | None:
@@ -231,13 +230,22 @@ class TopLevelCommands(AutoRegisteringGroup):
)
@click.option(
"--mode",
"modes",
"default_modes",
type=str,
multiple=True,
default=(),
show_default=False,
help=_MODES_EXPLANATION,
)
@click.option(
"--add-mode",
"added_modes",
type=str,
multiple=True,
default=(),
show_default=False,
help=_ADD_MODES_EXPLANATION,
)
@click.option(
"--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]),
@@ -303,7 +311,8 @@ class TopLevelCommands(AutoRegisteringGroup):
project_file_arg: str | None,
project_from_cwd: bool | None,
context: str,
modes: Sequence[str],
default_modes: Sequence[str],
added_modes: Sequence[str],
language_backend: str | None,
transport: Literal["stdio", "sse", "streamable-http"],
host: str,
@@ -315,6 +324,8 @@ class TopLevelCommands(AutoRegisteringGroup):
trace_lsp_communication: bool | None,
tool_timeout: float | None,
) -> None:
from serena.mcp import SerenaMCPFactory
# initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config)
# * memory log handler (for use by GUI/Dashboard)
# * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop)
@@ -346,11 +357,16 @@ class TopLevelCommands(AutoRegisteringGroup):
log.warning("No project root found from %s; not activating any project", os.getcwd())
project_file = project_file_arg or project
mode_selection_def: ModeSelectionDefinition | None = None
if default_modes or added_modes:
mode_selection_def = ModeSelectionDefinitionWithAddedModes(default_modes=default_modes or None, added_modes=added_modes or None)
factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server(
host=host,
port=port,
modes=modes,
mode_selection_def=mode_selection_def,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
enable_web_dashboard=enable_web_dashboard,
open_web_dashboard=open_web_dashboard,
@@ -394,6 +410,8 @@ class TopLevelCommands(AutoRegisteringGroup):
def print_system_prompt(
project: str, log_level: str, only_instructions: bool, context: str, modes: Sequence[str] | None = None
) -> None:
from serena.agent import SerenaAgent
prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account."
postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks."
@@ -403,9 +421,14 @@ class TopLevelCommands(AutoRegisteringGroup):
modes_selection_def: ModeSelectionDefinition | None = None
if modes:
modes_selection_def = ModeSelectionDefinition(default_modes=modes)
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
print(serena_config.default_modes)
print(serena_config.base_modes)
agent = SerenaAgent(
project=os.path.abspath(project),
serena_config=SerenaConfig(web_dashboard=False, log_level=lvl),
serena_config=serena_config,
context=context_instance,
modes=modes_selection_def,
)
@@ -828,6 +851,8 @@ class ProjectCommands(AutoRegisteringGroup):
:param path: The path to check.
:param project: The path to the project directory, defaults to the current working directory.
"""
from serena.project import Project
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
if os.path.isabs(path):
@@ -851,6 +876,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.
"""
from serena.project import Project
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
if os.path.isabs(file):
@@ -887,6 +914,10 @@ class ProjectCommands(AutoRegisteringGroup):
:param project: path to the project directory, defaults to the current working directory.
"""
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
from serena.agent import SerenaAgent
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
serena_config = SerenaConfig.from_config_file()
@@ -1040,6 +1071,8 @@ class ToolCommands(AutoRegisteringGroup):
@click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.")
@click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).")
def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None:
from serena.tools import ToolRegistry
tool_registry = ToolRegistry()
if quiet:
if only_optional:
@@ -1062,6 +1095,9 @@ class ToolCommands(AutoRegisteringGroup):
@click.argument("tool_name", type=str)
@click.option("--context", type=str, default=None, help="Context name or path to context file.")
def description(tool_name: str, context: str | None = None) -> None:
from serena.agent import SerenaAgent
from serena.mcp import SerenaMCPFactory
# Load the context
serena_context = None
if context:
@@ -1089,16 +1125,26 @@ class PromptCommands(AutoRegisteringGroup):
@staticmethod
@click.command(
"list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
"list", help="Lists prompt names and YAML files that can be overridden.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
)
def list() -> None:
# list prompt names
click.echo("Prompts:")
factory = SerenaPromptFactory()
for key in factory.get_prompt_names():
template = factory.get_prompt_template(key)
is_overridden = not template.path.startswith(PROMPT_TEMPLATES_DIR_INTERNAL)
click.echo(f" * '{key}' ({template.path if is_overridden else 'default'})")
# list prompts files
click.echo("\nPrompt files (which you can override with the create-override command):")
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
for prompt_yaml_name in serena_prompt_yaml_names:
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
click.echo(f" * {user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
else:
click.echo(prompt_yaml_name)
click.echo(f" * {prompt_yaml_name}")
@staticmethod
@click.command(
@@ -1171,6 +1217,26 @@ class PromptCommands(AutoRegisteringGroup):
os.remove(user_prompt_yaml_path)
click.echo(f"Deleted override file '{prompt_yaml_name}'.")
@staticmethod
@click.command(
"print-prompt-template",
help="prints the (unrendered) template for the corresponding prompt name. "
"This respects custom prompt yaml overrides and thus will print the value that will be used in Serena",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
@click.argument("prompt_name", type=str)
def print_prompt_template(prompt_name: str) -> None:
click.echo(SerenaPromptFactory().get_prompt_template_string(prompt_name))
@staticmethod
@click.command(
"print-cc-system-prompt-override",
help="To be used specifically in Claude Code as value for `--system-prompt`",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
def print_cc_system_prompt_override() -> None:
click.echo(SerenaPromptFactory().create_cc_system_prompt_override())
_mode = ModeCommands()
_context = ContextCommands()
+8 -2
View File
@@ -57,15 +57,21 @@ class CodeEditor(Generic[TSymbol], ABC):
"""
raise NotImplementedError("This method must be overridden for each subclass")
def read_file(self, relative_path: str) -> str:
def read_file(self, relative_path: str, lines: tuple[int, int] | None = None) -> str:
"""
Reads the content of a file.
:param relative_path: the relative path of the file to read
:param lines: tuple of (first_line, last_line) to read only a specific line range (0-based, inclusive)
:return: the content of the file
"""
with self._open_file_context(relative_path) as file:
return file.get_contents()
contents = file.get_contents()
if lines is None:
return contents
else:
first_line, last_line = lines
return TextUtils.get_text_in_lines_range(contents, first_line, last_line)
@contextmanager
def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]:
+32 -7
View File
@@ -168,10 +168,22 @@ class NamedToolInclusionDefinition(ToolInclusionDefinition):
@dataclass
class ModeSelectionDefinition:
base_modes: Sequence[str] | None = None
default_modes: Sequence[str] | None = None
@dataclass
class ModeSelectionDefinitionWithBaseModes(ModeSelectionDefinition):
base_modes: Sequence[str] | None = ("interactive", "editing")
"""
the base modes to use, which are always guaranteed to be included
"""
@dataclass
class ModeSelectionDefinitionWithAddedModes(ModeSelectionDefinition):
added_modes: Sequence[str] | None = None
class LanguageBackend(Enum):
LSP = "LSP"
"""
@@ -228,7 +240,7 @@ class LineEnding(Enum):
@dataclass
class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin):
class SharedConfig(ToolInclusionDefinition, ToStringMixin):
"""Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form
(same as in ModeSelectionDefinition).
The defaults here shall be none and should be set to the global default values in SerenaConfig.
@@ -255,7 +267,7 @@ Uses $projectDir and $projectFolderName as placeholders.
@dataclass(kw_only=True)
class ProjectConfig(SharedConfig):
class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_name: str
languages: list[Language]
ignored_paths: list[str] = field(default_factory=list)
@@ -473,6 +485,9 @@ class ProjectConfig(SharedConfig):
included_optional_tools = data["included_optional_tools"] or []
additional_workspace_folders = data.get("additional_workspace_folders") or []
if "base_modes" in data and data["base_modes"] is not None:
log.warning("The base_modes setting in project.yml is deprecated and will be ignored.")
return cls(
project_name=data["project_name"],
languages=languages,
@@ -489,7 +504,7 @@ class ProjectConfig(SharedConfig):
encoding=data["encoding"],
line_ending=line_ending,
language_backend=language_backend,
base_modes=data["base_modes"],
added_modes=data["added_modes"],
default_modes=data["default_modes"],
symbol_info_budget=symbol_info_budget,
ls_specific_settings=data.get("ls_specific_settings", {}),
@@ -680,7 +695,7 @@ class RegisteredProject(ToStringMixin):
@dataclass(kw_only=True)
class SerenaConfig(SharedConfig):
class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
"""
Holds the Serena agent configuration, which is typically loaded from a YAML configuration file
(when instantiated via :method:`from_config_file`), which is updated when projects are added or removed.
@@ -734,7 +749,6 @@ class SerenaConfig(SharedConfig):
"""
the language backend to use for code understanding features
"""
default_modes: Sequence[str] | None = ("interactive", "editing")
line_ending: LineEnding = LineEnding.NATIVE
symbol_info_budget: float = 10.0
"""
@@ -872,7 +886,18 @@ class SerenaConfig(SharedConfig):
if path is None:
continue
num_migrations += 1
project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated
try:
project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated
except Exception as e:
log.error(
"Failed to load project configuration for %s: %s. "
"This project will be skipped. Fix or delete its "
".serena/project.yml (or remove it from "
"serena_config.yml) to re-enable it.",
path,
e,
)
continue
project = RegisteredProject(
project_root=str(path),
project_config=project_config,
@@ -4,7 +4,6 @@
# NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually!
from interprompt.multilang_prompt import PromptList
from interprompt.prompt_factory import PromptFactoryBase
from typing import Any
@@ -14,15 +13,27 @@ class PromptFactory(PromptFactoryBase):
A class for retrieving and rendering prompt templates and prompt lists.
"""
def get_info_jet_brains_debug_repl_template_string(self) -> str:
return self.get_prompt_template_string("info_jet_brains_debug_repl")
def create_info_jet_brains_debug_repl(self) -> str:
return self._render_prompt("info_jet_brains_debug_repl", locals())
def get_onboarding_prompt_template_string(self) -> str:
return self.get_prompt_template_string("onboarding_prompt")
def create_onboarding_prompt(self, *, system: Any) -> str:
return self._render_prompt("onboarding_prompt", locals())
def get_connection_prompt_template_string(self) -> str:
return self.get_prompt_template_string("connection_prompt")
def create_connection_prompt(self) -> str:
return self._render_prompt("connection_prompt", locals())
def get_system_prompt_template_string(self) -> str:
return self.get_prompt_template_string("system_prompt")
def create_system_prompt(
self,
*,
@@ -33,3 +44,9 @@ class PromptFactory(PromptFactoryBase):
mode_system_prompts: Any,
) -> str:
return self._render_prompt("system_prompt", locals())
def get_cc_system_prompt_override_template_string(self) -> str:
return self.get_prompt_template_string("cc_system_prompt_override")
def create_cc_system_prompt_override(self) -> str:
return self._render_prompt("cc_system_prompt_override", locals())
+29 -15
View File
@@ -339,8 +339,9 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
class SessionStartActivateProjectHook(Hook):
def execute(self) -> None:
message = (
"**IMPORTANT**: Activate the current working directory as project using Serena's tools if not already done. "
"If you haven't read Serena Instructions Manual yet, do so. "
"**IMPORTANT**: If the current directory is a coding project you are working on:"
" 1. activate it using Serena's activate_project tool unless already done. "
" 2. if you haven't read Serena Instructions Manual yet, do so. "
"Follow this instruction before doing anything else."
)
result = {
@@ -358,29 +359,41 @@ class SessionEndCleanupHook(Hook):
class PreToolUseAutoApproveSerenaHook(PreToolUseHook):
"""Pre-tool-use hook that auto-approves Serena tool calls while the client is in accept-edits mode.
"""Pre-tool-use hook that auto-approves Serena tool calls while the client is in a permissive permission mode.
Claude Code's ``acceptEdits`` permission mode only applies to its built-in editing tools;
Serena's destructive tools (e.g. ``replace_symbol_body`` or ``rename_symbol``) would still
prompt the user on every call. This hook emits an ``allow`` decision for any Serena MCP
tool call whenever the client reports ``acceptEdits`` as the active permission mode, so
blanket edit approvals also cover Serena's tools. In all other situations it stays silent,
preserving the default approval flow.
Claude Code's permissive permission modes (``acceptEdits`` for blanket edit approval and
``auto`` for hands-off autonomous execution) only apply to its built-in editing tools or
its auto-mode classifier; Serena's destructive tools (e.g. ``replace_symbol_body`` or
``rename_symbol``) would still prompt the user on every call. This hook emits an ``allow``
decision for any Serena MCP tool call whenever the client reports one of these modes as
the active permission mode, so blanket approvals also cover Serena's tools. In all other
situations it stays silent, preserving the default approval flow.
``bypassPermissions`` and ``dontAsk`` are deliberately excluded. ``bypassPermissions``
already approves everything before the hook would matter, so silence here is harmless.
``dontAsk`` is the user's deliberate deny-by-default posture (auto-deny unless an explicit
allow rule matches); the hook honors that choice and stays silent rather than blanket
overriding it.
"""
_ACCEPT_EDITS_MODE = "acceptEdits"
#: permission modes for which this hook emits an ``allow`` decision. Frozen so the
#: set is immutable at the class level; matching is exact (case-sensitive) against
#: the canonical mode strings emitted by Claude Code's hook payload.
_AUTO_APPROVE_MODES: frozenset[str] = frozenset({"acceptEdits", "auto"})
def is_accept_edits_mode(self) -> bool:
return self._permission_mode == self._ACCEPT_EDITS_MODE
def is_auto_approve_mode(self) -> bool:
return self._permission_mode in self._AUTO_APPROVE_MODES
def execute(self) -> None:
# only emit a decision when both the tool and the mode match; stay silent otherwise
if not self.is_serena_tool() or not self.is_accept_edits_mode():
if not self.is_serena_tool() or not self.is_auto_approve_mode():
return
# name the actual mode in the reason so logs/debug output are unambiguous
# (the same hook handles multiple modes now)
output_data = self.OutputData(
permission_decision="allow",
permission_decision_reason="Auto-approved: Serena tool call while client is in acceptEdits mode.",
permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.",
)
click.echo(output_data.to_json_string())
@@ -425,7 +438,8 @@ class HookCommands(AutoRegisteringGroup):
@staticmethod
@click.command(
"auto-approve",
help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in acceptEdits mode (Claude Code).",
help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in a "
"permissive permission mode (acceptEdits or auto, Claude Code).",
)
@_client_option
def auto_approve(client: str) -> None:
@@ -661,6 +661,54 @@ class JetBrainsPluginClient(ToStringMixin):
}
return self._make_request("POST", "/debugReplClose", request_data)
def run_inspections(
self,
relative_path: str,
min_severity: str | None = None,
inspection_names: list[str] | None = None,
start_line: int | None = None,
end_line: int | None = None,
) -> jb.RunInspectionsResponse:
"""
Runs IDE inspections on the given file and returns the results.
:param relative_path: the relative path to the file to inspect
:param min_severity: minimum severity level to include (e.g. "WARNING", "ERROR")
:param inspection_names: optional list of specific inspection names to run
:param start_line: optional start line to restrict the inspection range
:param end_line: optional end line to restrict the inspection range
"""
request_data: dict[str, Any] = {
"relativePath": relative_path,
}
if min_severity is not None:
request_data["minSeverity"] = min_severity
if inspection_names is not None:
request_data["inspectionNames"] = inspection_names
if start_line is not None:
request_data["startLine"] = start_line
if end_line is not None:
request_data["endLine"] = end_line
return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data))
def list_inspections(
self,
language: str | None = None,
group_path_contains: str | None = None,
) -> jb.ListInspectionsResponse:
"""
Lists available IDE inspections, optionally filtered by language and/or group path.
:param language: optional language to filter inspections by (e.g. "Java", "Python")
:param group_path_contains: optional substring to filter inspection group paths
"""
request_data: dict[str, Any] = {}
if language is not None:
request_data["language"] = language
if group_path_contains is not None:
request_data["groupPathContains"] = group_path_contains
return cast(jb.ListInspectionsResponse, self._make_request("POST", "/listInspections", request_data))
def close(self) -> None:
self._session.close()
+27
View File
@@ -89,3 +89,30 @@ class TypeHierarchyNodeDTO(TypedDict):
class TypeHierarchyResponse(TypedDict):
hierarchy: NotRequired[list[TypeHierarchyNodeDTO]]
num_levels_not_included: NotRequired[int]
class InspectionResultDTO(TypedDict):
inspection_name: str
severity: str
message: str
relative_path: str
start_line: int
end_line: int
start_col: NotRequired[int]
end_col: NotRequired[int]
quick_fix_available: NotRequired[bool]
class RunInspectionsResponse(TypedDict):
inspections: list[InspectionResultDTO]
class InspectionInfoDTO(TypedDict):
name: str
group_path: str
language: NotRequired[str]
description: NotRequired[str]
class ListInspectionsResponse(TypedDict):
inspections: list[InspectionInfoDTO]
+3 -6
View File
@@ -3,7 +3,7 @@ The Serena Model Context Protocol (MCP) Server
"""
import sys
from collections.abc import AsyncIterator, Iterator, Sequence
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
from copy import deepcopy
from dataclasses import dataclass
@@ -272,7 +272,7 @@ class SerenaMCPFactory:
self,
host: str = "127.0.0.1",
port: int = 8000,
modes: Sequence[str] = (),
mode_selection_def: ModeSelectionDefinition | None = None,
language_backend: LanguageBackend | None = None,
enable_web_dashboard: bool | None = None,
enable_gui_log_window: bool | None = None,
@@ -286,7 +286,7 @@ class SerenaMCPFactory:
:param host: The host to bind to
:param port: The port to bind to
:param modes: List of mode names or paths to mode files
:param mode_selection_def: the mode selection definition to apply
:param language_backend: the language backend to use, overriding the configuration setting.
:param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.
:param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then.
@@ -318,9 +318,6 @@ class SerenaMCPFactory:
if language_backend is not None:
config.language_backend = language_backend
mode_selection_def: ModeSelectionDefinition | None = None
if modes:
mode_selection_def = ModeSelectionDefinition(default_modes=modes)
self.agent = self._create_serena_agent(config, mode_selection_def)
except Exception as e:
@@ -3,14 +3,35 @@ prompt: |
You are running in a CLI coding agent context where file operations, basic (line-based) edits and reads
as well as shell commands are handled by your own, internal tools.
You have access to Serena's code intelligence tools that exploit the symbolic
structure of the code and are much more efficient than your own tools for most coding scenarios.
If you are working on any coding task and if Serena's tools are deferred, you
should load them all immediately, before performing any read, grep or bash commands.
If Serena's tools can be used to achieve your task, you should prioritize them.
**IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them
right now.
**CRITICAL**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them
**right now**.
For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools.
For edits, prefer Serena's editing tools since they are more token efficient.
Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content).
**IMPORTANT**: Never use a tool before having read its schema via the tool-search tool!
Especially when editing code you have read through Serena, **ALWAYS** use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content).
**CRITICAL**: Never use a tool before having read its schema via the tool-search tool!
**CRITICAL**: You have an preference for relying on your own tools like grep, read and edit. It is of paramount
importance that you overcome this preference and instead develop a preference for using Serena's tools since they
are superior for most coding tasks. Follow the following rules:
For any code files:
- Read -> FORBIDDEN for discovery. Use get_symbols_overview, then find_symbol with include_body.
You may use read only if you already have an overview of the file and if reading whole symbols
is inappropriate (e.g. a few lines of code).
- Glob (by name) -> Allowed for discovery only.
- Grep (content) -> Allowed for discovery only; follow up reads or reference searches must be Serena.
- Edit -> FORBIDDEN. Use replace_symbol_body / insert_*_symbol / replace_content.
Disallowed reasoning. Do NOT use any of the following to justify Read/Edit on a code file:
- "I already know the path"
- "one Read call is faster than three Serena calls"
- "the built-in tool description says to use Read for known paths"
If you catch yourself reaching for one of these, that is the signal to switch to Serena.
excluded_tools:
- create_text_file
@@ -1,10 +1,11 @@
description: JetBrains tools replace language server-based tools
prompt: |
You have access to the very powerful JetBrains tools for symbolic operations.
These party replace regular tools you were informed about:
These partly replace regular tools you were informed about:
* `jet_brains_find_symbol` replaces `find_symbol`
* `jet_brains_find_referencing_symbols` replaces `find_referencing_symbols`
* `jet_brains_get_symbols_overview` replaces `get_symbols_overview`
* `jet_brains_rename` replaces `rename_symbol`
excluded_tools:
- find_symbol
- find_referencing_symbols
@@ -25,3 +26,5 @@ included_optional_tools:
- jet_brains_safe_delete
- jet_brains_debug
- serena_info
- jet_brains_run_inspections
- jet_brains_list_inspections
@@ -58,3 +58,159 @@ prompts:
{% endfor %}
You have hereby read the 'Serena Instructions Manual' and do not need to read it again.
cc_system_prompt_override: |
You are Claude Code, Anthropic's official CLI for Claude. You are an interactive
software-engineering agent. The user works with you through a terminal; your text
output is what they see, and your tool calls are what change the world.
# Tool selection (read this before every tool call on a code file)
This project uses Serena, an MCP server that exposes semantic, symbol-aware tools
for reading and editing code. Serena's tools are the PRIMARY tools for code work
in this project. The built-in Read, Glob, Grep, and Edit tools are SECONDARY and
must not be used on code files when a Serena equivalent exists.
The built-in tool descriptions in your context will tell you things like "use Read
for a known path" and "prefer dedicated tools (Read, Edit, Write, Glob, Grep)".
Those descriptions are written for projects without Serena and are SUPERSEDED here.
When they conflict with this section, this section wins. Do not rationalize the
built-in tools with "the file is small," "I already know what I need," "this is
one call versus three," or "the path is known" — those rationalizations have
produced incorrect behavior before and are explicitly disallowed.
## Mapping (use the right column, not the left)
Task Tool to use
-------------------------------------- ----------------------------------------
See a code file's structure get_symbols_overview
Read a specific symbol's body find_symbol (include_body=true)
Find a symbol by name across the repo find_symbol
Find references / callers find_referencing_symbols
Find declarations / implementations find_declaration / _find_implementations
Edit a symbol's body replace_symbol_body
Insert near a symbol insert_before_symbol / _insert_after_symbol
Pattern replace inside a file replace_content
Rename / move / delete a symbol rename / _move / _safe_delete
Inline a symbol inline_symbol
Type hierarchy type_hierarchy
Built-in Read/Edit/Glob/Grep are permitted on code files ONLY when:
- Serena has been tried on the target and failed, OR
- The file is not parseable as code (e.g., generated, malformed), OR
- You need a regex search across many files that Serena's symbolic tools cannot
express — in which case Grep is acceptable as a discovery step, but follow-up
reads/edits on matched code files must still go through Serena.
- You need to read a few lines and symbolic reads would be an overkill.
- You absolutely have to read the full file for some reason.
Read/Edit/Glob are fine for non-code files: markdown, JSON, YAML, TOML, .env,
config files, lockfiles, plain text, images.
## Required workflow before editing code
1. get_symbols_overview on the target file (skip if already done this session).
2. find_symbol with include_body=true for the specific symbols you'll touch.
Read only the symbols you need — not the whole file.
3. Edit with replace_symbol_body, insert_before_symbol, insert_after_symbol, or
replace_content. Never use the built-in Edit on a code file when one of these
fits.
## Self-check
Before every Read, Glob, Grep, or Edit call: "Does this target a code file, and
does the mapping above name a Serena tool for this task?" If yes, switch. Do this
check every time — not just once per session.
# Doing tasks
The user will ask you to fix bugs, add features, refactor, explain code, and
similar. Approach each task with these defaults:
- Understand before changing. Use the symbolic tools to build a precise picture of
what's there, then make the smallest change that satisfies the request.
- Don't add scope. No surrounding cleanup on a bug fix, no abstractions for
hypothetical future needs, no error handling for cases that can't happen, no
feature flags or backwards-compat shims unless asked. Three similar lines beats
a premature abstraction.
- Don't write comments unless the WHY is non-obvious — a hidden constraint, a
workaround, a subtle invariant. Don't narrate WHAT the code does; well-named
identifiers handle that. Don't reference the current task or PR in comments.
- Prefer editing existing files to creating new ones. Never create *.md or README
files unless the user explicitly asks.
- For exploratory questions ("what could we do about X?"), reply in 2–3 sentences
with a recommendation and the main tradeoff. Don't implement until the user
agrees.
- For UI/frontend changes you can't test in a browser, say so explicitly rather
than claiming success.
- Watch for security issues (injection, XSS, SQL injection, path traversal, secret
leaks). Fix them when you spot them.
# Executing actions with care
Local, reversible actions (editing files, running tests, reading state) are free
to take. Pause and confirm before:
- Destructive ops: deleting files/branches, dropping tables, killing processes,
rm -rf, overwriting uncommitted changes, git reset --hard, force-push.
- Hard-to-reverse ops: amending published commits, removing dependencies,
modifying CI/CD.
- Externally visible actions: pushing, opening/closing/commenting on PRs or
issues, sending messages, posting to third-party services.
- Uploading content to third-party tools (renderers, pastebins) — assume it's
public and may be cached.
If you hit an obstacle, find the root cause. Don't bypass it with --no-verify,
--force, or by deleting the thing in your way. If you find unfamiliar files,
branches, or config, investigate before deleting — it may be the user's
in-progress work.
A user approving an action once does not approve it forever. Match the scope of
your action to what was actually requested.
# Git and commits
- Only commit when the user asks. Never proactively.
- Never update git config. Never skip hooks (--no-verify, --no-gpg-sign) unless
the user explicitly asks.
- Prefer new commits over --amend. If a pre-commit hook fails, the commit didn't
happen — fix the issue, re-stage, and create a NEW commit (not --amend, which
would modify the previous commit).
- Stage files by name, not `git add -A` or `git add .` — those can sweep in
secrets or large binaries.
- Don't commit files that look like secrets (.env, credentials.json, *.pem). If
the user explicitly asks, warn first.
- For commit messages, use a HEREDOC to preserve formatting. End the trailer with:
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Don't push unless asked. Never force-push to main/master; warn if asked.
- For PRs, use `gh` via Bash. Look at the full diff against the base branch (not
just the latest commit) before drafting title/body.
# Tone and output
- Your tool calls aren't visible to the user — only your text is. Before your
first tool call, say in one sentence what you're about to do. While working,
give short updates at key moments: a finding, a direction change, a blocker.
Brief is good; silent is not.
- Don't narrate internal deliberation. State results and decisions; skip the
thinking-aloud.
- End-of-turn summary: one or two sentences max. What changed, what's next.
Nothing else.
- Match response shape to the task: a simple question gets a direct answer, not
headers and sections.
- No emojis unless the user asks.
- Use Github-flavored markdown. Reference code locations as `path:line` so the
user can jump.
# Parallel tool calls
When tool calls don't depend on each other, issue them in a single response.
When they do depend on each other, issue them sequentially with the dependent
values resolved. Don't use placeholders or guess.
# Asking for help vs. acting
When a request is ambiguous in a way that materially changes the work, ask one
focused question. When it's only ambiguous in ways that don't change the work,
pick the reasonable interpretation and proceed — and say which interpretation
you picked.
+26 -61
View File
@@ -3,16 +3,18 @@ project_name: "project_name"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog 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.)
@@ -76,71 +78,34 @@ read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
+23 -16
View File
@@ -37,13 +37,15 @@ gui_log_window: False
# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html
web_dashboard: True
# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled).
# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system
# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by
# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by
# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port
# may be higher if you have multiple instances running; try ports 24283, 24284, etc.)
# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html
# whether to open the Dashboard window/browser tab when Serena starts (provided that `web_dashboard` is enabled).
# If set to false, you can still open the dashboard manually:
# * When using an interface that supports a tray icon (see setting `web_dashboard_interface`),
# you can conveniently open the dashboard from the system tray.
# * When using the `browser` interface (no tray icon), so you can only open the dashboard by
# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by
# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port
# may be higher if you have multiple instances running; try ports 24283, 24284, etc.)
# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html
web_dashboard_open_on_launch: True
# defines the interface (application mode) used for the web dashboard (if enabled).
@@ -59,6 +61,7 @@ web_dashboard_open_on_launch: True
# opening the dashboard in browser tabs when selected from the tray menu.
# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested.
# On Linux, this cannot be universally supported, but it may work in some desktop environments.
# See https://oraios.github.io/serena/02-usage/060_dashboard.html
web_dashboard_interface:
# the address the web dashboard will listen on (bind address).
@@ -112,19 +115,23 @@ included_optional_tools: []
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If this is undefined, no base modes are included.
# The project configuration (project.yml) may override this setting.
# list of mode names to that are always to be included in the set of active modes.
# The full set of modes to be activated is base_modes + default_modes + added_modes,
# where added_modes can be defined by projects/CLI parameters.
# If this is undefined/empty, no base modes are included.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode).
default_modes:
- interactive
- editing
# list of mode names that are to be activated by default (but can be overridden by projects/CLI params).
# The full set of modes to be activated is base_modes + default_modes + added_modes,
# where added_modes are defined by projects/CLI parameters.
# If this is undefined/empty, no default modes are defined.
# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# Used as default for tools where the apply method has a default maximal answer length.
# Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default
# through the global configuration.
+266 -1
View File
@@ -3,6 +3,7 @@ import json
import logging
import os
from abc import ABC, abstractmethod
from collections import OrderedDict
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import asdict, dataclass
from time import perf_counter
@@ -11,7 +12,7 @@ from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar
from sensai.util.string import ToStringMixin
import serena.jetbrains.jetbrains_types as jb
from solidlsp import SolidLanguageServer
from solidlsp import SolidLanguageServer, ls_types
from solidlsp.ls import LSPFileBuffer
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
@@ -309,6 +310,12 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
end_line = end_pos["line"] if end_pos else None
return start_line, end_line
def get_body_line_numbers_or_raise(self) -> tuple[int, int]:
start_line, end_line = self.get_body_line_numbers()
if start_line is None or end_line is None:
raise ValueError(f"Body line numbers could not be determined for {self.get_name_path()}")
return start_line, end_line
@property
def line(self) -> int | None:
"""
@@ -869,6 +876,264 @@ class LanguageServerSymbolRetriever:
return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references]
def find_implementing_symbols(
self,
name_path: str,
relative_file_path: str,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[LanguageServerSymbol]:
"""
Find all symbols that implement the specified symbol, which is assumed to be unique.
:param name_path: the name path of the symbol to find implementations for. While this can be a matching pattern,
it should usually be the full path to ensure uniqueness.
:param relative_file_path: the relative path of the file in which the implemented symbol is defined.
:param include_body: whether to include the body of all symbols in the result.
:param include_kinds: which kinds of symbols to include in the result.
:param exclude_kinds: which kinds of symbols to exclude from the result.
"""
symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path)
return self.find_implementing_symbols_by_location(
symbol.location,
include_body=include_body,
include_kinds=include_kinds,
exclude_kinds=exclude_kinds,
)
def find_implementing_symbols_by_location(
self,
symbol_location: LanguageServerSymbolLocation,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[LanguageServerSymbol]:
"""
Find all symbols that implement the symbol at the given location.
:param symbol_location: the location of the symbol for which to find implementations.
Does not need to include an end_line, as it is unused in the search.
:param include_body: whether to include the body of all symbols in the result.
:param include_kinds: an optional sequence of ints representing the LSP symbol kind.
If provided, only symbols of the given kinds will be included in the result.
:param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
Takes precedence over include_kinds.
:return: a list of symbols that implement the given symbol
"""
if not symbol_location.has_position_in_file():
raise ValueError("Symbol location does not contain a valid position in a file")
assert symbol_location.relative_path is not None
assert symbol_location.line is not None
assert symbol_location.column is not None
lang_server = self.get_language_server(symbol_location.relative_path)
implementing_symbols = lang_server.request_implementing_symbols(
relative_file_path=symbol_location.relative_path,
line=symbol_location.line,
column=symbol_location.column,
include_body=include_body,
)
if include_kinds is not None:
implementing_symbols = [s for s in implementing_symbols if s["kind"] in include_kinds]
if exclude_kinds is not None:
implementing_symbols = [s for s in implementing_symbols if s["kind"] not in exclude_kinds]
return [LanguageServerSymbol(s) for s in implementing_symbols]
def find_declaration(
self,
relative_file_path: str,
line: int,
column: int,
include_body: bool = False,
) -> LanguageServerSymbol | None:
"""
Find the declaration/definition of the symbol at the given file position.
:param relative_file_path: the relative path to the file in which the symbol usage occurs.
:param line: the 0-based line number of the symbol usage.
:param column: the 0-based column number of the symbol usage.
:param include_body: whether to include the body of the defining symbol in the result.
:return: the defining symbol, or None if no definition could be resolved.
"""
lang_server = self.get_language_server(relative_file_path)
defining_symbol = lang_server.request_defining_symbol(
relative_file_path=relative_file_path,
line=line,
column=column,
include_body=include_body,
)
if defining_symbol is None:
return None
return LanguageServerSymbol(defining_symbol)
def get_file_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
"""
Get diagnostics for a file, optionally restricted to a line range and minimum severity.
:param relative_file_path: the relative path to the file.
:param start_line: the first 0-based line to include.
:param end_line: the last 0-based line to include. `-1` means until end of file.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
:return: the diagnostics matching the requested constraints.
"""
lang_server = self.get_language_server(relative_file_path)
return lang_server.request_text_document_diagnostics(
relative_file_path=relative_file_path,
start_line=start_line,
end_line=end_line,
min_severity=min_severity,
)
@staticmethod
def _symbol_identity(symbol: LanguageServerSymbol) -> tuple[str | None, int | None, int | None, str]:
return (symbol.relative_path, symbol.line, symbol.column, symbol.get_name_path())
@staticmethod
def _normalize_symbol_for_diagnostics(symbol: LanguageServerSymbol) -> LanguageServerSymbol:
current_symbol = symbol
while current_symbol.is_low_level():
parent_symbol = current_symbol.get_parent()
if parent_symbol is None:
break
current_symbol = parent_symbol
return current_symbol
def find_diagnostic_owner_symbol(self, relative_file_path: str, line: int, column: int) -> LanguageServerSymbol | None:
"""
Find the symbol that should own a diagnostic at the given position.
This prefers the structural container of the diagnostic over low-level symbols such as
local variables, because diagnostics are typically more meaningful when grouped by the
surrounding function, method, class, or analogous construct.
:param relative_file_path: the relative path to the file containing the diagnostic.
:param line: the 0-based line of the diagnostic.
:param column: the 0-based column of the diagnostic.
:return: the owning symbol, or None if no symbol could be resolved.
"""
lang_server = self.get_language_server(relative_file_path)
symbol_dict = lang_server.request_symbol_at_location(
relative_file_path=relative_file_path,
line=line,
column=column,
)
if symbol_dict is None:
return None
return self._normalize_symbol_for_diagnostics(LanguageServerSymbol(symbol_dict))
def _get_diagnostics_for_symbol(self, symbol: LanguageServerSymbol, min_severity: int) -> list[ls_types.Diagnostic]:
relative_path = symbol.relative_path
if relative_path is None:
return []
start_line, end_line = symbol.get_body_line_numbers()
if start_line is None:
if symbol.line is None:
return []
start_line = symbol.line
if end_line is None:
end_line = start_line
return self.get_file_diagnostics(
relative_file_path=relative_path,
start_line=start_line,
end_line=end_line,
min_severity=min_severity,
)
def get_symbol_diagnostics(
self,
name_path: str,
reference_file: str | None = None,
check_symbol_references: bool = False,
min_severity: int = 4,
) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]:
"""
Get diagnostics for the specified symbol and, optionally, for all symbols that reference it.
:param name_path: the name path of the symbol to find. It should usually be unique.
:param reference_file: optional file path used to disambiguate the symbol search.
:param check_symbol_references: whether to additionally collect diagnostics for referencing symbols.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
:return: a mapping from symbols to the diagnostics that overlap their body ranges.
"""
symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=reference_file or None)
return self.get_symbol_diagnostics_by_location(
symbol.location,
check_symbol_references=check_symbol_references,
min_severity=min_severity,
)
def get_symbol_diagnostics_by_location(
self,
symbol_location: LanguageServerSymbolLocation,
check_symbol_references: bool = False,
min_severity: int = 4,
) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]:
"""
Get diagnostics for the symbol at the given location and, optionally, for all referencing symbols.
:param symbol_location: location of the symbol to inspect.
:param check_symbol_references: whether to additionally collect diagnostics for referencing symbols.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
:return: an ordered mapping from symbols to the diagnostics that overlap their body ranges.
"""
if not symbol_location.has_position_in_file():
raise ValueError("Symbol location does not contain a valid position in a file")
symbol = self.find_by_location(symbol_location)
if symbol is None:
assert symbol_location.relative_path is not None
assert symbol_location.line is not None
assert symbol_location.column is not None
lang_server = self.get_language_server(symbol_location.relative_path)
symbol_dict = lang_server.request_symbol_at_location(
relative_file_path=symbol_location.relative_path,
line=symbol_location.line,
column=symbol_location.column,
)
if symbol_dict is None:
return {}
symbol = LanguageServerSymbol(symbol_dict)
symbols_to_check: "OrderedDict[tuple[str | None, int | None, int | None, str], LanguageServerSymbol]" = OrderedDict()
symbols_to_check[self._symbol_identity(symbol)] = symbol
if check_symbol_references:
reference_symbols = self.find_referencing_symbols_by_location(
symbol.location,
include_body=False,
exclude_kinds=[SymbolKind.File, SymbolKind.Module, SymbolKind.Package, SymbolKind.Namespace],
)
for reference in reference_symbols:
reference_relative_path = reference.get_relative_path()
normalized_reference_symbol = None
if reference_relative_path is not None:
normalized_reference_symbol = self.find_diagnostic_owner_symbol(
relative_file_path=reference_relative_path,
line=reference.line,
column=reference.character,
)
if normalized_reference_symbol is None:
normalized_reference_symbol = self._normalize_symbol_for_diagnostics(reference.symbol)
symbols_to_check.setdefault(self._symbol_identity(normalized_reference_symbol), normalized_reference_symbol)
result: dict[LanguageServerSymbol, list[ls_types.Diagnostic]] = {}
for current_symbol in symbols_to_check.values():
diagnostics = self._get_diagnostics_for_symbol(current_symbol, min_severity=min_severity)
if diagnostics:
result[current_symbol] = diagnostics
return result
def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]:
"""
:param relative_path: the path of the file for which to get the symbol overview
+52 -39
View File
@@ -12,7 +12,7 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional
from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional
from serena.util.file_system import scan_directory
from serena.util.text_utils import ContentReplacer, search_files
@@ -48,7 +48,7 @@ class ReadFileTool(Tool):
return self._limit_length(result, max_answer_chars)
class CreateTextFileTool(Tool, ToolMarkerCanEdit):
class CreateTextFileTool(EditingToolWithDiagnostics):
"""
Creates/overwrites a file in the project directory.
"""
@@ -61,23 +61,27 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit):
:param content: the (appropriately encoded) content to write to the file
:return: a message indicating success or failure
"""
project_root = self.get_project_root()
abs_path = (Path(project_root) / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
# validating the destination path
project_root = self.get_project_root()
abs_path = (Path(project_root) / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
if will_overwrite_existing:
self.project.validate_relative_path(relative_path, require_not_ignored=True)
else:
assert abs_path.is_relative_to(self.get_project_root()), (
f"Cannot create file outside of the project directory, got {relative_path=}"
)
if will_overwrite_existing:
self.project.validate_relative_path(relative_path, require_not_ignored=True)
else:
assert abs_path.is_relative_to(self.get_project_root()), (
f"Cannot create file outside of the project directory, got {relative_path=}"
)
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str)
answer = f"File created: {relative_path}."
if will_overwrite_existing:
answer += " Overwrote existing file."
return answer
# writing the file
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str)
answer = f"File created: {relative_path}."
if will_overwrite_existing:
answer += " Overwrote existing file."
return diagnostics_context.format_result(answer)
class ListDirTool(Tool):
@@ -156,7 +160,7 @@ class FindFileTool(Tool):
return result
class ReplaceContentTool(Tool, ToolMarkerCanEdit):
class ReplaceContentTool(EditingToolWithDiagnostics):
"""
Replaces content in a file (optionally using regular expressions).
"""
@@ -212,16 +216,17 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
Performs the replacement, with additional options not exposed in the tool.
This function can be used internally by other tools.
"""
self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored)
with EditedFileContext(relative_path, self.create_code_editor()) as context:
original_content = context.get_original_content()
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
updated_content = replacer.replace(original_content, needle, repl)
context.set_updated_content(updated_content)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored)
with EditedFileContext(relative_path, self.create_code_editor()) as context:
original_content = context.get_original_content()
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
updated_content = replacer.replace(original_content, needle, repl)
context.set_updated_content(updated_content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Deletes a range of lines within a file.
"""
@@ -241,12 +246,13 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
"""
code_editor = self.create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
return diagnostics_context.format_result(SUCCESS_RESULT)
class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Replaces a range of lines within a file with new content.
"""
@@ -268,16 +274,19 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
:param end_line: the 0-based index of the last line to be deleted
:param content: the content to insert
"""
# normalizing the replacement content
if not content.endswith("\n"):
content += "\n"
result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line)
if result != SUCCESS_RESULT:
return result
self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
code_editor.insert_at_line(relative_path, start_line, content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Inserts content at a given line in a file.
"""
@@ -298,11 +307,15 @@ class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
:param line: the 0-based index of the line to insert content at
:param content: the content to be inserted
"""
# normalizing the inserted content
if not content.endswith("\n"):
content += "\n"
code_editor = self.create_code_editor()
code_editor.insert_at_line(relative_path, line, content)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.insert_at_line(relative_path, line, content)
return diagnostics_context.format_result(SUCCESS_RESULT)
class SearchForPatternTool(Tool):
+73
View File
@@ -595,3 +595,76 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Runs JetBrains IDE inspections on a file and returns the results.
"""
def apply(
self,
relative_path: str,
min_severity: str | None = None,
inspection_names: list[str] | None = None,
start_line: int | None = None,
end_line: int | None = None,
max_answer_chars: int = -1,
) -> str:
"""
Runs IDE inspections (code analysis) on the given file and returns the problems found.
This leverages the full power of JetBrains' static analysis engine, including language-specific
inspections, type checking, potential bugs, code style issues, and more.
:param relative_path: the relative path to the file to inspect.
:param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO").
If not specified, all severities are returned.
:param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]).
If not specified, all applicable inspections are run.
:param start_line: optional 1-based start line to restrict the inspection range.
:param end_line: optional 1-based end line to restrict the inspection range.
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: JSON string with inspection results including severity, message, and location.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.run_inspections(
relative_path=relative_path,
min_severity=min_severity,
inspection_names=inspection_names,
start_line=start_line,
end_line=end_line,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Lists available JetBrains IDE inspections, optionally filtered by language or group.
"""
def apply(
self,
language: str | None = None,
group_path_contains: str | None = None,
max_answer_chars: int = -1,
) -> str:
"""
Lists the available IDE inspections. Use this to discover which inspections can be passed
to the run_inspections tool's `inspection_names` parameter.
:param language: optional language to filter by (e.g. "Java", "Python", "Kotlin").
:param group_path_contains: optional substring to match against the inspection group path
(e.g. "probable bugs", "code style").
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: JSON string with the list of available inspections including name, group path, and language.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.list_inspections(
language=language,
group_path_contains=group_path_contains,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
+259 -17
View File
@@ -6,20 +6,24 @@ import copy
import os
from collections import Counter, defaultdict
from collections.abc import Sequence
from typing import Any
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper
from serena.tools import (
SUCCESS_RESULT,
EditingToolWithDiagnostics,
Tool,
ToolMarkerSymbolicEdit,
ToolMarkerSymbolicRead,
)
from serena.tools.tools_base import ToolMarkerOptional
from serena.util.ls_diagnostics import GroupedDiagnostics
from serena.util.text_utils import find_text_coordinates
from solidlsp.ls_types import SymbolKind
class RestartLanguageServerTool(Tool, ToolMarkerOptional):
"""Restarts the language server, may be necessary when edits not through Serena happen."""
"""Restarts the language server(s)."""
def apply(self) -> str:
"""Use this tool only on explicit user request or after confirmation.
@@ -324,7 +328,242 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results)
class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
class FindImplementationsTool(Tool, ToolMarkerSymbolicRead):
"""
Finds symbols that implement the given symbol using the language server backend.
"""
# noinspection PyDefaultArgument
def apply(
self,
name_path: str,
relative_path: str,
include_info: bool = False,
include_kinds: list[int] = [], # noqa: B006
exclude_kinds: list[int] = [], # noqa: B006
max_answer_chars: int = -1,
) -> str:
"""
Finds implementations of the symbol at the given `name_path`.
:param name_path: for finding the symbol to find implementations for, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol for which to find implementations.
Note that here you can't pass a directory but must pass a file.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the implementing symbols.
:param include_kinds: same as in the `find_symbol` tool.
:param exclude_kinds: same as in the `find_symbol` tool.
:param max_answer_chars: same as in the `find_symbol` tool.
:return: a list of JSON objects with the symbols implementing the requested symbol
"""
include_body = False
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()
implementing_symbols = symbol_retriever.find_implementing_symbols(
name_path,
relative_file_path=relative_path,
include_body=include_body,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
)
symbol_dicts = [
dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols
]
if include_info:
info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols)
for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True):
if symbol_info := info_by_symbol.get(s):
s_dict["info"] = symbol_info
s_dict.pop("name", None) # name is included in the info
result = self._to_json(symbol_dicts)
return self._limit_length(result, max_answer_chars)
class FindDeclarationTool(Tool, ToolMarkerSymbolicRead):
"""
Finds the declaration/definition of a symbol
"""
def apply(
self,
relative_path: str,
regex: str,
containing_symbol_name_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
) -> str:
r"""
Finds the declaration of a symbol.
:param relative_path: the relative path to the source file containing the symbol for which to find the declaration.
:param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup.
For example, to find the declaration of the `process` method in a call like `obj.process()`,
pass an expression like "obj\.(process)\(process_input_arg=37\)".
Prefer regexes with sufficiently large context around the group to render the match unambiguous.
Uses Python syntax with MULTILINE and DOTALL flags enabled.
:param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file.
:param include_body: whether to include the symbol's body in the result. Default False.
:param include_info: whether to include additional info (hover-like). Default False.
"""
symbol_retriever = self.create_language_server_symbol_retriever()
relative_path = self._sanitize_input_param(relative_path)
regex = self._sanitize_input_param(regex)
# find relevant location for lookup
editor = self.create_code_editor()
if not containing_symbol_name_path:
content = editor.read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
else:
symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path)
body_line_numers = symbol.get_body_line_numbers_or_raise()
content = editor.read_file(relative_path, lines=body_line_numers)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
coords.line += body_line_numers[0]
# retrieve declaration
defining_symbol = symbol_retriever.find_declaration(
relative_file_path=relative_path,
line=coords.line,
column=coords.col,
include_body=include_body,
)
if defining_symbol is None:
raise ValueError(
f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}."
)
# create output
symbol_dict = self._defining_symbol_to_result_dict(
symbol_retriever,
defining_symbol,
include_body,
include_info,
)
result = self._to_json(symbol_dict)
return result
@staticmethod
def _defining_symbol_to_result_dict(
symbol_retriever: Any,
defining_symbol: LanguageServerSymbol,
include_body: bool,
include_info: bool,
) -> dict[str, Any]:
symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True))
if not include_body and include_info:
if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol):
symbol_dict["info"] = symbol_info
symbol_dict.pop("name", None)
return symbol_dict
class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
"""
Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol.
"""
FILE_LEVEL_DIAGNOSTIC_BUCKET = "<file>"
def apply(
self,
relative_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
max_answer_chars: int = -1,
) -> str:
"""
Gets diagnostics for a file. Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`.
If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path `<file>`.
:param relative_path: the relative path to the file to inspect.
:param start_line: the first 0-based line to include. Defaults to 0.
:param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:param max_answer_chars: same as in the `find_symbol` tool.
:return: grouped diagnostics for the requested file.
"""
symbol_retriever = self.create_language_server_symbol_retriever()
diagnostics = symbol_retriever.get_file_diagnostics(
relative_file_path=relative_path,
start_line=start_line,
end_line=end_line,
min_severity=min_severity,
)
grouped_diagnostics = GroupedDiagnostics()
for diagnostic in diagnostics:
diag_range = diagnostic["range"]["start"]
name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET
owner_symbol = symbol_retriever.find_diagnostic_owner_symbol(
relative_file_path=relative_path,
line=diag_range["line"],
column=diag_range["character"],
)
if owner_symbol is not None:
name_path = owner_symbol.get_name_path()
grouped_diagnostics.add(relative_path, name_path, diagnostic)
result = self._to_json(grouped_diagnostics.get_dict())
return self._limit_length(result, max_answer_chars)
class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Gets diagnostics for a symbol and, optionally, for symbols that reference it.
"""
def apply(
self,
name_path: str,
reference_file: str = "",
check_symbol_references: bool = False,
min_severity: int = 4,
max_answer_chars: int = -1,
) -> str:
"""
Gets diagnostics for the specified symbol. When `check_symbol_references` is true, diagnostics for all
referencing symbols are also included. The result is grouped as
`relative_path -> severity -> name_path -> diagnostics_results`.
:param name_path: the name path of the symbol to inspect.
:param reference_file: optional file path used to disambiguate the symbol search.
:param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:param max_answer_chars: same as in the `find_symbol` tool.
:return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols.
"""
symbol_retriever = self.create_language_server_symbol_retriever()
diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics(
name_path=name_path,
reference_file=reference_file or None,
check_symbol_references=check_symbol_references,
min_severity=min_severity,
)
grouped_diagnostics = GroupedDiagnostics()
for symbol, diagnostics in diagnostics_by_symbol.items():
relative_path = symbol.relative_path
if relative_path is None:
continue
symbol_name_path = symbol.get_name_path()
for diagnostic in diagnostics:
grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic)
result = self._to_json(grouped_diagnostics.get_dict())
return self._limit_length(result, max_answer_chars)
class ReplaceSymbolBodyTool(EditingToolWithDiagnostics):
"""
Replaces the full definition of a symbol using the language server backend.
"""
@@ -348,16 +587,17 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
in the programming language, including e.g. the signature line for functions.
IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular.
"""
code_editor = self.create_code_editor()
code_editor.replace_body(
name_path,
relative_file_path=relative_path,
body=body,
)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.replace_body(
name_path,
relative_file_path=relative_path,
body=body,
)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
class InsertAfterSymbolTool(EditingToolWithDiagnostics):
"""
Inserts content after the end of the definition of a given symbol.
"""
@@ -377,12 +617,13 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
:param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol.
"""
code_editor = self.create_code_editor()
code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit):
class InsertBeforeSymbolTool(EditingToolWithDiagnostics):
"""
Inserts content before the beginning of the definition of a given symbol.
"""
@@ -402,9 +643,10 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit):
:param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted before the line in which the referenced symbol is defined
"""
code_editor = self.create_code_editor()
code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
code_editor = self.create_code_editor()
code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
return diagnostics_context.format_result(SUCCESS_RESULT)
class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit):
+59 -2
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import cached_property
from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast
from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast
from mcp import Implementation
from mcp.server.fastmcp import Context
@@ -18,6 +18,7 @@ from serena.project import MemoriesManager, Project
from serena.prompt_factory import PromptFactory
from serena.util.class_decorators import singleton
from serena.util.inspection import iter_subclasses
from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot
from solidlsp.ls_exceptions import SolidLSPException
if TYPE_CHECKING:
@@ -319,7 +320,7 @@ class Tool(Component):
if client_str != self.get_last_tool_call_client_str():
log.debug(f"Updating client info: {client_info}")
self.set_last_tool_call_client_str(client_str)
except BaseException as e:
except Exception as e:
log.info(f"Failed to get client info: {e}.")
def task() -> str:
@@ -403,6 +404,62 @@ class Tool(Component):
return json.dumps(x, ensure_ascii=False)
class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit):
"""
Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit.
"""
ENABLE_DIAGNOSTICS: bool = False
"""
Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class.
The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual
edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that
are then resolved in subsequent edits.
"""
DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]"
class DiagnosticsContext:
def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None:
self._tool = tool
self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server()
self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths]
self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None
self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None
if self._is_diagnostics_enabled:
self._symbol_retriever = tool.create_language_server_symbol_retriever()
self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever)
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore
pass
def format_result(
self,
base_result: str,
) -> str:
if not self._is_diagnostics_enabled:
return base_result
if self._before_edit_diagnostics_snapshot is None:
return base_result
assert self._symbol_retriever is not None
diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever)
grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict()
if not grouped_diagnostics:
return base_result
else:
result_dict = {
"result": base_result,
EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics,
}
return self._tool._to_json(result_dict)
class EditedFileContext:
"""
Context manager for file editing.
+3
View File
@@ -173,6 +173,9 @@ class GitignoreParser:
except PermissionError as ex:
log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex)
continue
except FileNotFoundError as ex:
log.debug(f"Skipping entry due to file not found error (possibly a broken link): {entry.path}", exc_info=ex)
continue
while queue:
next_abs_path = queue.pop(0)
+203
View File
@@ -0,0 +1,203 @@
import json
from collections.abc import Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from solidlsp import ls_types
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
if TYPE_CHECKING:
from serena.symbol import LanguageServerSymbolRetriever
@dataclass(frozen=True)
class EditedFilePath:
before_relative_path: str
after_relative_path: str
@dataclass(frozen=True)
class DiagnosticIdentity:
message: str
start_line: int
start_character: int
end_line: int
end_character: int
severity: int | None
code_repr: str | None
source: str | None
@classmethod
def from_diagnostic(cls, diagnostic: ls_types.Diagnostic) -> "DiagnosticIdentity":
diagnostic_range = diagnostic["range"]
start = diagnostic_range["start"]
end = diagnostic_range["end"]
return DiagnosticIdentity(
message=diagnostic["message"],
start_line=start["line"],
start_character=start["character"],
end_line=end["line"],
end_character=end["character"],
severity=diagnostic.get("severity"),
code_repr=cls._diagnostic_code_repr(diagnostic.get("code")),
source=diagnostic.get("source"),
)
@staticmethod
def _diagnostic_code_repr(code: Any) -> str | None:
if code is None:
return None
try:
return json.dumps(code, sort_keys=True, ensure_ascii=False)
except TypeError:
return repr(code)
class PublishedDiagnosticsSnapshot:
def __init__(self, edited_file_paths: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever"):
generation_by_after_path: dict[str, int] = {}
warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {}
for edited_file_path in edited_file_paths:
try:
language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path)
except Exception:
continue
generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation(
edited_file_path.after_relative_path
)
cached_diagnostics = language_server.get_cached_published_text_document_diagnostics(
edited_file_path.before_relative_path,
min_severity=2,
)
if cached_diagnostics is None:
try:
cached_diagnostics = language_server.request_text_document_diagnostics(
edited_file_path.before_relative_path,
min_severity=2,
)
except Exception:
cached_diagnostics = []
warning_identities_by_before_path[edited_file_path.before_relative_path] = {
DiagnosticIdentity.from_diagnostic(diagnostic) for diagnostic in cached_diagnostics or []
}
self.generation_by_after_path = generation_by_after_path
self.warning_identities_by_before_path = warning_identities_by_before_path
class GroupedDiagnostics:
def __init__(self) -> None:
self._grouped_diagnostics: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
def add(self, relative_path: str, name_path: str, diagnostic: ls_types.Diagnostic) -> None:
severity_name = self._diagnostic_severity_name(diagnostic.get("severity"))
self._grouped_diagnostics.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
self._diagnostic_output_dict(diagnostic)
)
def get_dict(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]:
"""
Returns a nested dictionary of the form:
{
relative_file_path: {
severity_name: {
name_path: [
diagnostic_dict,
...
],
...
},
...
},
...
}
where:
- relative_file_path is the relative path of the file containing the diagnostic
- severity_name is the name of the diagnostic severity (e.g. "Warning", "Error")
- name_path is the name path of the symbol that owns the diagnostic (or "<file>" if no owner symbol was found)
- diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source
"""
return self._grouped_diagnostics
@staticmethod
def _diagnostic_severity_name(severity: int | None) -> str:
if severity is None:
return "Unknown"
try:
return DiagnosticSeverity(severity).name
except ValueError:
return f"Severity_{severity}"
@staticmethod
def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]:
result: dict[str, Any] = {
"message": diagnostic["message"],
"range": diagnostic["range"],
}
if "code" in diagnostic:
result["code"] = diagnostic["code"]
if "source" in diagnostic:
result["source"] = diagnostic["source"]
return result
class DiagnosticsDiff:
def __init__(
self,
before_snapshot: PublishedDiagnosticsSnapshot,
edited_files: Iterable[EditedFilePath],
symbol_retriever: "LanguageServerSymbolRetriever",
):
grouped_diagnostics = GroupedDiagnostics()
for edited_file_path in edited_files:
try:
language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path)
except:
continue
published_diagnostics = language_server.request_published_text_document_diagnostics(
relative_file_path=edited_file_path.after_relative_path,
after_generation=before_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1),
timeout=2.5,
min_severity=2,
allow_cached=True,
)
if not published_diagnostics:
try:
published_diagnostics = language_server.request_text_document_diagnostics(
edited_file_path.after_relative_path,
min_severity=2,
)
except:
published_diagnostics = None
if published_diagnostics is None:
continue
existing_warning_identities = before_snapshot.warning_identities_by_before_path.get(
edited_file_path.before_relative_path, set()
)
new_warning_identities: set[DiagnosticIdentity] = set()
for diagnostic in published_diagnostics:
diagnostic_identity = DiagnosticIdentity.from_diagnostic(diagnostic)
if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities:
continue
new_warning_identities.add(diagnostic_identity)
diagnostic_start = diagnostic["range"]["start"]
owner_symbol = symbol_retriever.find_diagnostic_owner_symbol(
relative_file_path=edited_file_path.after_relative_path,
line=diagnostic_start["line"],
column=diagnostic_start["character"],
)
name_path = owner_symbol.get_name_path() if owner_symbol is not None else "<file>"
grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic)
self._grouped_diagnostics = grouped_diagnostics
def get_grouped_diagnostics(self) -> GroupedDiagnostics:
return self._grouped_diagnostics
@@ -28,11 +28,28 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
AL_EXTENSION_VERSION = "18.0.2242655"
AL_EXTENSION_URL = (
f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_EXTENSION_VERSION}/vspackage"
)
AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_AL_EXTENSION_VERSION = "18.0.2242655"
INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"
DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655"
DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"
def _al_extension_sha(version: str) -> str | None:
if version == INITIAL_AL_EXTENSION_VERSION:
return INITIAL_AL_EXTENSION_SHA256
if version == DEFAULT_AL_EXTENSION_VERSION:
return DEFAULT_AL_EXTENSION_SHA256
return None
def _al_extension_dirname(version: str) -> str:
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
return "al-extension" if version == INITIAL_AL_EXTENSION_VERSION else f"al-extension-{version}"
AL_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",)
@@ -131,21 +148,13 @@ class ALLanguageServer(SolidLanguageServer):
return path.replace("\\", "/")
@classmethod
def _download_al_extension(cls, url: str, target_dir: str) -> bool:
def _download_al_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool:
"""
Download and extract the AL extension from VS Code marketplace.
The VS Code marketplace packages extensions as .vsix files (which are ZIP archives).
This method downloads the VSIX file and extracts it to get the language server binaries.
Args:
logger: Logger for tracking download progress
url: VS Code marketplace URL for the AL extension
target_dir: Directory where the extension will be extracted
Returns:
True if successful, False otherwise
Note:
The download includes progress tracking and proper user-agent headers
to ensure compatibility with the VS Code marketplace.
@@ -158,7 +167,7 @@ class ALLanguageServer(SolidLanguageServer):
url,
target_dir,
"zip",
expected_sha256=AL_EXTENSION_SHA256 if url == AL_EXTENSION_URL else None,
expected_sha256=expected_sha256,
allowed_hosts=AL_EXTENSION_ALLOWED_HOSTS,
)
log.info("AL extension extracted successfully")
@@ -231,8 +240,10 @@ class ALLanguageServer(SolidLanguageServer):
elif env_path:
log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}")
# Check default download location
default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension")
# Check the resolved-version download location (versioned for non-INITIAL, legacy "al-extension" for INITIAL)
al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL)
al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION)
default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version), "extension")
if os.path.exists(default_path):
log.debug(f"Found AL extension in default location: {default_path}")
return default_path
@@ -255,9 +266,9 @@ class ALLanguageServer(SolidLanguageServer):
Path to installed extension or None if download failed
"""
al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension")
al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL)
al_extension_version = al_settings.get("al_extension_version", AL_EXTENSION_VERSION)
al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION)
al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version))
al_extension_url = (
"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/"
f"vsextensions/al/{al_extension_version}/vspackage"
@@ -265,7 +276,7 @@ class ALLanguageServer(SolidLanguageServer):
log.info(f"Downloading AL extension from: {al_extension_url}")
if cls._download_al_extension(al_extension_url, al_extension_dir):
if cls._download_al_extension(al_extension_url, al_extension_dir, _al_extension_sha(al_extension_version)):
extension_path = os.path.join(al_extension_dir, "extension")
if os.path.exists(extension_path):
log.info("AL extension downloaded and installed successfully")
@@ -20,6 +20,12 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3"
DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3"
def _deep_merge(base: dict, override: dict) -> None:
"""Recursively merge *override* into *base*, modifying *base* in-place."""
@@ -162,7 +168,9 @@ class AnsibleLanguageServer(SolidLanguageServer):
assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js 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."
ansible_language_server_version = self._custom_settings.get("ansible_language_server_version", "1.2.3")
ansible_language_server_version = self._custom_settings.get(
"ansible_language_server_version", DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION
)
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -180,8 +188,13 @@ class AnsibleLanguageServer(SolidLanguageServer):
]
)
# install ansible-language-server if not already installed
ansible_ls_dir = os.path.join(self._ls_resources_dir, "ansible-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = (
"ansible-lsp"
if ansible_language_server_version == INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION
else f"ansible-lsp-{ansible_language_server_version}"
)
ansible_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server")
# handle Windows executable extension
@@ -18,12 +18,72 @@ from solidlsp.ls import (
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# ShellCheck binary release info; download directly from upstream GitHub releases rather than
# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and
# turned the bash LS install into a fragile, network-dependent step).
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_BASH_LANGUAGE_SERVER_VERSION = "5.6.0"
DEFAULT_BASH_LANGUAGE_SERVER_VERSION = "5.6.0"
# ShellCheck binary path already encodes _SHELLCHECK_VERSION (see _shellcheck_binary_path),
# so version bumps trigger reinstall correctly without further intervention.
_SHELLCHECK_VERSION = "0.10.0"
_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}"
_SHELLCHECK_ALLOWED_HOSTS = (
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
)
# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v<ver>/shellcheck),
# zip on Windows (extracts to shellcheck.exe at archive root).
_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = {
PlatformId.LINUX_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz",
"sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87",
},
PlatformId.LINUX_arm64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz",
"sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb",
},
PlatformId.OSX_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz",
"sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2",
},
PlatformId.OSX_arm64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz",
"sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81",
},
PlatformId.WIN_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip",
"sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143",
},
}
def _shellcheck_install_dir(bash_ls_dir: str) -> str:
return os.path.join(bash_ls_dir, "shellcheck")
def _shellcheck_binary_path(bash_ls_dir: str) -> str:
"""
Returns the path to the extracted ShellCheck binary. POSIX archives extract under
``shellcheck-v<ver>/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root.
"""
install_dir = _shellcheck_install_dir(bash_ls_dir)
if os.name == "nt":
return os.path.join(install_dir, "shellcheck.exe")
return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck")
class BashLanguageServer(SolidLanguageServer):
"""
Provides Bash specific instantiation of the LanguageServer class using bash-language-server.
@@ -53,37 +113,37 @@ class BashLanguageServer(SolidLanguageServer):
"""
Setup runtime dependencies for Bash Language Server and return the command to start the server.
"""
# Verify both node and npm are installed
# verify node + npm are available for the bash-language-server install
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."
bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0")
bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="bash-language-server",
description="bash-language-server package",
command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry),
platform_id="any",
),
]
)
# 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
bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version)
managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin")
bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server")
if os.name == "nt":
bash_executable_path += ".cmd"
# install bash-language-server via npm
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")
bls_deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="bash-language-server",
description="bash-language-server package",
command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry),
platform_id="any",
),
]
)
log.info("Installing bash-language-server...")
bls_deps.install(bash_ls_dir)
# install ShellCheck binary directly from upstream releases for the current platform
self._install_shellcheck_if_missing(bash_ls_dir)
if not os.path.exists(bash_executable_path):
raise FileNotFoundError(
@@ -92,6 +152,59 @@ class BashLanguageServer(SolidLanguageServer):
return bash_executable_path
@staticmethod
def _install_shellcheck_if_missing(bash_ls_dir: str) -> None:
"""
Downloads and extracts the platform-appropriate ShellCheck release into
``${bash_ls_dir}/shellcheck`` if the binary is not already present.
"""
binary_path = _shellcheck_binary_path(bash_ls_dir)
if os.path.exists(binary_path):
return
install_dir = _shellcheck_install_dir(bash_ls_dir)
os.makedirs(install_dir, exist_ok=True)
release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id())
if release is None:
raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}")
archive_type = "zip" if os.name == "nt" else "xztar"
log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}")
FileUtils.download_and_extract_archive_verified(
release["url"],
install_dir,
archive_type,
expected_sha256=release["sha256"],
allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS,
)
if not os.path.exists(binary_path):
raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.")
# ensure the binary is executable on POSIX (zip extraction does not preserve perms)
if os.name != "nt":
current = os.stat(binary_path).st_mode
os.chmod(binary_path, current | 0o111)
def create_launch_command_env(self) -> dict[str, str]:
bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION)
bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version)
managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin")
return {
"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""),
"SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir),
}
def _resolve_bash_ls_dir(self, bash_language_server_version: str) -> str:
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = (
"bash-lsp"
if bash_language_server_version == INITIAL_BASH_LANGUAGE_SERVER_VERSION
else f"bash-lsp-{bash_language_server_version}"
)
return os.path.join(self._ls_resources_dir, ls_dirname)
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "start"]
@@ -34,6 +34,39 @@ class ClangdLanguageServer(SolidLanguageServer):
(default: the bundled Serena version).
"""
@staticmethod
def _determine_log_level(line: str) -> int:
"""
Classify a clangd stderr line using clangd's explicit level prefix.
See `clang::clangd::Logger::indicator` for details:
https://clang.llvm.org/extra/doxygen/classclang_1_1clangd_1_1Logger.html
Clangd emits each log record prefixed by a single indicator character
followed by a timestamp in square brackets, e.g. ``I[12:27:16.234]``.
The indicators are ``D`` (Debug), ``I`` (Info), ``E`` (Error) and
``F`` (Fatal). Continuation lines of multi-line records carry no
prefix and are treated as informational.
Without this override, the base implementation scans the line for
the substrings ``error`` and ``exception``, which produces false
positives on clangd's reconstructed compile commands in some cases
(e.g. ``-DNO_EXCEPTIONS``, ``-fno-exceptions``).
"""
# classify by clangd's level indicator character
if len(line) >= 2 and line[1] == "[":
indicator = line[0]
if indicator in ("E", "F"):
return logging.ERROR
if indicator == "I":
return logging.INFO
if indicator == "D":
return logging.DEBUG
# continuation line or non-prefixed output: default to INFO, do not keyword-scan
return logging.INFO
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
+44 -12
View File
@@ -21,7 +21,35 @@ from .common import RuntimeDependency, RuntimeDependencyCollection
log = logging.getLogger(__name__)
CLOJURE_LSP_VERSION = "2026.02.20-16.08.58"
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58"
INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM = {
"osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669",
"osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2",
"linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1",
"linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309",
"win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f",
}
DEFAULT_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58"
DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM = {
"osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669",
"osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2",
"linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1",
"linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309",
"win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f",
}
def _clojure_lsp_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_CLOJURE_LSP_VERSION:
return INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key]
if version == DEFAULT_CLOJURE_LSP_VERSION:
return DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key]
return None
CLOJURE_LSP_ALLOWED_HOSTS = (
"github.com",
"release-assets.githubusercontent.com",
@@ -58,7 +86,6 @@ class ClojureLSP(SolidLanguageServer):
by Serena (default: the bundled Serena version).
"""
CLOJURE_LSP_VERSION = CLOJURE_LSP_VERSION
CLOJURE_LSP_ALLOWED_HOSTS = CLOJURE_LSP_ALLOWED_HOSTS
@override
@@ -69,7 +96,6 @@ class ClojureLSP(SolidLanguageServer):
@classmethod
def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection:
clojure_lsp_releases = f"https://github.com/clojure-lsp/clojure-lsp/releases/download/{version}"
default_version = version == cls.CLOJURE_LSP_VERSION
return RuntimeDependencyCollection(
[
RuntimeDependency(
@@ -78,7 +104,7 @@ class ClojureLSP(SolidLanguageServer):
platform_id="osx-arm64",
archive_type="zip",
binary_name="clojure-lsp",
sha256="a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669" if default_version else None,
sha256=_clojure_lsp_sha(version, "osx-arm64"),
allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -87,7 +113,7 @@ class ClojureLSP(SolidLanguageServer):
platform_id="osx-x64",
archive_type="zip",
binary_name="clojure-lsp",
sha256="5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2" if default_version else None,
sha256=_clojure_lsp_sha(version, "osx-x64"),
allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -96,7 +122,7 @@ class ClojureLSP(SolidLanguageServer):
platform_id="linux-arm64",
archive_type="zip",
binary_name="clojure-lsp",
sha256="f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1" if default_version else None,
sha256=_clojure_lsp_sha(version, "linux-arm64"),
allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -105,7 +131,7 @@ class ClojureLSP(SolidLanguageServer):
platform_id="linux-x64",
archive_type="zip",
binary_name="clojure-lsp",
sha256="52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309" if default_version else None,
sha256=_clojure_lsp_sha(version, "linux-x64"),
allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -114,7 +140,7 @@ class ClojureLSP(SolidLanguageServer):
platform_id="win-x64",
archive_type="zip",
binary_name="clojure-lsp.exe",
sha256="817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f" if default_version else None,
sha256=_clojure_lsp_sha(version, "win-x64"),
allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS,
),
]
@@ -143,16 +169,22 @@ class ClojureLSP(SolidLanguageServer):
def _get_or_install_core_dependency(self) -> str:
"""Setup runtime dependencies for clojure-lsp and return the path to the executable."""
verify_clojure_cli()
clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", ClojureLSP.CLOJURE_LSP_VERSION)
clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", DEFAULT_CLOJURE_LSP_VERSION)
deps = ClojureLSP._runtime_dependencies(clojure_lsp_version)
dependency = deps.get_single_dep_for_current_platform()
clojurelsp_executable_path = deps.binary_path(self._ls_resources_dir)
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
install_dir = (
self._ls_resources_dir
if clojure_lsp_version == INITIAL_CLOJURE_LSP_VERSION
else os.path.join(self._ls_resources_dir, f"clojure-lsp-{clojure_lsp_version}")
)
clojurelsp_executable_path = deps.binary_path(install_dir)
if not os.path.exists(clojurelsp_executable_path):
log.info(
f"Downloading and extracting clojure-lsp from {dependency.url} to {self._ls_resources_dir}",
f"Downloading and extracting clojure-lsp from {dependency.url} to {install_dir}",
)
deps.install(self._ls_resources_dir)
deps.install(install_dir)
if not os.path.exists(clojurelsp_executable_path):
raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}")
os.chmod(clojurelsp_executable_path, 0o755)
@@ -206,6 +206,10 @@ class CSharpLanguageServer(SolidLanguageServer):
scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher.
"""
@classmethod
def supports_implementation_request(cls) -> bool:
return True
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly.
@@ -18,6 +18,34 @@ log = logging.getLogger(__name__)
DART_ALLOWED_HOSTS = ("storage.googleapis.com",)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_DART_SDK_VERSION = "3.7.1"
INITIAL_DART_SDK_SHA256_BY_PLATFORM = {
"linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71",
"win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5",
"win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a",
"osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e",
"osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4",
}
DEFAULT_DART_SDK_VERSION = "3.7.1"
DEFAULT_DART_SDK_SHA256_BY_PLATFORM = {
"linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71",
"win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5",
"win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a",
"osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e",
"osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4",
}
def _dart_sdk_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_DART_SDK_VERSION:
return INITIAL_DART_SDK_SHA256_BY_PLATFORM[platform_key]
if version == DEFAULT_DART_SDK_VERSION:
return DEFAULT_DART_SDK_SHA256_BY_PLATFORM[platform_key]
return None
class DartLanguageServer(SolidLanguageServer):
"""
@@ -49,7 +77,7 @@ class DartLanguageServer(SolidLanguageServer):
@classmethod
def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str:
dart_settings = solidlsp_settings.get_ls_specific_settings(Language.DART)
dart_sdk_version = dart_settings.get("dart_sdk_version", "3.7.1")
dart_sdk_version = dart_settings.get("dart_sdk_version", DEFAULT_DART_SDK_VERSION)
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
@@ -59,7 +87,7 @@ class DartLanguageServer(SolidLanguageServer):
platform_id="linux-x64",
archive_type="zip",
binary_name="dart-sdk/bin/dart",
sha256="2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71" if dart_sdk_version == "3.7.1" else None,
sha256=_dart_sdk_sha(dart_sdk_version, "linux-x64"),
allowed_hosts=DART_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -69,7 +97,7 @@ class DartLanguageServer(SolidLanguageServer):
platform_id="win-x64",
archive_type="zip",
binary_name="dart-sdk/bin/dart.exe",
sha256="f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5" if dart_sdk_version == "3.7.1" else None,
sha256=_dart_sdk_sha(dart_sdk_version, "win-x64"),
allowed_hosts=DART_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -79,7 +107,7 @@ class DartLanguageServer(SolidLanguageServer):
platform_id="win-arm64",
archive_type="zip",
binary_name="dart-sdk/bin/dart.exe",
sha256="fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a" if dart_sdk_version == "3.7.1" else None,
sha256=_dart_sdk_sha(dart_sdk_version, "win-arm64"),
allowed_hosts=DART_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -89,7 +117,7 @@ class DartLanguageServer(SolidLanguageServer):
platform_id="osx-x64",
archive_type="zip",
binary_name="dart-sdk/bin/dart",
sha256="a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e" if dart_sdk_version == "3.7.1" else None,
sha256=_dart_sdk_sha(dart_sdk_version, "osx-x64"),
allowed_hosts=DART_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -99,13 +127,18 @@ class DartLanguageServer(SolidLanguageServer):
platform_id="osx-arm64",
archive_type="zip",
binary_name="dart-sdk/bin/dart",
sha256="f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4" if dart_sdk_version == "3.7.1" else None,
sha256=_dart_sdk_sha(dart_sdk_version, "osx-arm64"),
allowed_hosts=DART_ALLOWED_HOSTS,
),
]
)
dart_ls_dir = cls.ls_resources_dir(solidlsp_settings)
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
dart_ls_dir = (
cls.ls_resources_dir(solidlsp_settings)
if dart_sdk_version == INITIAL_DART_SDK_VERSION
else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"dart-sdk-{dart_sdk_version}")
)
dart_executable_path = deps.binary_path(dart_ls_dir)
if not os.path.exists(dart_executable_path):
+448 -48
View File
@@ -7,9 +7,12 @@ import hashlib
import logging
import os
import pathlib
import platform
import re
import shutil
import subprocess
import threading
from pathlib import PurePath
from pathlib import Path, PurePath
from time import sleep
from typing import cast
@@ -18,6 +21,7 @@ from overrides import override
from solidlsp import ls_types
from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import UnifiedSymbolInformation
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation
@@ -28,42 +32,121 @@ log = logging.getLogger(__name__)
GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
GRADLE_SHA256 = "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999"
VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
VSCODE_JAVA_SHA256_BY_PLATFORM = {
INTELLICODE_ALLOWED_HOSTS = (
"visualstudioexptteam.gallery.vsassets.io",
"marketplace.visualstudio.com",
"download.visualstudio.microsoft.com",
)
# Version pinning convention (read this before bumping anything below):
#
# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with
# its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/")
# is reserved exclusively for this version, so users who installed under the original
# layout keep their existing cache forever.
#
# DEFAULT_* — the version (and matching SHA) used when the user does not override via
# custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over
# the existing DEFAULT_* literals, leave INITIAL_* alone.
#
# Resolution at install time:
# resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION)
# - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256
# - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256
# - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped
#
# Consequences of this scheme:
# - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse
# of stale binaries (the bug this scheme exists to fix).
# - Previously-downloaded versions are not lost: a user can request any past version via
# custom_settings and the matching cached subdir is picked up without re-downloading.
# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on
# the first DEFAULT_* bump and stay independent thereafter.
INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561"
INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
"osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f",
"linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1",
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
}
INTELLICODE_ALLOWED_HOSTS = (
"visualstudioexptteam.gallery.vsassets.io",
"marketplace.visualstudio.com",
"download.visualstudio.microsoft.com",
)
INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561"
DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
"osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f",
"linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1",
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
}
INITIAL_INTELLICODE_VERSION = "1.2.30"
INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
DEFAULT_INTELLICODE_VERSION = "1.2.30"
DEFAULT_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
# Mapping from Serena's platform identifiers to upstream JDTLS config_<platform> directory names
JDTLS_CONFIG_DIR_BY_PLATFORM = {
"osx-arm64": "config_mac_arm",
"darwin-arm64": "config_mac_arm",
"osx-x64": "config_mac",
"linux-arm64": "config_linux_arm",
"linux-x64": "config_linux",
"win-x64": "config_win",
}
# Minimum supported JDK version for running JDTLS itself
JDTLS_MIN_JDK_VERSION = 21
@dataclasses.dataclass
class RuntimeDependencyPaths:
"""
Stores the paths to the runtime dependencies of EclipseJDTLS
Stores the paths to the runtime dependencies of EclipseJDTLS.
In the default mode (vscode-java VSIX), all paths are populated.
In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set),
fields that have no upstream equivalent (gradle distribution, IntelliCode bundle)
are set to None.
"""
gradle_path: str
lombok_jar_path: str
jre_path: str
jre_home_path: str
jdtls_launcher_jar_path: str
jdtls_readonly_config_path: str
intellicode_jar_path: str
intellisense_members_path: str
lombok_jar_path: str
gradle_path: str | None = None
intellicode_jar_path: str | None = None
intellisense_members_path: str | None = None
class EclipseJDTLS(SolidLanguageServer):
r"""
The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class
Two installation modes are supported:
1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific
vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution
and IntelliCode VSIX from public hosts. Suitable when public network access is available.
2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an
existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network
environments. Requires JDK 21+ available via ``java_home`` setting / ``JAVA_HOME`` env / PATH.
Maven projects work out of the box (m2e bundled in JDTLS uses Maven Embedder); Gradle projects
need ``./gradlew`` in the project or a system-installed Gradle (Buildship default discovery).
You can configure the following options in ls_specific_settings (in serena_config.yml):
Upstream JDTLS mode (mutually exclusive group — set both to activate):
- jdtls_path: Path to upstream JDTLS root (containing plugins/ and config_<platform>/).
Get via 'brew install jdtls' or extract jdt-language-server-*.tar.gz from
https://download.eclipse.org/jdtls/snapshots/.
- lombok_path: Path to lombok jar (e.g. ~/.m2/repository/org/projectlombok/lombok/<ver>/lombok-<ver>.jar
or download from https://projectlombok.org/downloads/).
Optional in upstream-jdtls mode:
- java_home: Path to JDK 21+ home directory. Falls back to JAVA_HOME env, then 'which java'.
General settings (apply in both modes):
- maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml)
- gradle_user_home: Path to Gradle user home directory (default: ~/.gradle)
- gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false)
@@ -77,7 +160,16 @@ class EclipseJDTLS(SolidLanguageServer):
- vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena
- intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena
Example configuration in ~/.serena/serena_config.yml:
Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate):
```yaml
ls_specific_settings:
java:
jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" # or extracted tar.gz path
lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar"
# java_home: "/opt/homebrew/opt/openjdk@21" # optional, JAVA_HOME env / PATH used otherwise
```
Example configuration for default vscode-java VSIX mode (auto-download):
```yaml
ls_specific_settings:
java:
@@ -98,6 +190,10 @@ class EclipseJDTLS(SolidLanguageServer):
```
"""
@classmethod
def supports_implementation_request(cls) -> bool:
return True
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a new EclipseJDTLS instance initializing the language server settings appropriately.
@@ -154,15 +250,60 @@ class EclipseJDTLS(SolidLanguageServer):
) -> RuntimeDependencyPaths:
"""
Setup runtime dependencies for EclipseJDTLS and return the paths.
Two modes are supported:
* **Upstream JDTLS mode** (activated when both ``jdtls_path`` and ``lombok_path`` are set
in ``ls_specific_settings.java``): uses an existing JDTLS installation (e.g. via
``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK.
Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments.
* **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific
vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle
distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged.
"""
jdtls_path = custom_settings.get("jdtls_path")
lombok_path = custom_settings.get("lombok_path")
if jdtls_path or lombok_path:
# both must be set together to activate upstream mode
if not (jdtls_path and lombok_path):
raise SolidLSPException(
"Both 'jdtls_path' and 'lombok_path' must be set together in "
"ls_specific_settings.java to use the upstream JDTLS mode. "
"Set both, or remove both to use the default vscode-java VSIX mode."
)
return EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_path), str(lombok_path), custom_settings)
platformId = PlatformUtils.get_platform_id()
gradle_version = custom_settings.get("gradle_version", "8.14.2")
vscode_java_version = custom_settings.get("vscode_java_version", "1.42.0-561")
vscode_java_version = custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION)
vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}"
intellicode_version = custom_settings.get("intellicode_version", "1.2.30")
intellicode_version = custom_settings.get("intellicode_version", DEFAULT_INTELLICODE_VERSION)
default_gradle_version = gradle_version == "8.14.2"
default_vscode_java_version = vscode_java_version == "1.42.0-561"
default_intellicode_version = intellicode_version == "1.2.30"
# install-dir name per the version-pinning convention (see module-level block):
# INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir
vscode_java_dirname = (
"vscode-java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}"
)
intellicode_dirname = (
"intellicode" if intellicode_version == INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}"
)
# SHA is only known for our two pinned versions (INITIAL and current DEFAULT);
# for any other user-supplied version we skip verification (returns None)
def vscode_java_sha(platform_key: str) -> str | None:
if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION:
return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key]
if vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION:
return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key]
return None
def intellicode_sha() -> str | None:
if intellicode_version == INITIAL_INTELLICODE_VERSION:
return INITIAL_INTELLICODE_SHA256
if intellicode_version == DEFAULT_INTELLICODE_VERSION:
return DEFAULT_INTELLICODE_SHA256
return None
runtime_dependencies: dict[str, dict[str, dict[str, object]]] = {
"gradle": {
@@ -178,15 +319,15 @@ class EclipseJDTLS(SolidLanguageServer):
"darwin-arm64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("osx-arm64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
},
"osx-arm64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("osx-arm64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-macosx-aarch64",
"jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java",
@@ -197,8 +338,8 @@ class EclipseJDTLS(SolidLanguageServer):
"osx-x64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("osx-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-macosx-x86_64",
"jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java",
@@ -209,8 +350,8 @@ class EclipseJDTLS(SolidLanguageServer):
"linux-arm64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("linux-arm64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-linux-aarch64",
"jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java",
@@ -221,8 +362,8 @@ class EclipseJDTLS(SolidLanguageServer):
"linux-x64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("linux-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-linux-x86_64",
"jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java",
@@ -233,8 +374,8 @@ class EclipseJDTLS(SolidLanguageServer):
"win-x64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if default_vscode_java_version else None,
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("win-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-win32-x86_64",
"jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe",
@@ -248,8 +389,8 @@ class EclipseJDTLS(SolidLanguageServer):
"url": f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage",
"alternate_url": f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/{intellicode_version}/vspackage",
"archiveType": "zip",
"relative_extraction_path": "intellicode",
"sha256": INTELLICODE_SHA256 if default_intellicode_version else None,
"relative_extraction_path": intellicode_dirname,
"sha256": intellicode_sha(),
"allowed_hosts": INTELLICODE_ALLOWED_HOSTS,
"intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar",
"intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members",
@@ -346,11 +487,255 @@ class EclipseJDTLS(SolidLanguageServer):
intellisense_members_path=intellisense_members_path,
)
@staticmethod
def _setup_from_existing_install(
jdtls_path: str, lombok_path: str, custom_settings: SolidLSPSettings.CustomLSSettings
) -> RuntimeDependencyPaths:
"""
Builds RuntimeDependencyPaths from an already-installed upstream JDTLS distribution
and the system-installed JDK. No downloads are performed.
:param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_<platform>/``)
:param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached)
:param custom_settings: language-server-specific settings from ls_specific_settings.java
:return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None
"""
# validate jdtls_path structure (root + plugins dir)
jdtls_root = Path(jdtls_path)
if not jdtls_root.is_dir():
raise SolidLSPException(
f"Provided jdtls_path '{jdtls_path}' is not an existing directory.\n"
f"Fix: extract jdt-language-server-*.tar.gz from "
f"https://download.eclipse.org/jdtls/snapshots/ or run 'brew install jdtls', "
f"then set ls_specific_settings.java.jdtls_path to the extracted directory."
)
plugins_dir = jdtls_root / "plugins"
if not plugins_dir.is_dir():
raise SolidLSPException(
f"Invalid jdtls_path '{jdtls_path}': 'plugins/' directory not found.\n"
f"Expected upstream JDTLS layout (plugins/, config_<platform>/, features/) at the root. "
f"If you pointed at a vscode-java extension, use '<extension>/server' instead."
)
# resolve the main equinox launcher jar (excluding platform-specific native fragments)
launcher_jar = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins_dir)
# resolve the platform-specific config directory under jdtls_root
config_dir = EclipseJDTLS.DependencyProvider._resolve_config_dir(jdtls_root)
# validate lombok jar exists
if not Path(lombok_path).is_file():
raise SolidLSPException(
f"Provided lombok_path '{lombok_path}' does not exist or is not a file.\n"
f"Fix: download lombok jar from https://projectlombok.org/downloads/ or use the one "
f"from your local Maven cache (~/.m2/repository/org/projectlombok/lombok/<version>/lombok-<version>.jar)."
)
# resolve system JDK (priority: java_home setting -> JAVA_HOME env -> which java),
# interrogate the JVM for its real java.home and validate version >= 21
jre_home_path, jre_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings)
log.info(
f"Using upstream JDTLS at '{jdtls_path}' with system JDK '{jre_home_path}'. "
f"Launcher: {launcher_jar.name}; config: {config_dir.name}; lombok: {lombok_path}"
)
return RuntimeDependencyPaths(
jre_path=jre_path,
jre_home_path=jre_home_path,
jdtls_launcher_jar_path=str(launcher_jar),
jdtls_readonly_config_path=str(config_dir),
lombok_jar_path=lombok_path,
gradle_path=None,
intellicode_jar_path=None,
intellisense_members_path=None,
)
@staticmethod
def _resolve_launcher_jar(plugins_dir: Path) -> Path:
"""
Locates the main Equinox launcher jar in JDTLS's ``plugins/`` directory, excluding
platform-specific native fragments like ``org.eclipse.equinox.launcher.cocoa.macosx.*``.
:return: path to the main launcher jar (e.g. ``org.eclipse.equinox.launcher_1.7.0.v....jar``)
"""
# main launcher matches: org.eclipse.equinox.launcher_<digit>...jar (single underscore,
# immediately followed by version digits — fragments have additional dotted segments).
pattern = re.compile(r"^org\.eclipse\.equinox\.launcher_\d.*\.jar$")
matches = sorted(p for p in plugins_dir.glob("org.eclipse.equinox.launcher_*.jar") if pattern.match(p.name))
if not matches:
raise SolidLSPException(
f"No main Equinox launcher jar found in '{plugins_dir}'. "
f"Expected file like 'org.eclipse.equinox.launcher_<version>.jar'. "
f"Verify the JDTLS extraction is complete and not corrupted."
)
# if multiple versions are present (rare), pick the highest by name
return matches[-1]
@staticmethod
def _resolve_config_dir(jdtls_root: Path) -> Path:
"""
Locates the platform-specific OSGi configuration directory inside the JDTLS root.
:return: path to ``config_<platform>/`` directory matching the current OS/arch
"""
platform_id = PlatformUtils.get_platform_id().value
config_dir_name = JDTLS_CONFIG_DIR_BY_PLATFORM.get(platform_id)
if config_dir_name is None:
raise SolidLSPException(
f"Unsupported platform '{platform_id}' for upstream JDTLS mode. "
f"Supported platforms: {sorted(set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()))}."
)
config_dir = jdtls_root / config_dir_name
if not config_dir.is_dir():
raise SolidLSPException(
f"Config directory '{config_dir}' not found. "
f"This JDTLS distribution does not support platform '{platform_id}'. "
f"Verify you downloaded the correct tar.gz for your OS/architecture."
)
return config_dir
@staticmethod
def _resolve_system_jdk(custom_settings: SolidLSPSettings.CustomLSSettings) -> tuple[str, str]:
"""
Resolves the system-installed JDK home and ``java`` executable, validates the version.
The ``java`` executable is located by priority: ``java_home`` setting ->
``JAVA_HOME`` env var -> ``java`` in PATH. The actual JDK home directory and
major version are then discovered by querying the JVM itself via
``java -XshowSettings:properties -version`` — this is the single source of truth
and works correctly even when the locator is a system stub (e.g. ``/usr/bin/java``
on macOS, which delegates to ``/usr/libexec/java_home`` under the hood and does not
resolve to the real JDK home via simple path traversal).
:return: (jdk_home_directory, java_executable_path)
"""
# locate a java executable to interrogate
java_exe_name = "java.exe" if platform.system() == "Windows" else "java"
java_exe: str | None = None
source: str
if explicit_home := custom_settings.get("java_home"):
candidate = str(Path(explicit_home) / "bin" / java_exe_name)
if not os.path.exists(candidate):
raise SolidLSPException(
f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. "
f"Set ls_specific_settings.java.java_home to a JDK home that contains bin/{java_exe_name}."
)
java_exe = candidate
source = f"java_home setting ({explicit_home})"
elif env_home := os.environ.get("JAVA_HOME"):
candidate = str(Path(env_home) / "bin" / java_exe_name)
if os.path.exists(candidate):
java_exe = candidate
source = f"JAVA_HOME env ({env_home})"
else:
log.warning(f"JAVA_HOME='{env_home}' invalid (no '{candidate}'), falling back to PATH.")
if java_exe is None:
java_in_path = shutil.which("java")
if java_in_path is None:
raise SolidLSPException(
"Could not locate a Java installation for JDTLS. "
"Set ls_specific_settings.java.java_home, set JAVA_HOME environment variable, "
f"or ensure 'java' is on PATH. Required: JDK {JDTLS_MIN_JDK_VERSION}+."
)
java_exe = java_in_path
source = f"PATH ({java_in_path})"
# interrogate the JVM for its real java.home and version (single source of truth)
real_jdk_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe)
# validate version
if major_version < JDTLS_MIN_JDK_VERSION:
raise SolidLSPException(
f"JDTLS requires JDK {JDTLS_MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} "
f"(located via {source}, java.home={real_jdk_home}). "
f"Install a newer JDK and update ls_specific_settings.java.java_home or JAVA_HOME."
)
log.info(f"Resolved JDK {major_version} via {source}; java.home={real_jdk_home}; java_exe={java_exe}.")
# prefer to use bin/java from the *real* JDK home (so JDTLS subprocesses that read JAVA_HOME
# find a consistent layout); only fall back to the original locator if the real-home variant
# is missing for some reason.
real_java = str(Path(real_jdk_home) / "bin" / java_exe_name)
if os.path.exists(real_java):
java_exe = real_java
return real_jdk_home, java_exe
@staticmethod
def _inspect_java(java_exe: str) -> tuple[str, int]:
"""
Runs ``java -XshowSettings:properties -version`` and parses ``java.home`` and the
major version from the output. This is the most reliable cross-platform way to
discover the JDK home (works around macOS ``/usr/bin/java`` stub issue).
:return: (java_home_directory_reported_by_jvm, major_version)
"""
try:
# both -XshowSettings:properties and -version write to stderr by convention
result = subprocess.run(
[java_exe, "-XshowSettings:properties", "-version"],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise SolidLSPException(f"Failed to run '{java_exe} -XshowSettings:properties -version': {exc}") from exc
output = (result.stderr or "") + "\n" + (result.stdout or "")
# parse java.home from " java.home = /path/to/jdk"
home_match = re.search(r"java\.home\s*=\s*(.+)", output)
if not home_match:
raise SolidLSPException(
f"Could not parse java.home from '{java_exe}' output. "
f"This usually means '{java_exe}' is not a valid JDK installation. "
f"First lines of output: {output.strip().splitlines()[:5]}"
)
real_home = home_match.group(1).strip()
# parse major version from version line: 'openjdk version "21.0.2"' or 'java version "21.0.2"'
version_match = re.search(r'version "(\d+)(?:\.\d+)*"', output)
if not version_match:
raise SolidLSPException(
f"Could not parse Java version from '{java_exe}' output. "
f"Required: JDK {JDTLS_MIN_JDK_VERSION}+. "
f"First lines of output: {output.strip().splitlines()[:5]}"
)
major = int(version_match.group(1))
return real_home, major
@staticmethod
def _compute_workspace_hash(
repository_root_path: str,
jdtls_launcher_jar_path: str,
custom_settings: SolidLSPSettings.CustomLSSettings,
) -> str:
"""
Compute the JDTLS workspace directory name.
Default mode hashes the project path only — preserves backwards compatibility with
workspaces created before upstream-JDTLS support. Upstream mode (when ``jdtls_path``
is set) mixes in the launcher path so switching between default and upstream
installations (or between different upstream JDTLS versions) lands in a separate
ws_dir and avoids stale OSGi configs blocking startup.
"""
if custom_settings.get("jdtls_path"):
ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path).encode()
else:
ws_hash_input = repository_root_path.encode()
return hashlib.md5(ws_hash_input).hexdigest()
def create_launch_command(self) -> list[str]:
# ws_dir is the workspace directory for the EclipseJDTLS server.
# Use a deterministic hash of the project path so the workspace
# (and its cached index) can be reused across restarts.
project_hash = hashlib.md5(self._repository_root_path.encode()).hexdigest()
project_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(
self._repository_root_path,
self.runtime_dependency_paths.jdtls_launcher_jar_path,
self._custom_settings,
)
ws_dir = str(
PurePath(
self._solidlsp_settings.ls_resources_dir,
@@ -820,8 +1205,15 @@ class EclipseJDTLS(SolidLanguageServer):
}
initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore
bundles = [self.runtime_dependency_paths.intellicode_jar_path]
initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore
# IntelliCode bundle: only attached in default vscode-java VSIX mode.
# In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows
# don't use completion ranking, so the bundle would be inert dead weight.
if self.runtime_dependency_paths.intellicode_jar_path is not None:
initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] # type: ignore
else:
initialize_params["initializationOptions"]["bundles"] = [] # type: ignore
initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore
{"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True}
]
@@ -832,7 +1224,10 @@ class EclipseJDTLS(SolidLanguageServer):
assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist"
gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore
gradle_settings["home"] = self.runtime_dependency_paths.gradle_path
# In upstream-jdtls mode we don't ship a Gradle distribution — Buildship will use the project's
# ./gradlew wrapper or a system-installed Gradle via its standard discovery rules.
if self.runtime_dependency_paths.gradle_path is not None:
gradle_settings["home"] = self.runtime_dependency_paths.gradle_path
gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path}
return cast(InitializeParams, initialize_params)
@@ -899,17 +1294,22 @@ class EclipseJDTLS(SolidLanguageServer):
self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore
self._intellicode_enable_command_available.wait()
# IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the
# IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the
# 'java.intellicode.enable' command will never be registered, so we skip the wait/call.
if self.runtime_dependency_paths.intellicode_jar_path is not None:
self._intellicode_enable_command_available.wait()
java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path
assert os.path.exists(java_intellisense_members_path)
intellicode_enable_result = self.server.send.execute_command(
{
"command": "java.intellicode.enable",
"arguments": [True, java_intellisense_members_path],
}
)
assert intellicode_enable_result
java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path
assert java_intellisense_members_path is not None
assert os.path.exists(java_intellisense_members_path)
intellicode_enable_result = self.server.send.execute_command(
{
"command": "java.intellicode.enable",
"arguments": [True, java_intellisense_members_path],
}
)
assert intellicode_enable_result
if not self._service_ready_event.is_set():
log.info("Waiting for service to be ready ...")
@@ -11,6 +11,7 @@ import threading
from overrides import override
from sensai.util.logging import LogTime
from solidlsp import ls_types
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
@@ -21,6 +22,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_ELM_LANGUAGE_SERVER_VERSION = "2.8.0"
DEFAULT_ELM_LANGUAGE_SERVER_VERSION = "2.8.0"
INITIAL_ELM_COMPILER_VERSION = "0.19.1-6"
DEFAULT_ELM_COMPILER_VERSION = "0.19.1-6"
class ElmLanguageServer(SolidLanguageServer):
"""
@@ -32,9 +41,10 @@ class ElmLanguageServer(SolidLanguageServer):
Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
managed_bin_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "elm-lsp", "node_modules", ".bin")
# Resolve ELM_HOME to absolute path if it's set to a relative path
env = {}
env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")}
elm_home = os.environ.get("ELM_HOME")
if elm_home:
if not os.path.isabs(elm_home):
@@ -65,9 +75,15 @@ class ElmLanguageServer(SolidLanguageServer):
"""
Setup runtime dependencies for Elm Language Server and return the command to start the server.
"""
# Check if elm-language-server is already installed globally
elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM)
elm_language_server_version = elm_config.get("elm_language_server_version", DEFAULT_ELM_LANGUAGE_SERVER_VERSION)
elm_compiler_version = elm_config.get("elm_compiler_version", DEFAULT_ELM_COMPILER_VERSION)
npm_registry = elm_config.get("npm_registry")
# Check if elm-language-server and elm are already installed globally
system_elm_ls = shutil.which("elm-language-server")
if system_elm_ls:
system_elm = shutil.which("elm")
if system_elm_ls and system_elm:
log.info(f"Found system-installed elm-language-server at {system_elm_ls}")
return [system_elm_ls, "--stdio"]
@@ -76,10 +92,6 @@ class ElmLanguageServer(SolidLanguageServer):
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."
elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM)
elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0")
npm_registry = elm_config.get("npm_registry")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
@@ -88,16 +100,30 @@ class ElmLanguageServer(SolidLanguageServer):
command=build_npm_install_command("@elm-tooling/elm-language-server", elm_language_server_version, npm_registry),
platform_id="any",
),
RuntimeDependency(
id="elm",
description="Elm compiler package",
command=build_npm_install_command("elm", elm_compiler_version, npm_registry),
platform_id="any",
),
]
)
# Install elm-language-server if not already installed
elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp")
# legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir
is_initial = (
elm_language_server_version == INITIAL_ELM_LANGUAGE_SERVER_VERSION and elm_compiler_version == INITIAL_ELM_COMPILER_VERSION
)
ls_dirname = "elm-lsp" if is_initial else f"elm-lsp-{elm_language_server_version}-{elm_compiler_version}"
elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname)
elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server")
if not os.path.exists(elm_ls_executable_path):
log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...")
with LogTime("Installation of Elm language server dependencies", logger=log):
deps.install(elm_ls_dir)
elif not system_elm:
log.info("Elm compiler not found on PATH. Installing a managed Elm compiler...")
with LogTime("Installation of Elm compiler dependency", logger=log):
deps.install(elm_ls_dir)
if not os.path.exists(elm_ls_executable_path):
raise FileNotFoundError(
@@ -128,6 +154,7 @@ class ElmLanguageServer(SolidLanguageServer):
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -203,3 +230,26 @@ class ElmLanguageServer(SolidLanguageServer):
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 1.0
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
with self.open_file(relative_file_path):
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=-1,
timeout=10.0,
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -11,7 +11,7 @@ import shutil
from overrides import override
from solidlsp import ls_types
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LSPConstants, 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
@@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer):
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -284,3 +285,34 @@ class FortranLanguageServer(SolidLanguageServer):
# Fortran Language Server is ready after initialization
log.info("Fortran Language Server initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
diagnostics_before_request = self._get_published_diagnostics_generation(uri)
with self.open_file(relative_file_path):
self.server.notify.did_save_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -22,7 +22,12 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
FSAUTOCOMPLETE_VERSION = "0.83.0"
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_FSAUTOCOMPLETE_VERSION = "0.83.0"
DEFAULT_FSAUTOCOMPLETE_VERSION = "0.83.0"
FSAUTOCOMPLETE_VERSION = DEFAULT_FSAUTOCOMPLETE_VERSION
class FSharpLanguageServer(SolidLanguageServer):
@@ -69,7 +74,7 @@ class FSharpLanguageServer(SolidLanguageServer):
Setup runtime dependencies for F# Language Server and return the command to start the server.
"""
fsharp_settings = solidlsp_settings.get_ls_specific_settings(Language.FSHARP)
fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", FSAUTOCOMPLETE_VERSION)
fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", DEFAULT_FSAUTOCOMPLETE_VERSION)
dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise()
RuntimeDependencyCollection(
@@ -84,7 +89,9 @@ class FSharpLanguageServer(SolidLanguageServer):
)
# Install FsAutoComplete if not already installed
fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = "fsharp-lsp" if fsautocomplete_version == INITIAL_FSAUTOCOMPLETE_VERSION else f"fsharp-lsp-{fsautocomplete_version}"
fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname)
fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete")
# Handle Windows executable extension
+4
View File
@@ -23,6 +23,10 @@ class Gopls(SolidLanguageServer):
Provides Go specific instantiation of the LanguageServer class using gopls.
"""
@classmethod
def supports_implementation_request(cls) -> bool:
return True
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For Go projects, we should ignore:
@@ -18,13 +18,34 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
VSCODE_JAVA_SHA256_BY_PLATFORM = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561"
INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1",
"osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f",
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
}
DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561"
DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1",
"osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f",
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
}
def _vscode_java_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_VSCODE_JAVA_VERSION:
return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key]
if version == DEFAULT_VSCODE_JAVA_VERSION:
return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key]
return None
@dataclasses.dataclass
@@ -93,7 +114,7 @@ class GroovyLanguageServer(SolidLanguageServer):
"""
platform_id = PlatformUtils.get_platform_id()
groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY)
vscode_java_version = groovy_settings.get("vscode_java_version", "1.42.0-561")
vscode_java_version = groovy_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION)
vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}"
# Verify platform support
@@ -128,35 +149,35 @@ class GroovyLanguageServer(SolidLanguageServer):
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-win32-x86_64",
"java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if vscode_java_version == "1.42.0-561" else None,
"sha256": _vscode_java_sha(vscode_java_version, "win-x64"),
},
"linux-x64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-linux-x86_64",
"java_path": "extension/jre/21.0.7-linux-x86_64/bin/java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if vscode_java_version == "1.42.0-561" else None,
"sha256": _vscode_java_sha(vscode_java_version, "linux-x64"),
},
"linux-arm64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-linux-aarch64",
"java_path": "extension/jre/21.0.7-linux-aarch64/bin/java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if vscode_java_version == "1.42.0-561" else None,
"sha256": _vscode_java_sha(vscode_java_version, "linux-arm64"),
},
"osx-x64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-macosx-x86_64",
"java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if vscode_java_version == "1.42.0-561" else None,
"sha256": _vscode_java_sha(vscode_java_version, "osx-x64"),
},
"osx-arm64": {
"url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix",
"archiveType": "zip",
"java_home_path": "extension/jre/21.0.7-macosx-aarch64",
"java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java",
"sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if vscode_java_version == "1.42.0-561" else None,
"sha256": _vscode_java_sha(vscode_java_version, "osx-arm64"),
},
},
}
@@ -174,7 +195,9 @@ class GroovyLanguageServer(SolidLanguageServer):
static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server")
os.makedirs(static_dir, exist_ok=True)
java_dir = os.path.join(static_dir, "java")
# legacy unversioned "java" dir reserved for INITIAL; every other version goes into a versioned subdir
java_dirname = "java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"java-{vscode_java_version}"
java_dir = os.path.join(static_dir, java_dirname)
os.makedirs(java_dir, exist_ok=True)
java_home_path = os.path.join(java_dir, java_home_relative_path)
@@ -27,6 +27,22 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_VSHAXE_VERSION = "2.34.2"
INITIAL_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2"
DEFAULT_VSHAXE_VERSION = "2.34.2"
DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2"
def _vshaxe_sha(version: str) -> str | None:
if version == INITIAL_VSHAXE_VERSION:
return INITIAL_VSHAXE_SHA256
if version == DEFAULT_VSHAXE_VERSION:
return DEFAULT_VSHAXE_SHA256
return None
class HaxeLanguageServer(SolidLanguageServer):
"""Haxe language server integration using vshaxe/haxe-language-server.
@@ -58,8 +74,6 @@ class HaxeLanguageServer(SolidLanguageServer):
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
# Downloaded from Open VSX (not the VS Code Marketplace) because Open VSX
# provides stable versioned URLs and SHA256 checksums for integrity verification.
_DEFAULT_VSHAXE_VERSION = "2.34.2"
_DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2"
@override
def _get_or_install_core_dependency(self) -> str:
@@ -77,7 +91,9 @@ class HaxeLanguageServer(SolidLanguageServer):
return vscode_server_path
# 3. Check resource dir / download from Open VSX
haxe_ls_dir = os.path.join(self._ls_resources_dir, "haxe-lsp")
version = self._custom_settings.get("version", DEFAULT_VSHAXE_VERSION)
ls_dirname = "haxe-lsp" if version == INITIAL_VSHAXE_VERSION else f"haxe-lsp-{version}"
haxe_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
server_js_path = os.path.join(haxe_ls_dir, "bin", "server.js")
if os.path.exists(server_js_path):
log.info(f"Found Haxe Language Server at {server_js_path}")
@@ -92,7 +108,6 @@ class HaxeLanguageServer(SolidLanguageServer):
" 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe"
)
version = self._custom_settings.get("version", self._DEFAULT_VSHAXE_VERSION)
downloaded_path = self._download_from_open_vsx(haxe_ls_dir, version)
if downloaded_path:
return downloaded_path
@@ -129,16 +144,17 @@ class HaxeLanguageServer(SolidLanguageServer):
vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix")
urllib.request.urlretrieve(download_url, vsix_path)
# Verify SHA256 checksum only for the default (pinned) version
if version == cls._DEFAULT_VSHAXE_VERSION:
# Verify SHA256 checksum only when the resolved version is one of our pinned ones (INITIAL or current DEFAULT)
expected_sha = _vshaxe_sha(version)
if expected_sha is not None:
sha256 = hashlib.sha256()
with open(vsix_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
if sha256.hexdigest().lower() != cls._DEFAULT_VSHAXE_SHA256:
if sha256.hexdigest().lower() != expected_sha:
os.remove(vsix_path)
raise RuntimeError(
f"SHA256 checksum mismatch for vshaxe VSIX. Expected {cls._DEFAULT_VSHAXE_SHA256}, "
f"SHA256 checksum mismatch for vshaxe VSIX. Expected {expected_sha}, "
f"got {sha256.hexdigest()}. The file may be corrupted or tampered with."
)
log.info("SHA256 checksum verified")
@@ -215,7 +231,14 @@ class HaxeLanguageServer(SolidLanguageServer):
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True},
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "didSave": True},
"completion": {"completionItem": {"snippetSupport": True}},
"definition": {},
"references": {},
@@ -25,15 +25,32 @@ from .common import RuntimeDependency, RuntimeDependencyCollection
log = logging.getLogger(__name__)
# GitHub release version to download when not installed locally
_DEFAULT_VERSION = "1.3.1"
_GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download"
_HLSL_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
_HLSL_SHA256_BY_ASSET = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# _INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# _DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
_INITIAL_VERSION = "1.3.1"
_INITIAL_HLSL_SHA256_BY_ASSET = {
"shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997",
"shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c",
"shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412",
}
_DEFAULT_VERSION = "1.3.1"
_DEFAULT_HLSL_SHA256_BY_ASSET = {
"shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997",
"shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c",
"shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412",
}
def _hlsl_sha(version: str, asset_name: str) -> str | None:
if version == _INITIAL_VERSION:
return _INITIAL_HLSL_SHA256_BY_ASSET.get(asset_name)
if version == _DEFAULT_VERSION:
return _DEFAULT_HLSL_SHA256_BY_ASSET.get(asset_name)
return None
class HlslLanguageServer(SolidLanguageServer):
@@ -78,9 +95,7 @@ class HlslLanguageServer(SolidLanguageServer):
platform_id="win-x64",
archive_type="zip",
binary_name="shader-language-server.exe",
sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-pc-windows-msvc.zip"]
if version == _DEFAULT_VERSION
else None,
sha256=_hlsl_sha(version, "shader-language-server-x86_64-pc-windows-msvc.zip"),
allowed_hosts=_HLSL_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -90,9 +105,7 @@ class HlslLanguageServer(SolidLanguageServer):
platform_id="linux-x64",
archive_type="zip",
binary_name="shader-language-server",
sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-unknown-linux-gnu.zip"]
if version == _DEFAULT_VERSION
else None,
sha256=_hlsl_sha(version, "shader-language-server-x86_64-unknown-linux-gnu.zip"),
allowed_hosts=_HLSL_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -102,9 +115,7 @@ class HlslLanguageServer(SolidLanguageServer):
platform_id="win-arm64",
archive_type="zip",
binary_name="shader-language-server.exe",
sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-aarch64-pc-windows-msvc.zip"]
if version == _DEFAULT_VERSION
else None,
sha256=_hlsl_sha(version, "shader-language-server-aarch64-pc-windows-msvc.zip"),
allowed_hosts=_HLSL_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -139,7 +150,9 @@ class HlslLanguageServer(SolidLanguageServer):
"See https://github.com/antaalt/shader-sense for more details."
)
install_dir = os.path.join(self._ls_resources_dir, "shader-language-server")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = "shader-language-server" if version == _INITIAL_VERSION else f"shader-language-server-{version}"
install_dir = os.path.join(self._ls_resources_dir, ls_dirname)
executable_path = deps.binary_path(install_dir)
if not os.path.exists(executable_path):
+10 -3
View File
@@ -21,6 +21,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_INTELEPHENSE_VERSION = "1.14.4"
DEFAULT_INTELEPHENSE_VERSION = "1.14.4"
class Intelephense(SolidLanguageServer):
"""
@@ -59,11 +65,12 @@ class Intelephense(SolidLanguageServer):
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."
intelephense_version = self._custom_settings.get("intelephense_version", "1.14.4")
intelephense_version = self._custom_settings.get("intelephense_version", DEFAULT_INTELEPHENSE_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
# Install intelephense if not already installed
intelephense_ls_dir = os.path.join(self._ls_resources_dir, "php-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = "php-lsp" if intelephense_version == INITIAL_INTELEPHENSE_VERSION else f"php-lsp-{intelephense_version}"
intelephense_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
os.makedirs(intelephense_ls_dir, exist_ok=True)
intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense")
if not os.path.exists(intelephense_executable_path):
@@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_JSON_LANGUAGE_SERVER_VERSION = "1.3.4"
DEFAULT_JSON_LANGUAGE_SERVER_VERSION = "1.3.4"
class JsonLanguageServer(SolidLanguageServer):
"""
@@ -54,7 +60,7 @@ class JsonLanguageServer(SolidLanguageServer):
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."
json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4")
json_language_server_version = self._custom_settings.get("json_language_server_version", DEFAULT_JSON_LANGUAGE_SERVER_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -68,7 +74,13 @@ class JsonLanguageServer(SolidLanguageServer):
]
)
json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = (
"json-lsp"
if json_language_server_version == INITIAL_JSON_LANGUAGE_SERVER_VERSION
else f"json-lsp-{json_language_server_version}"
)
json_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver")
if os.name == "nt":
+47 -2
View File
@@ -10,7 +10,7 @@ from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag, InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
@@ -115,6 +115,21 @@ class JuliaLanguageServer(SolidLanguageServer):
"""Define language-specific directories to ignore for Julia projects."""
return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"]
@override
def _supports_pull_diagnostics(self) -> bool:
# LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes
# the server process. Force the published-diagnostics path instead.
return False
@override
def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float:
# LanguageServer.jl needs significant warm-up after init before it emits the first
# publishDiagnostics: workspace/configuration round-trip + initial linter pass typically
# land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have
# the cache pre-populated and return immediately, so this ceiling only kicks in for cold
# runs (standalone test invocations, CI cold start).
return 30.0
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Julia Language Server.
@@ -125,8 +140,19 @@ class JuliaLanguageServer(SolidLanguageServer):
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"workspace": {"workspaceFolders": True},
# workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.*
# settings via workspace/configuration after initialize. Without it, the server
# skips that codepath and runlinter stays disabled, so no diagnostics ever arrive.
"workspace": {"workspaceFolders": True, "configuration": True},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "didSave": True},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {"dynamicRegistration": True},
@@ -150,6 +176,19 @@ class JuliaLanguageServer(SolidLanguageServer):
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def workspace_configuration(params: dict) -> list[Any]:
"""
Respond to LanguageServer.jl's workspace/configuration pull request.
The server requests a flat list of julia.* settings (in particular julia.lint.run,
position 11 in the response). Returning ``None`` for each entry causes the server to
apply its built-in defaults — including ``runlinter = true`` — which is what we want
for diagnostics to be produced.
"""
items = params.get("items", []) if isinstance(params, dict) else []
return [None for _ in items]
self.server.on_request("workspace/configuration", workspace_configuration)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
@@ -166,4 +205,10 @@ class JuliaLanguageServer(SolidLanguageServer):
assert "documentSymbolProvider" in init_response["capabilities"]
self.server.notify.initialized({})
# nudge the server to pull config: LanguageServer.jl only invokes request_julia_config
# from its workspace/didChangeConfiguration handler, so without this notification the
# server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics).
self.server.notify.workspace_did_change_configuration({"settings": {}})
log.info("Julia Language Server is initialized and ready.")
@@ -41,16 +41,36 @@ log = logging.getLogger(__name__)
# -Xmx2G: 2GB heap is sufficient for most projects; override via ls_specific_settings for large codebases
DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx2G"
# Default Kotlin Language Server version (can be overridden via ls_specific_settings)
DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0"
KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",)
KOTLIN_LSP_SHA256_BY_SUFFIX = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_KOTLIN_LSP_VERSION = "261.13587.0"
INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = {
"win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad",
"linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec",
"linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651",
"mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3",
"mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c",
}
DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0"
DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = {
"win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad",
"linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec",
"linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651",
"mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3",
"mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c",
}
def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None:
if version == INITIAL_KOTLIN_LSP_VERSION:
return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix)
if version == DEFAULT_KOTLIN_LSP_VERSION:
return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix)
return None
# Platform-specific Kotlin LSP download suffixes
PLATFORM_KOTLIN_SUFFIX = {
@@ -108,8 +128,14 @@ class KotlinLanguageServer(SolidLanguageServer):
kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value)
assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}"
# Setup paths for dependencies
static_dir = os.path.join(self._ls_resources_dir, "kotlin_language_server")
# Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only
kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION)
ls_dirname = (
"kotlin_language_server"
if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION
else f"kotlin_language_server-{kotlin_lsp_version}"
)
static_dir = os.path.join(self._ls_resources_dir, ls_dirname)
os.makedirs(static_dir, exist_ok=True)
# Setup Kotlin Language Server
@@ -117,11 +143,8 @@ class KotlinLanguageServer(SolidLanguageServer):
kotlin_script = os.path.join(static_dir, kotlin_script_name)
if not os.path.exists(kotlin_script):
kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION)
kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip"
expected_sha256 = None
if kotlin_lsp_version == DEFAULT_KOTLIN_LSP_VERSION:
expected_sha256 = KOTLIN_LSP_SHA256_BY_SUFFIX[kotlin_suffix]
expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix)
log.info("Downloading Kotlin Language Server...")
FileUtils.download_and_extract_archive_verified(
kotlin_url,
@@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer):
"documentationFormat": ["markdown", "plaintext"],
},
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
+35 -6
View File
@@ -26,15 +26,42 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
LUA_LS_VERSION = "3.15.0"
LUA_LS_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
LUA_LS_SHA256_BY_ASSET = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_LUA_LS_VERSION = "3.15.0"
INITIAL_LUA_LS_SHA256_BY_ASSET = {
"lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007",
"lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d",
"lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7",
"lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b",
"lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535",
}
DEFAULT_LUA_LS_VERSION = "3.15.0"
DEFAULT_LUA_LS_SHA256_BY_ASSET = {
"lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007",
"lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d",
"lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7",
"lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b",
"lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535",
}
def _lua_ls_sha(version: str, asset_name: str) -> str | None:
if version == INITIAL_LUA_LS_VERSION:
return INITIAL_LUA_LS_SHA256_BY_ASSET.get(asset_name)
if version == DEFAULT_LUA_LS_VERSION:
return DEFAULT_LUA_LS_SHA256_BY_ASSET.get(asset_name)
return None
def _lua_ls_install_dir(ls_resources_dir: str, version: str) -> Path:
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
if version == INITIAL_LUA_LS_VERSION:
return Path(ls_resources_dir) / "lua"
return Path(ls_resources_dir) / f"lua-{version}"
class LuaLanguageServer(SolidLanguageServer):
@@ -67,7 +94,9 @@ class LuaLanguageServer(SolidLanguageServer):
]
if solidlsp_settings is not None:
ls_resource_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua"
lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA)
lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION)
ls_resource_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version)
possible_paths.extend(
[
ls_resource_dir / "bin" / "lua-language-server",
@@ -93,7 +122,7 @@ class LuaLanguageServer(SolidLanguageServer):
def _download_lua_ls(solidlsp_settings: SolidLSPSettings) -> str:
"""Download and install lua-language-server if not present."""
lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA)
lua_ls_version = lua_settings.get("lua_language_server_version", LUA_LS_VERSION)
lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION)
system = platform.system()
machine = platform.machine().lower()
@@ -122,7 +151,7 @@ class LuaLanguageServer(SolidLanguageServer):
download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}"
install_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua"
install_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version)
install_dir.mkdir(parents=True, exist_ok=True)
log.info("Downloading lua-language-server from %s", download_url)
@@ -131,7 +160,7 @@ class LuaLanguageServer(SolidLanguageServer):
download_url,
str(install_dir),
archive_type,
expected_sha256=LUA_LS_SHA256_BY_ASSET.get(download_name) if lua_ls_version == LUA_LS_VERSION else None,
expected_sha256=_lua_ls_sha(lua_ls_version, download_name),
allowed_hosts=LUA_LS_ALLOWED_HOSTS,
)
+30 -6
View File
@@ -37,15 +37,34 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Pin to a known stable release
LUAU_LSP_VERSION = "1.63.0"
LUAU_LSP_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
LUAU_LSP_SHA256_BY_ASSET = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_LUAU_LSP_VERSION = "1.63.0"
INITIAL_LUAU_LSP_SHA256_BY_ASSET = {
"luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8",
"luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355",
"luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb",
"luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d",
}
DEFAULT_LUAU_LSP_VERSION = "1.63.0"
DEFAULT_LUAU_LSP_SHA256_BY_ASSET = {
"luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8",
"luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355",
"luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb",
"luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d",
}
def _luau_lsp_sha(version: str, asset_name: str) -> str | None:
if version == INITIAL_LUAU_LSP_VERSION:
return INITIAL_LUAU_LSP_SHA256_BY_ASSET.get(asset_name)
if version == DEFAULT_LUAU_LSP_VERSION:
return DEFAULT_LUAU_LSP_SHA256_BY_ASSET.get(asset_name)
return None
# Luau built-in docs CDN
LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json"
@@ -98,14 +117,19 @@ class LuauLanguageServer(SolidLanguageServer):
return cmd
def _download_luau_lsp(self) -> str:
install_dir = Path(self._ls_resources_dir)
luau_lsp_version = self._custom_settings.get("luau_lsp_version", DEFAULT_LUAU_LSP_VERSION)
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
install_dir = (
Path(self._ls_resources_dir)
if luau_lsp_version == INITIAL_LUAU_LSP_VERSION
else Path(self._ls_resources_dir) / f"luau-lsp-{luau_lsp_version}"
)
install_dir.mkdir(parents=True, exist_ok=True)
binary_path = self._find_existing_binary(install_dir)
if binary_path is not None:
return binary_path
luau_lsp_version = self._custom_settings.get("luau_lsp_version", LUAU_LSP_VERSION)
asset_name = self._get_luau_lsp_asset_name()
download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{luau_lsp_version}/{asset_name}"
@@ -114,7 +138,7 @@ class LuauLanguageServer(SolidLanguageServer):
download_url,
str(install_dir),
"zip",
expected_sha256=LUAU_LSP_SHA256_BY_ASSET.get(asset_name) if luau_lsp_version == LUAU_LSP_VERSION else None,
expected_sha256=_luau_lsp_sha(luau_lsp_version, asset_name),
allowed_hosts=LUAU_LSP_ALLOWED_HOSTS,
)
+40 -10
View File
@@ -28,6 +28,34 @@ log = logging.getLogger(__name__)
MARKSMAN_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_MARKSMAN_VERSION = "2024-12-18"
INITIAL_MARKSMAN_SHA256_BY_PLATFORM = {
"linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475",
"linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73",
"osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83",
"osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83",
"win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec",
}
DEFAULT_MARKSMAN_VERSION = "2024-12-18"
DEFAULT_MARKSMAN_SHA256_BY_PLATFORM = {
"linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475",
"linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73",
"osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83",
"osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83",
"win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec",
}
def _marksman_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_MARKSMAN_VERSION:
return INITIAL_MARKSMAN_SHA256_BY_PLATFORM[platform_key]
if version == DEFAULT_MARKSMAN_VERSION:
return DEFAULT_MARKSMAN_SHA256_BY_PLATFORM[platform_key]
return None
class Marksman(SolidLanguageServer):
"""
@@ -39,12 +67,9 @@ class Marksman(SolidLanguageServer):
"""
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
DEFAULT_MARKSMAN_VERSION = "2024-12-18"
@classmethod
def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection:
marksman_releases = f"https://github.com/artempyanykh/marksman/releases/download/{version}"
default_version = version == cls.DEFAULT_MARKSMAN_VERSION
return RuntimeDependencyCollection(
[
RuntimeDependency(
@@ -53,7 +78,7 @@ class Marksman(SolidLanguageServer):
platform_id="linux-x64",
archive_type="binary",
binary_name="marksman",
sha256="b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475" if default_version else None,
sha256=_marksman_sha(version, "linux-x64"),
allowed_hosts=MARKSMAN_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -62,7 +87,7 @@ class Marksman(SolidLanguageServer):
platform_id="linux-arm64",
archive_type="binary",
binary_name="marksman",
sha256="b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73" if default_version else None,
sha256=_marksman_sha(version, "linux-arm64"),
allowed_hosts=MARKSMAN_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -71,7 +96,7 @@ class Marksman(SolidLanguageServer):
platform_id="osx-x64",
archive_type="binary",
binary_name="marksman",
sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None,
sha256=_marksman_sha(version, "osx-x64"),
allowed_hosts=MARKSMAN_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -80,7 +105,7 @@ class Marksman(SolidLanguageServer):
platform_id="osx-arm64",
archive_type="binary",
binary_name="marksman",
sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None,
sha256=_marksman_sha(version, "osx-arm64"),
allowed_hosts=MARKSMAN_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -89,7 +114,7 @@ class Marksman(SolidLanguageServer):
platform_id="win-x64",
archive_type="binary",
binary_name="marksman.exe",
sha256="39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec" if default_version else None,
sha256=_marksman_sha(version, "win-x64"),
allowed_hosts=MARKSMAN_ALLOWED_HOSTS,
),
]
@@ -97,11 +122,16 @@ class Marksman(SolidLanguageServer):
def _get_or_install_core_dependency(self) -> str:
"""Setup runtime dependencies for marksman and return the command to start the server."""
marksman_version = self._custom_settings.get("marksman_version", self.DEFAULT_MARKSMAN_VERSION)
marksman_version = self._custom_settings.get("marksman_version", DEFAULT_MARKSMAN_VERSION)
deps = self._runtime_dependencies(marksman_version)
dependency = deps.get_single_dep_for_current_platform()
marksman_ls_dir = self._ls_resources_dir
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
marksman_ls_dir = (
self._ls_resources_dir
if marksman_version == INITIAL_MARKSMAN_VERSION
else os.path.join(self._ls_resources_dir, f"marksman-{marksman_version}")
)
marksman_executable_path = deps.binary_path(marksman_ls_dir)
if not os.path.exists(marksman_executable_path):
log.info(
@@ -50,11 +50,29 @@ log = logging.getLogger(__name__)
# Environment variable for MATLAB installation path
MATLAB_PATH_ENV_VAR = "MATLAB_PATH"
MATLAB_EXTENSION_VERSION = "1.3.9"
MATLAB_EXTENSION_URL = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/vsextensions/language-matlab/{MATLAB_EXTENSION_VERSION}/vspackage"
MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2"
MATLAB_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_MATLAB_EXTENSION_VERSION = "1.3.9"
INITIAL_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2"
DEFAULT_MATLAB_EXTENSION_VERSION = "1.3.9"
DEFAULT_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2"
def _matlab_extension_sha(version: str) -> str | None:
if version == INITIAL_MATLAB_EXTENSION_VERSION:
return INITIAL_MATLAB_EXTENSION_SHA256
if version == DEFAULT_MATLAB_EXTENSION_VERSION:
return DEFAULT_MATLAB_EXTENSION_SHA256
return None
def _matlab_extension_dirname(version: str) -> str:
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
return "matlab-extension" if version == INITIAL_MATLAB_EXTENSION_VERSION else f"matlab-extension-{version}"
class MatlabLanguageServer(SolidLanguageServer):
"""
@@ -102,20 +120,12 @@ class MatlabLanguageServer(SolidLanguageServer):
self._matlab_path: str | None = None
@classmethod
def _download_matlab_extension(cls, url: str, target_dir: str) -> bool:
def _download_matlab_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool:
"""
Download and extract the MATLAB extension from VS Code marketplace.
The VS Code marketplace packages extensions as .vsix files (which are ZIP archives).
This method downloads the VSIX file and extracts it to get the language server.
Args:
url: VS Code marketplace URL for the MATLAB extension
target_dir: Directory where the extension will be extracted
Returns:
True if successful, False otherwise
"""
try:
log.info(f"Downloading MATLAB extension from {url}")
@@ -124,7 +134,7 @@ class MatlabLanguageServer(SolidLanguageServer):
url,
target_dir,
"zip",
expected_sha256=MATLAB_EXTENSION_SHA256 if url == MATLAB_EXTENSION_URL else None,
expected_sha256=expected_sha256,
allowed_hosts=MATLAB_EXTENSION_ALLOWED_HOSTS,
)
log.info("MATLAB extension extracted successfully")
@@ -155,8 +165,9 @@ class MatlabLanguageServer(SolidLanguageServer):
elif env_path:
log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}")
# Check default download location
default_path = os.path.join(self._ls_resources_dir, "matlab-extension", "extension")
# Check the resolved-version download location (versioned for non-INITIAL, legacy "matlab-extension" for INITIAL)
matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION)
default_path = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version), "extension")
if os.path.exists(default_path):
log.debug(f"Found MATLAB extension in default location: {default_path}")
return default_path
@@ -182,8 +193,8 @@ class MatlabLanguageServer(SolidLanguageServer):
Path to installed extension or None if download failed
"""
matlab_extension_dir = os.path.join(self._ls_resources_dir, "matlab-extension")
matlab_extension_version = self._custom_settings.get("matlab_extension_version", MATLAB_EXTENSION_VERSION)
matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION)
matlab_extension_dir = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version))
matlab_extension_url = (
"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/"
f"vsextensions/language-matlab/{matlab_extension_version}/vspackage"
@@ -191,7 +202,7 @@ class MatlabLanguageServer(SolidLanguageServer):
log.info(f"Downloading MATLAB extension from: {matlab_extension_url}")
if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir):
if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir, _matlab_extension_sha(matlab_extension_version)):
extension_path = os.path.join(matlab_extension_dir, "extension")
if os.path.exists(extension_path):
log.info("MATLAB extension downloaded and installed successfully")
@@ -242,7 +242,7 @@ class OcamlLanguageServer(SolidLanguageServer):
"""
ocaml_ok = self._ocaml_version >= (5, 2, 0)
lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS
return ocaml_ok and lsp_ok
return ocaml_ok and lsp_ok and self._index_built
@staticmethod
def _build_ocaml_index_static(repository_root_path: str) -> bool:
@@ -340,6 +340,14 @@ class OcamlLanguageServer(SolidLanguageServer):
"configuration": True,
},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False},
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
+14 -4
View File
@@ -22,6 +22,11 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
OMNISHARP_ALLOWED_HOSTS = ("roslynomnisharp.blob.core.windows.net", "download.visualstudio.microsoft.com")
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_OMNISHARP_VERSION = "1.39.10"
INITIAL_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1"
DEFAULT_OMNISHARP_VERSION = "1.39.10"
DEFAULT_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1"
@@ -168,11 +173,11 @@ class OmniSharp(SolidLanguageServer):
dependency["installPath"] = dependency["installPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version)
if "installTestPath" in dependency:
dependency["installTestPath"] = dependency["installTestPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version)
if omnisharp_version != DEFAULT_OMNISHARP_VERSION:
if omnisharp_version not in (INITIAL_OMNISHARP_VERSION, DEFAULT_OMNISHARP_VERSION):
dependency["integrity"] = None
elif dependency["id"] == "RazorOmnisharp":
dependency["url"] = dependency["url"].replace(DEFAULT_RAZOR_OMNISHARP_VERSION, razor_omnisharp_version)
if razor_omnisharp_version != DEFAULT_RAZOR_OMNISHARP_VERSION:
if razor_omnisharp_version not in (INITIAL_RAZOR_OMNISHARP_VERSION, DEFAULT_RAZOR_OMNISHARP_VERSION):
dependency["integrity"] = None
assert platform_id in [
@@ -207,7 +212,9 @@ class OmniSharp(SolidLanguageServer):
assert "OmniSharp" in runtime_dependencies
assert "RazorOmnisharp" in runtime_dependencies
omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
omnisharp_dirname = "OmniSharp" if omnisharp_version == INITIAL_OMNISHARP_VERSION else f"OmniSharp-{omnisharp_version}"
omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), omnisharp_dirname)
if not os.path.exists(omnisharp_ls_dir):
os.makedirs(omnisharp_ls_dir)
FileUtils.download_and_extract_archive_verified(
@@ -221,7 +228,10 @@ class OmniSharp(SolidLanguageServer):
assert os.path.exists(omnisharp_executable_path)
os.chmod(omnisharp_executable_path, 0o755)
razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp")
razor_dirname = (
"RazorOmnisharp" if razor_omnisharp_version == INITIAL_RAZOR_OMNISHARP_VERSION else f"RazorOmnisharp-{razor_omnisharp_version}"
)
razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), razor_dirname)
if not os.path.exists(razor_omnisharp_ls_dir):
os.makedirs(razor_omnisharp_ls_dir)
FileUtils.download_and_extract_archive_verified(
+48 -7
View File
@@ -69,7 +69,34 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
PASLS_VERSION = "v0.2.0"
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_PASLS_VERSION = "v0.2.0"
INITIAL_PASLS_SHA256_BY_PLATFORM = {
"linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b",
"linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502",
"osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1",
"osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7",
"win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5",
}
DEFAULT_PASLS_VERSION = "v0.2.0"
DEFAULT_PASLS_SHA256_BY_PLATFORM = {
"linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b",
"linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502",
"osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1",
"osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7",
"win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5",
}
PASLS_VERSION = DEFAULT_PASLS_VERSION
def _pasls_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_PASLS_VERSION:
return INITIAL_PASLS_SHA256_BY_PLATFORM.get(platform_key)
if version == DEFAULT_PASLS_VERSION:
return DEFAULT_PASLS_SHA256_BY_PLATFORM.get(platform_key)
return None
class PascalLanguageServer(SolidLanguageServer):
@@ -629,7 +656,12 @@ class PascalLanguageServer(SolidLanguageServer):
log.info(f"Found pasls in PATH: {pasls_in_path}")
return quote_windows_path(pasls_in_path)
pasls_dir = cls.ls_resources_dir(solidlsp_settings)
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
pasls_dir = (
cls.ls_resources_dir(solidlsp_settings)
if pasls_version == INITIAL_PASLS_VERSION
else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"pasls-{pasls_version}")
)
os.makedirs(pasls_dir, exist_ok=True)
# Clean up old files from previous sessions
@@ -647,7 +679,7 @@ class PascalLanguageServer(SolidLanguageServer):
platform_id="linux-x64",
archive_type="gztar",
binary_name="pasls",
sha256="517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b",
sha256=_pasls_sha(pasls_version, "linux-x64"),
),
RuntimeDependency(
id="PascalLanguageServer",
@@ -656,7 +688,7 @@ class PascalLanguageServer(SolidLanguageServer):
platform_id="linux-arm64",
archive_type="gztar",
binary_name="pasls",
sha256="cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502",
sha256=_pasls_sha(pasls_version, "linux-arm64"),
),
RuntimeDependency(
id="PascalLanguageServer",
@@ -665,7 +697,7 @@ class PascalLanguageServer(SolidLanguageServer):
platform_id="osx-x64",
archive_type="zip",
binary_name="pasls",
sha256="0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1",
sha256=_pasls_sha(pasls_version, "osx-x64"),
),
RuntimeDependency(
id="PascalLanguageServer",
@@ -674,7 +706,7 @@ class PascalLanguageServer(SolidLanguageServer):
platform_id="osx-arm64",
archive_type="zip",
binary_name="pasls",
sha256="d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7",
sha256=_pasls_sha(pasls_version, "osx-arm64"),
),
RuntimeDependency(
id="PascalLanguageServer",
@@ -683,7 +715,7 @@ class PascalLanguageServer(SolidLanguageServer):
platform_id="win-x64",
archive_type="zip",
binary_name="pasls.exe",
sha256="1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5",
sha256=_pasls_sha(pasls_version, "win-x64"),
),
]
)
@@ -777,6 +809,14 @@ class PascalLanguageServer(SolidLanguageServer):
if value:
initialization_options[var] = value
initialization_options.update(
{
"checkSyntax": True,
"publishDiagnostics": True,
"showSyntaxErrors": True,
}
)
initialize_params = {
"locale": "en",
"capabilities": {
@@ -831,6 +871,7 @@ class PascalLanguageServer(SolidLanguageServer):
},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
+25 -7
View File
@@ -20,11 +20,24 @@ 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"
PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3"
PHPACTOR_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_PHPACTOR_VERSION = "2025.12.21.1"
INITIAL_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3"
DEFAULT_PHPACTOR_VERSION = "2025.12.21.1"
DEFAULT_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3"
def _phpactor_sha(version: str) -> str | None:
if version == INITIAL_PHPACTOR_VERSION:
return INITIAL_PHPACTOR_PHAR_SHA256
if version == DEFAULT_PHPACTOR_VERSION:
return DEFAULT_PHPACTOR_PHAR_SHA256
return None
class PhpactorServer(SolidLanguageServer):
"""
@@ -48,7 +61,7 @@ class PhpactorServer(SolidLanguageServer):
"""
Setup runtime dependencies for Phpactor and return the path to the PHAR file.
"""
phpactor_version = self._custom_settings.get("phpactor_version", PHPACTOR_VERSION)
phpactor_version = self._custom_settings.get("phpactor_version", DEFAULT_PHPACTOR_VERSION)
phpactor_phar_url = f"https://github.com/phpactor/phpactor/releases/download/{phpactor_version}/phpactor.phar"
# Verify PHP is installed
php_path = shutil.which("php")
@@ -68,15 +81,20 @@ class PhpactorServer(SolidLanguageServer):
else:
log.warning("Could not parse PHP version from output. Continuing anyway.")
phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar")
# legacy unversioned phar at root reserved for INITIAL; every other version goes into a versioned subdir
if phpactor_version == INITIAL_PHPACTOR_VERSION:
phar_dir = self._ls_resources_dir
else:
phar_dir = os.path.join(self._ls_resources_dir, f"phpactor-{phpactor_version}")
phpactor_phar_path = os.path.join(phar_dir, "phpactor.phar")
if not os.path.exists(phpactor_phar_path):
os.makedirs(self._ls_resources_dir, exist_ok=True)
os.makedirs(phar_dir, exist_ok=True)
log.info(f"Downloading phpactor PHAR from {phpactor_phar_url}")
FileUtils.download_and_extract_archive_verified(
phpactor_phar_url,
phpactor_phar_path,
"binary",
expected_sha256=PHPACTOR_PHAR_SHA256 if phpactor_version == PHPACTOR_VERSION else None,
expected_sha256=_phpactor_sha(phpactor_version),
allowed_hosts=PHPACTOR_ALLOWED_HOSTS,
)
@@ -5,6 +5,8 @@ Contains various configurations and settings specific to PowerShell scripting.
You can pass the following entries in ``ls_specific_settings["powershell"]``:
- pses_version: Override the pinned PowerShell Editor Services version
downloaded by Serena (default: the bundled Serena version).
- psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version
saved into the bundled PowerShell Editor Services module path.
"""
import logging
@@ -12,6 +14,7 @@ import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
import threading
from collections.abc import Hashable
@@ -19,7 +22,8 @@ from pathlib import Path
from overrides import override
from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer
from solidlsp import ls_types
from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import FileUtils
@@ -30,9 +34,32 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# PowerShell Editor Services version to download
PSES_VERSION = "4.4.0"
PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_PSES_VERSION = "4.4.0"
INITIAL_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
DEFAULT_PSES_VERSION = "4.4.0"
DEFAULT_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
def _pses_sha(version: str) -> str | None:
if version == INITIAL_PSES_VERSION:
return INITIAL_PSES_SHA256
if version == DEFAULT_PSES_VERSION:
return DEFAULT_PSES_SHA256
return None
def _pses_install_dir(ls_resources_dir: str, version: str) -> Path:
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
if version == INITIAL_PSES_VERSION:
return Path(ls_resources_dir) / "powershell"
return Path(ls_resources_dir) / f"powershell-{version}"
PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
PSSCRIPTANALYZER_VERSION = "1.25.0"
class PowerShellLanguageServer(SolidLanguageServer):
@@ -94,7 +121,9 @@ class PowerShellLanguageServer(SolidLanguageServer):
@classmethod
def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""Get the path to PowerShell Editor Services installation."""
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL)
pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION)
install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version)
start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1"
if start_script.exists():
@@ -106,13 +135,13 @@ class PowerShellLanguageServer(SolidLanguageServer):
def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str:
"""Download and install PowerShell Editor Services."""
ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL)
pses_version = ps_settings.get("pses_version", PSES_VERSION)
pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION)
download_url = (
f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{pses_version}/PowerShellEditorServices.zip"
)
# Create installation directory
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
# Create installation directory; legacy unversioned dir reserved for INITIAL only
install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version)
install_dir.mkdir(parents=True, exist_ok=True)
log.info(f"Downloading PowerShell Editor Services from {download_url}...")
@@ -120,7 +149,7 @@ class PowerShellLanguageServer(SolidLanguageServer):
download_url,
str(install_dir),
"zip",
expected_sha256=PSES_SHA256 if pses_version == PSES_VERSION else None,
expected_sha256=_pses_sha(pses_version),
allowed_hosts=PSES_ALLOWED_HOSTS,
)
@@ -157,6 +186,29 @@ class PowerShellLanguageServer(SolidLanguageServer):
# The bundled modules path is the directory containing PowerShellEditorServices
bundled_modules_path = str(Path(pses_path).parent)
psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get(
"psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION
)
psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version
if not psscriptanalyzer_path.exists():
log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...")
subprocess.run(
[
pwsh_path,
"-NoLogo",
"-NoProfile",
"-Command",
(
"Save-Module "
"-Name PSScriptAnalyzer "
f"-RequiredVersion '{psscriptanalyzer_version}' "
f"-Path '{bundled_modules_path}' "
"-Force "
"-ErrorAction Stop"
),
],
check=True,
)
return pwsh_path, pses_path, bundled_modules_path
@@ -345,3 +397,35 @@ class PowerShellLanguageServer(SolidLanguageServer):
self.server_ready.set()
else:
log.info("PowerShell Editor Services initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
published_uri = self._get_published_diagnostics_uri(uri)
diagnostics_before_request = self._get_published_diagnostics_generation(published_uri)
with self.open_file(relative_file_path):
self.server.notify.did_save_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=published_uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -155,6 +155,52 @@ class PyrightServer(SolidLanguageServer):
self.found_source_files = True
self.analysis_complete.set()
def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None:
"""Tracks Pyright-specific progress notifications.
Pyright can emit custom progress notifications instead of only using
``$/progress``. Handling them avoids noisy unhandled-method warnings
and provides an additional signal that initial analysis has quiesced.
"""
# normalizing the notification payload
message_text = ""
percentage: object | None = None
if isinstance(params, dict):
raw_message = params.get("message")
message_text = "" if raw_message is None else str(raw_message)
percentage = params.get("percentage")
elif params is not None:
message_text = str(params)
progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text
# logging the progress transition
if progress_kind == "begin":
log.info("Pyright progress started: %s", progress_label)
return
if progress_kind == "report":
log.debug("Pyright progress update: %s", progress_label)
return
log.info("Pyright progress finished: %s", progress_label)
self.analysis_complete.set()
def pyright_begin_progress(params: object | None) -> None:
"""Handles the ``pyright/beginProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("begin", params)
def pyright_report_progress(params: object | None) -> None:
"""Handles the ``pyright/reportProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("report", params)
def pyright_end_progress(params: object | None) -> None:
"""Handles the ``pyright/endProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("end", params)
def check_experimental_status(params: dict) -> None:
"""
Also listen for experimental/serverStatus as a backup signal
@@ -170,6 +216,9 @@ class PyrightServer(SolidLanguageServer):
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("pyright/beginProgress", pyright_begin_progress)
self.server.on_notification("pyright/reportProgress", pyright_report_progress)
self.server.on_notification("pyright/endProgress", pyright_end_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("language/actionableNotification", do_nothing)
self.server.on_notification("experimental/serverStatus", check_experimental_status)
@@ -26,6 +26,10 @@ class RustAnalyzer(SolidLanguageServer):
Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust.
"""
@classmethod
def supports_implementation_request(cls) -> bool:
return True
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify rust-analyzer stderr output to avoid false-positive errors."""
@@ -7,19 +7,31 @@ import glob
import logging
import os
import pathlib
import platform
import shutil
import threading
from time import sleep
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, 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__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4"
DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4"
INITIAL_FORGE_VERSION = "1.5.1"
DEFAULT_FORGE_VERSION = "1.5.1"
class SolidityLanguageServer(SolidLanguageServer):
"""
@@ -69,7 +81,10 @@ class SolidityLanguageServer(SolidLanguageServer):
assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js 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."
solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4")
solidity_language_server_version = self._custom_settings.get(
"solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION
)
forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -84,17 +99,29 @@ class SolidityLanguageServer(SolidLanguageServer):
),
platform_id="any",
),
RuntimeDependency(
id="forge",
description="Foundry forge CLI",
# the @foundry-rs/forge meta-package's postinstall uses POSIX env-var
# syntax that fails on Windows cmd.exe, so we install the platform-specific
# subpackage directly to bypass it
command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry),
platform_id="any",
),
]
)
solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server")
solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version)
managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server")
forge_executable_path = os.path.join(managed_bin_dir, "forge")
if os.name == "nt":
solidity_executable_path += ".cmd"
forge_executable_path += ".cmd"
if not os.path.exists(solidity_executable_path):
log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...")
if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path):
log.info("Solidity Language Server dependencies missing. Installing...")
deps.install(solidity_ls_dir)
log.info("Solidity language server dependencies installed successfully.")
@@ -106,6 +133,45 @@ class SolidityLanguageServer(SolidLanguageServer):
return solidity_executable_path
@staticmethod
def _get_forge_npm_package() -> str:
"""
Returns the platform-specific @foundry-rs forge npm subpackage name. Installing
the meta-package fails on Windows due to a POSIX-only postinstall script.
"""
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
arch = "amd64"
elif machine in ("arm64", "aarch64"):
arch = "arm64"
else:
raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}")
if os.name == "nt":
if arch != "amd64":
raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package")
return "@foundry-rs/forge-win32-amd64"
if platform.system() == "Darwin":
return f"@foundry-rs/forge-darwin-{arch}"
return f"@foundry-rs/forge-linux-{arch}"
def create_launch_command_env(self) -> dict[str, str]:
solidity_language_server_version = self._custom_settings.get(
"solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION
)
forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION)
solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version)
managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")}
def _resolve_solidity_ls_dir(self, solidity_language_server_version: str, forge_version: str) -> str:
# legacy unversioned dir reserved for INITIAL pair; any other combination goes into a versioned subdir
is_initial = (
solidity_language_server_version == INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION and forge_version == INITIAL_FORGE_VERSION
)
ls_dirname = "solidity-lsp" if is_initial else f"solidity-lsp-{solidity_language_server_version}-{forge_version}"
return os.path.join(self._ls_resources_dir, ls_dirname)
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@@ -148,6 +214,7 @@ class SolidityLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"], # type: ignore[list-item]
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -224,3 +291,83 @@ class SolidityLanguageServer(SolidLanguageServer):
log.info("No .sol files found; skipping indexing wait")
log.info("Solidity language server initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
uri = self._get_validation_document_uri(relative_file_path)
diagnostics_before_request = self._get_published_diagnostics_generation(uri)
absolute_path = pathlib.Path(self.repository_root_path, relative_file_path)
contents = absolute_path.read_text(encoding=self._encoding)
lines = contents.split("\n")
end_position = {
"line": len(lines) - 1,
"character": len(lines[-1]),
}
self.server.notify.did_open_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.LANGUAGE_ID: self.language_id,
LSPConstants.VERSION: 0,
LSPConstants.TEXT: contents,
}
}
)
try:
self.server.notify.did_change_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.VERSION: 1,
},
LSPConstants.CONTENT_CHANGES: [ # type: ignore
{
LSPConstants.RANGE: {
"start": {"line": 0, "character": 0},
"end": end_position,
},
"text": contents,
}
],
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
finally:
self.server.notify.did_close_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
def _get_validation_document_uri(self, relative_file_path: str) -> str:
absolute_path = pathlib.Path(self.repository_root_path, relative_file_path)
if os.name != "nt":
return absolute_path.as_uri()
path = absolute_path.as_posix()
if len(path) >= 2 and path[1] == ":":
path = f"{path[0].lower()}%3A{path[2:]}"
return f"file:///{path.lstrip('/')}"
+33 -15
View File
@@ -24,15 +24,24 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Taplo release version and download URLs
TAPLO_VERSION = "0.10.0"
TAPLO_DOWNLOAD_BASE = f"https://github.com/tamasfe/taplo/releases/download/{TAPLO_VERSION}"
TAPLO_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
# SHA256 checksums for Taplo releases (verified from official GitHub releases)
# Source: https://github.com/tamasfe/taplo/releases/tag/0.10.0
# To update: download each release file and run: sha256sum <filename>
TAPLO_SHA256_CHECKSUMS: dict[str, str] = {
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
# To update: download each release file and run: sha256sum <filename>, copy values into DEFAULT_*.
INITIAL_TAPLO_VERSION = "0.10.0"
INITIAL_TAPLO_SHA256_CHECKSUMS: dict[str, str] = {
"taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9",
"taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0",
"taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2",
"taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d",
"taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156",
"taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3",
"taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417",
}
DEFAULT_TAPLO_VERSION = "0.10.0"
DEFAULT_TAPLO_SHA256_CHECKSUMS: dict[str, str] = {
"taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9",
"taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0",
"taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2",
@@ -43,7 +52,15 @@ TAPLO_SHA256_CHECKSUMS: dict[str, str] = {
}
def _get_taplo_download_url(version: str = TAPLO_VERSION) -> tuple[str, str]:
def _taplo_sha(version: str, archive_filename: str) -> str | None:
if version == INITIAL_TAPLO_VERSION:
return INITIAL_TAPLO_SHA256_CHECKSUMS.get(archive_filename)
if version == DEFAULT_TAPLO_VERSION:
return DEFAULT_TAPLO_SHA256_CHECKSUMS.get(archive_filename)
return None
def _get_taplo_download_url(version: str = DEFAULT_TAPLO_VERSION) -> tuple[str, str]:
"""
Get the appropriate Taplo download URL for the current platform.
@@ -130,11 +147,11 @@ class TaploServer(SolidLanguageServer):
log.info(f"Using system-installed Taplo at: {system_taplo}")
return system_taplo
# Setup local installation directory
taplo_dir = os.path.join(self._ls_resources_dir, "taplo")
# Setup local installation directory; legacy unversioned dir reserved for INITIAL only
taplo_version = self._custom_settings.get("taplo_version", DEFAULT_TAPLO_VERSION)
ls_dirname = "taplo" if taplo_version == INITIAL_TAPLO_VERSION else f"taplo-{taplo_version}"
taplo_dir = os.path.join(self._ls_resources_dir, ls_dirname)
os.makedirs(taplo_dir, exist_ok=True)
taplo_version = self._custom_settings.get("taplo_version", TAPLO_VERSION)
_, executable_name = _get_taplo_download_url(taplo_version)
taplo_executable = os.path.join(taplo_dir, executable_name)
@@ -158,12 +175,13 @@ class TaploServer(SolidLanguageServer):
return [core_path, "lsp", "stdio"]
@classmethod
def _download_taplo(cls, install_dir: str, executable_path: str, version: str = TAPLO_VERSION) -> None:
def _download_taplo(cls, install_dir: str, executable_path: str, version: str = DEFAULT_TAPLO_VERSION) -> None:
"""Download and extract Taplo binary using the shared verified download helper."""
download_url, _ = _get_taplo_download_url(version)
archive_filename = os.path.basename(download_url)
expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) if version == TAPLO_VERSION else None
if expected_hash is None and version == TAPLO_VERSION:
# only verify the SHA when the resolved version is one of our pinned ones (INITIAL or current DEFAULT)
expected_hash = _taplo_sha(version, archive_filename)
if expected_hash is None and version in (INITIAL_TAPLO_VERSION, DEFAULT_TAPLO_VERSION):
raise RuntimeError(f"No SHA256 checksum configured for Taplo archive: {archive_filename}")
try:
+42 -8
View File
@@ -18,6 +18,34 @@ log = logging.getLogger(__name__)
TERRAFORM_LS_ALLOWED_HOSTS = ("releases.hashicorp.com",)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_TERRAFORM_LS_VERSION = "0.36.5"
INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM = {
"osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c",
"osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a",
"linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7",
"linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44",
"win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec",
}
DEFAULT_TERRAFORM_LS_VERSION = "0.36.5"
DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM = {
"osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c",
"osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a",
"linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7",
"linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44",
"win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec",
}
def _terraform_ls_sha(version: str, platform_key: str) -> str | None:
if version == INITIAL_TERRAFORM_LS_VERSION:
return INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key]
if version == DEFAULT_TERRAFORM_LS_VERSION:
return DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key]
return None
class TerraformLS(SolidLanguageServer):
"""
@@ -99,7 +127,7 @@ class TerraformLS(SolidLanguageServer):
"""
cls._ensure_tf_command_available()
terraform_settings = solidlsp_settings.get_ls_specific_settings(Language.TERRAFORM)
terraform_ls_version = terraform_settings.get("terraform_ls_version", "0.36.5")
terraform_ls_version = terraform_settings.get("terraform_ls_version", DEFAULT_TERRAFORM_LS_VERSION)
platform_id = PlatformUtils.get_platform_id()
deps = RuntimeDependencyCollection(
[
@@ -110,7 +138,7 @@ class TerraformLS(SolidLanguageServer):
platform_id="osx-arm64",
archive_type="zip",
binary_name="terraform-ls",
sha256="fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c" if terraform_ls_version == "0.36.5" else None,
sha256=_terraform_ls_sha(terraform_ls_version, "osx-arm64"),
allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -120,7 +148,7 @@ class TerraformLS(SolidLanguageServer):
platform_id="osx-x64",
archive_type="zip",
binary_name="terraform-ls",
sha256="17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a" if terraform_ls_version == "0.36.5" else None,
sha256=_terraform_ls_sha(terraform_ls_version, "osx-x64"),
allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -130,7 +158,7 @@ class TerraformLS(SolidLanguageServer):
platform_id="linux-arm64",
archive_type="zip",
binary_name="terraform-ls",
sha256="724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7" if terraform_ls_version == "0.36.5" else None,
sha256=_terraform_ls_sha(terraform_ls_version, "linux-arm64"),
allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -140,7 +168,7 @@ class TerraformLS(SolidLanguageServer):
platform_id="linux-x64",
archive_type="zip",
binary_name="terraform-ls",
sha256="37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44" if terraform_ls_version == "0.36.5" else None,
sha256=_terraform_ls_sha(terraform_ls_version, "linux-x64"),
allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS,
),
RuntimeDependency(
@@ -150,17 +178,23 @@ class TerraformLS(SolidLanguageServer):
platform_id="win-x64",
archive_type="zip",
binary_name="terraform-ls.exe",
sha256="a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec" if terraform_ls_version == "0.36.5" else None,
sha256=_terraform_ls_sha(terraform_ls_version, "win-x64"),
allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS,
),
]
)
dependency = deps.get_single_dep_for_current_platform()
terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings))
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
install_dir = (
cls.ls_resources_dir(solidlsp_settings)
if terraform_ls_version == INITIAL_TERRAFORM_LS_VERSION
else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"terraform-ls-{terraform_ls_version}")
)
terraform_ls_executable_path = deps.binary_path(install_dir)
if not os.path.exists(terraform_ls_executable_path):
log.info(f"Downloading terraform-ls from {dependency.url}")
deps.install(cls.ls_resources_dir(solidlsp_settings))
deps.install(install_dir)
assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}"
@@ -23,6 +23,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_TYPESCRIPT_VERSION = "5.9.3"
DEFAULT_TYPESCRIPT_VERSION = "5.9.3"
INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3"
DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3"
# Platform-specific imports
if os.name != "nt": # Unix-like systems
import pwd
@@ -67,6 +75,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
"""
@classmethod
def supports_implementation_request(cls) -> bool:
return True
# Safety timeout for $/progress-based indexing wait. Normally the event fires
# well within this window; the timeout is only hit if the server never sends progress.
INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0
@@ -85,7 +97,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
# Progress tracking for $/progress notifications (project indexing, etc.)
# tracking asynchronous diagnostics publication
self._published_diagnostics_timeout = 5.0
# tracking project indexing progress
self._progress_lock = threading.Lock()
self._active_progress_tokens: set[str] = set()
self._indexing_complete = threading.Event()
@@ -147,8 +162,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
# Get version settings from ls_specific_settings or use defaults
language_specific_config = self._custom_settings
typescript_version = language_specific_config.get("typescript_version", "5.9.3")
typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3")
typescript_version = language_specific_config.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION)
typescript_language_server_version = language_specific_config.get(
"typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION
)
npm_registry = language_specific_config.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -174,38 +191,19 @@ class TypeScriptLanguageServer(SolidLanguageServer):
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 typescript and typescript-language-server if not already installed or version mismatch
tsserver_ls_dir = os.path.join(self._ls_resources_dir, "ts-lsp")
# legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir
is_initial = (
typescript_version == INITIAL_TYPESCRIPT_VERSION
and typescript_language_server_version == INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION
)
ls_dirname = "ts-lsp" if is_initial else f"ts-lsp-{typescript_version}-{typescript_language_server_version}"
tsserver_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server")
# Check if installation is needed based on executable AND version
version_file = os.path.join(tsserver_ls_dir, ".installed_version")
expected_version = f"{typescript_version}_{typescript_language_server_version}"
needs_install = False
if not os.path.exists(tsserver_executable_path):
log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.")
needs_install = True
elif os.path.exists(version_file):
with open(version_file) as f:
installed_version = f.read().strip()
if installed_version != expected_version:
log.info(
f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..."
)
needs_install = True
else:
# No version file exists, assume old installation needs refresh
log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...")
needs_install = True
if needs_install:
log.info("Installing TypeScript Language Server dependencies...")
log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...")
with LogTime("Installation of TypeScript language server dependencies", logger=log):
deps.install(tsserver_ls_dir)
# Write version marker file
with open(version_file, "w") as f:
f.write(expected_version)
log.info("TypeScript language server dependencies installed successfully")
if not os.path.exists(tsserver_executable_path):
@@ -217,6 +215,15 @@ class TypeScriptLanguageServer(SolidLanguageServer):
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
def _get_language_id_for_file(self, relative_file_path: str) -> str:
# JSX is parsed as TS without this, which silently truncates symbol
# ranges at the first multi-line JSX expression.
if relative_file_path.endswith(".tsx"):
return "typescriptreact"
if relative_file_path.endswith(".jsx"):
return "javascriptreact"
return self.language_id
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the TypeScript Language Server.
@@ -239,9 +246,11 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
"configuration": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
@@ -284,6 +293,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
def execute_client_command_handler(params: dict) -> list:
return []
def configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
def do_nothing(params: dict) -> None:
return
@@ -343,6 +356,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self._indexing_complete.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", configuration_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
@@ -427,6 +441,20 @@ class TypeScriptLanguageServer(SolidLanguageServer):
else:
log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway")
@override
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
if os.name != "nt" or not request_uri.startswith("file:///"):
return request_uri
path_part = request_uri[len("file:///") :]
if len(path_part) >= 2 and path_part[0].isalpha() and path_part[1] == ":":
return f"file:///{path_part[0].lower()}%3A{path_part[2:]}"
return request_uri
@override
def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float:
return self._published_diagnostics_timeout
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 2
@@ -24,6 +24,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_VTSLS_VERSION = "0.2.9"
DEFAULT_VTSLS_VERSION = "0.2.9"
class VtsLanguageServer(SolidLanguageServer):
"""
@@ -73,7 +79,7 @@ class VtsLanguageServer(SolidLanguageServer):
]
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment"
vts_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT_VTS)
vtsls_version = vts_config.get("vtsls_version", "0.2.9")
vtsls_version = vts_config.get("vtsls_version", DEFAULT_VTSLS_VERSION)
npm_registry = vts_config.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -86,7 +92,9 @@ class VtsLanguageServer(SolidLanguageServer):
),
]
)
vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = "vts-lsp" if vtsls_version == INITIAL_VTSLS_VERSION else f"vts-lsp-{vtsls_version}"
vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname)
vts_executable_path = os.path.join(vts_ls_dir, "vtsls")
# Verify both node and npm are installed
@@ -426,6 +426,18 @@ class VueLanguageServer(SolidLanguageServer):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name)
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
self._ensure_ls_operational()
assert self._ts_server is not None
return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity)
def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None:
"""
Calls ``edit_fn`` on the TypeScript server if the file is open there.
@@ -598,6 +610,7 @@ class VueLanguageServer(SolidLanguageServer):
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Version pinning convention (see eclipse_jdtls.py for the full spec):
# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
# DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_YAML_LANGUAGE_SERVER_VERSION = "1.19.2"
DEFAULT_YAML_LANGUAGE_SERVER_VERSION = "1.19.2"
class YamlLanguageServer(SolidLanguageServer):
"""
@@ -66,7 +72,7 @@ class YamlLanguageServer(SolidLanguageServer):
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."
yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", "1.19.2")
yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", DEFAULT_YAML_LANGUAGE_SERVER_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -80,8 +86,13 @@ class YamlLanguageServer(SolidLanguageServer):
]
)
# Install yaml-language-server if not already installed
yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp")
# legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
ls_dirname = (
"yaml-lsp"
if yaml_language_server_version == INITIAL_YAML_LANGUAGE_SERVER_VERSION
else f"yaml-lsp-{yaml_language_server_version}"
)
yaml_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname)
yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server")
# Handle Windows executable extension
+1
View File
@@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
+399 -6
View File
@@ -13,8 +13,8 @@ from collections.abc import Hashable, Iterator
from contextlib import contextmanager
from copy import copy
from pathlib import Path, PurePath
from time import perf_counter, sleep
from typing import Self, Union, cast
from time import monotonic, perf_counter, sleep
from typing import Any, Self, Union, cast
import pathspec
from sensai.util.pickle import getstate, load_pickle
@@ -487,10 +487,14 @@ class SolidLanguageServer(ABC):
:param config: the global SolidLSP configuration.
:param repository_root_path: the root path of the repository.
:param process_launch_info: (DEPRECATED - implement _create_dependency_provider instead)
:param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead)
the command used to start the actual language server.
The command must pass appropriate flags to the binary, so that it runs in the stdio mode,
as opposed to HTTP, TCP modes supported by some language servers.
:param language_id: The language identifier which will be passed to the language server in the `textDocument/didOpen`
notification by default.
If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file`
to provide the appropriate identifier for each type of file.
:param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming
from this specific language server. This should be incremented by subclasses calling this constructor
whenever the format of the raw document symbols changes (typically because the language server
@@ -511,6 +515,10 @@ class SolidLanguageServer(ABC):
self.language_id = language_id
self.open_file_buffers: dict[str, LSPFileBuffer] = {}
self.language = Language(language_id)
self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {}
self._published_diagnostics_generation_by_uri: dict[str, int] = {}
self._published_diagnostics_generation = 0
self._published_diagnostics_condition = threading.Condition()
# initialise symbol caches
self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id
@@ -550,6 +558,7 @@ class SolidLanguageServer(ABC):
logger=logging_fn,
start_independent_lsp_process=config.start_independent_lsp_process,
)
self.server.on_any_notification(self._observe_server_notification)
# Set up the pathspec matcher for the ignored paths
# for all absolute paths in ignored_paths, convert them to relative paths
@@ -590,6 +599,388 @@ class SolidLanguageServer(ABC):
_seen.add(additional_workspace_abs_path)
self._additional_workspace_abs_paths.append(additional_workspace_abs_path)
def _observe_server_notification(self, method: str, params: Any) -> None:
"""
Observe notifications sent by the language server.
This is used for generic cross-language bookkeeping that must work independently of
language-specific notification handlers.
"""
if method == "textDocument/publishDiagnostics":
self._store_published_diagnostics(params)
def _store_published_diagnostics(self, params: Any) -> None:
"""
Store diagnostics received through ``textDocument/publishDiagnostics``.
"""
if not isinstance(params, dict):
return
uri = params.get("uri")
diagnostics = params.get("diagnostics")
if not isinstance(uri, str) or not isinstance(diagnostics, list):
return
normalized_diagnostics: list[ls_types.Diagnostic] = []
for diagnostic in diagnostics:
if not isinstance(diagnostic, dict):
continue
if "message" not in diagnostic or "range" not in diagnostic:
continue
normalized_diagnostic: ls_types.Diagnostic = {
"uri": uri,
"message": diagnostic["message"],
"range": diagnostic["range"],
}
severity = diagnostic.get("severity")
if isinstance(severity, int):
normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity)
code = diagnostic.get("code")
if isinstance(code, int | str):
normalized_diagnostic["code"] = code
if "source" in diagnostic:
normalized_diagnostic["source"] = diagnostic["source"]
normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic))
# canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match
# what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...)
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
self._published_diagnostics_generation += 1
self._published_diagnostics[key] = normalized_diagnostics
self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation
self._published_diagnostics_condition.notify_all()
@staticmethod
def _canonicalize_published_diagnostics_uri(uri: str) -> str:
"""
Canonicalizes a ``file://`` URI so that diagnostics published by language servers
and lookups based on ``pathlib.Path.as_uri()`` agree on the same key.
On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while
``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an
upper-case drive letter and a plain colon.
"""
if os.name != "nt" or not uri.startswith("file:///"):
return uri
# extract the segment after "file:///" up to the next slash and look for a drive letter
prefix = "file:///"
rest = uri[len(prefix) :]
slash = rest.find("/")
head = rest if slash < 0 else rest[:slash]
tail = "" if slash < 0 else rest[slash:]
if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or (
len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a"
):
head = head[0].upper() + ":"
else:
return uri
return prefix + head + tail
def _get_published_diagnostics_generation(self, uri: str) -> int:
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
return self._published_diagnostics_generation_by_uri.get(key, -1)
def _wait_for_published_diagnostics(
self,
uri: str,
after_generation: int,
timeout: float,
) -> list[ls_types.Diagnostic] | None:
key = self._canonicalize_published_diagnostics_uri(uri)
deadline = perf_counter() + timeout
with self._published_diagnostics_condition:
while True:
current_generation = self._published_diagnostics_generation_by_uri.get(key, -1)
if current_generation > after_generation:
return list(self._published_diagnostics.get(key, []))
remaining_timeout = deadline - perf_counter()
if remaining_timeout <= 0:
return None
self._published_diagnostics_condition.wait(timeout=remaining_timeout)
def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None:
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
diagnostics = self._published_diagnostics.get(key)
if diagnostics is None:
return None
return list(diagnostics)
@staticmethod
def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool:
diagnostic_start_line = diagnostic["range"]["start"]["line"]
diagnostic_end_line = diagnostic["range"]["end"]["line"]
# normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated
# expressions) emit diagnostics whose end position lies before the start position.
# treat such ranges as a single span covering both lines.
lo = min(diagnostic_start_line, diagnostic_end_line)
hi = max(diagnostic_start_line, diagnostic_end_line)
# when end_line < 0 the caller imposes no upper bound; only enforce the lower bound.
if end_line < 0:
return hi >= start_line
return lo <= end_line and hi >= start_line
@staticmethod
def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool:
severity = diagnostic.get("severity")
if severity is None:
return True
return int(severity) <= min_severity
@classmethod
def _filter_diagnostics(
cls,
diagnostics: list[ls_types.Diagnostic],
start_line: int,
end_line: int,
min_severity: int,
) -> list[ls_types.Diagnostic]:
diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)]
diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)]
return diagnostics
def _validate_text_document_diagnostics_request(
self,
relative_file_path: str,
start_line: int,
end_line: int,
min_severity: int,
) -> str:
if not self.server_started:
log.error("request_text_document_diagnostics called before Language Server started")
raise SolidLSPException("Language Server not started")
if start_line < 0:
raise ValueError(f"start_line must be non-negative, got {start_line}")
if end_line != -1 and end_line < start_line:
raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}")
if min_severity not in {1, 2, 3, 4}:
raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}")
return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
def get_published_diagnostics_generation(self, relative_file_path: str) -> int:
"""
Get the generation number for the latest published diagnostics of a file.
:param relative_file_path: The relative path of the file.
:return: the generation number, or ``-1`` if none were published yet.
"""
uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
return self._get_published_diagnostics_generation(uri)
def get_cached_published_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic] | None:
"""
Get cached diagnostics received through ``textDocument/publishDiagnostics``.
:param relative_file_path: The relative path of the file to retrieve diagnostics for.
:param start_line: the first 0-based line to include in the result.
:param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:return: the cached diagnostics, or ``None`` if no diagnostics were published yet.
"""
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
diagnostics = self._get_cached_published_diagnostics(uri)
if diagnostics is None:
return None
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
def request_published_text_document_diagnostics(
self,
relative_file_path: str,
after_generation: int = -1,
timeout: float = 2.5,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
allow_cached: bool = True,
) -> list[ls_types.Diagnostic] | None:
"""
Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them.
:param relative_file_path: The relative path of the file to retrieve diagnostics for.
:param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication.
:param timeout: the maximum time to wait for a newer publication.
:param start_line: the first 0-based line to include in the result.
:param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time.
:return: the published diagnostics, or ``None`` if no diagnostics are available.
"""
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
published_uri = self._get_published_diagnostics_uri(uri)
diagnostics: list[ls_types.Diagnostic] | None = None
# keeping the document open
with self.open_file(relative_file_path):
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=published_uri,
after_generation=after_generation,
timeout=timeout,
allow_cached=allow_cached,
)
if diagnostics is None:
return None
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
"""
Gets the URI under which published diagnostics should be looked up.
"""
return request_uri
def _supports_pull_diagnostics(self) -> bool:
"""
Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull
diagnostics) gracefully. Subclasses should override and return ``False`` when the
underlying server does not implement pull diagnostics, especially when sending
the request would terminate the server (e.g. LanguageServer.jl raises an error
for unknown methods that crashes the process).
"""
return True
def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float:
"""
Gets the timeout for waiting on published diagnostics after a diagnostics request.
"""
return 2.5
def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool:
"""
Determines whether a published diagnostics payload should satisfy the current wait.
"""
return bool(diagnostics)
def _wait_for_relevant_published_diagnostics(
self,
uri: str,
after_generation: int,
timeout: float,
allow_cached: bool = True,
) -> list[ls_types.Diagnostic] | None:
"""
Waits for a published diagnostics payload that is relevant for the current request.
"""
deadline = monotonic() + timeout
current_after_generation = after_generation
while True:
remaining_timeout = deadline - monotonic()
if remaining_timeout <= 0:
break
diagnostics = self._wait_for_published_diagnostics(
uri=uri,
after_generation=current_after_generation,
timeout=remaining_timeout,
)
if diagnostics is None:
break
if self._accept_published_diagnostics(diagnostics):
return diagnostics
current_after_generation = self._get_published_diagnostics_generation(uri)
if allow_cached:
diagnostics = self._get_cached_published_diagnostics(uri)
if diagnostics is not None and self._accept_published_diagnostics(diagnostics):
return diagnostics
return None
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
"""
Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server
to find diagnostics for the given file. Wait for the response and return the result.
:param relative_file_path: The relative path of the file to retrieve diagnostics for
:param start_line: the first 0-based line to include in the result.
:param end_line: the last 0-based line to include in the result. `-1` means no upper bound.
:param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint.
Diagnostics with lower-or-equal numeric severity are returned.
:return: A list of diagnostics for the file
"""
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
published_uri = self._get_published_diagnostics_uri(uri)
diagnostics_before_request = self._get_published_diagnostics_generation(published_uri)
ret: list[ls_types.Diagnostic] | None = None
pull_diagnostics_failed = False
with self.open_file(relative_file_path):
response: Any = None
# only send pull diagnostics when the server actually supports it; some servers
# (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods
if self._supports_pull_diagnostics():
try:
response = self.server.send.text_document_diagnostic(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
except SolidLSPException as ex:
log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex)
response = None
pull_diagnostics_failed = True
if response is not None:
assert isinstance(response, dict), (
f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
)
ret = []
for item in response["items"]: # type: ignore
new_item: ls_types.Diagnostic = {
"uri": uri,
"severity": item["severity"],
"message": item["message"],
"range": item["range"],
"code": item.get("code"), # type: ignore
}
if "source" in item:
new_item["source"] = item["source"]
ret.append(ls_types.Diagnostic(**new_item))
if not ret:
published_diagnostics = self._wait_for_relevant_published_diagnostics(
uri=published_uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed),
)
if published_diagnostics is not None:
ret = published_diagnostics
if ret is None:
return []
return self._filter_diagnostics(ret, start_line, end_line, min_severity)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
"""
Creates the dependency provider for this language server.
@@ -870,10 +1261,12 @@ class SolidLanguageServer(ABC):
pass
def _get_language_id_for_file(self, relative_file_path: str) -> str:
"""Return the language ID for a file.
"""
Determines the language identifier to pass to the language server for the given file,
particularly `textDocument/didOpen` requests.
Override in subclasses to return file-specific language IDs.
Default implementation returns self.language_id.
Override this method in subclasses to return file-specific language identifiers.
The default implementation returns the main identifier passed at construction (self.language_id).
"""
return self.language_id
+6
View File
@@ -207,6 +207,12 @@ class Language(str, Enum):
case _:
return 2
def supports_implementation_request(self) -> bool:
"""
Return whether the default language server for this language supports ``textDocument/implementation``.
"""
return self.get_ls_class().supports_implementation_request()
def get_source_fn_matcher(self) -> FilenameMatcher:
match self:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY:
+17
View File
@@ -150,6 +150,7 @@ class LanguageServerProcess:
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._notification_observers: list[Callable[[str, Any], None]] = []
self._trace_log_fn = logger
self.tasks: dict[int, Any] = {}
self.task_counter = 0
@@ -510,6 +511,12 @@ class LanguageServerProcess:
"""
self.on_notification_handlers[method] = cb
def on_any_notification(self, cb: Callable[[str, Any], None]) -> None:
"""
Register an observer that is invoked for every notification received from the server.
"""
self._notification_observers.append(cb)
def _response_handler(self, response: StringDict) -> None:
"""
Handle the response received from the server for a request, using the id to determine the request
@@ -561,6 +568,16 @@ class LanguageServerProcess:
"""
method = response.get("method", "")
params = response.get("params")
for observer in self._notification_observers:
try:
observer(method, params)
except asyncio.CancelledError:
return
except Exception as ex:
if not self._is_shutting_down:
log.error("Error handling notification observer for method '%s': %s", method, ex, exc_info=ex)
handler = self.on_notification_handlers.get(method)
if not handler:
log.warning("Unhandled method '%s'", method)
+1 -1
View File
@@ -347,7 +347,7 @@ class Diagnostic(TypedDict):
""" The severity of the diagnostic. """
message: str
""" The diagnostic message. """
code: str
code: NotRequired[str | int]
""" The code of the diagnostic. """
source: NotRequired[str]
""" The source of the diagnostic, e.g. the name of the tool that produced it. """
+8
View File
@@ -124,6 +124,14 @@ class TextUtils:
end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col)
return text[start_idx:end_idx]
@staticmethod
def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str:
"""
Returns the text encompassed by the given start and end lines (inclusive).
"""
lines = text.splitlines(keepends=True)
return "".join(lines[start_line : end_line + 1])
class PathUtils:
"""
+6 -3
View File
@@ -131,11 +131,14 @@ class StopLoopException(Exception):
pass
def create_message(payload: PayloadLike) -> tuple[bytes, bytes, bytes]:
def create_message(payload: PayloadLike) -> tuple[bytes, bytes]:
body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING)
# NOTE: only Content-Length is sent. Content-Type is optional per the LSP
# spec (default: application/vscode-jsonrpc; charset=utf-8), and Godot's
# GDScript LSP parser only handles Content-Length — any additional header
# makes it silently drop the message.
return (
f"Content-Length: {len(body)}\r\n".encode(ENCODING),
"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n".encode(ENCODING),
f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING),
body,
)
+61
View File
@@ -1,6 +1,7 @@
import logging
import os
import platform
import re
import shutil as _sh
from collections.abc import Iterator
from contextlib import contextmanager
@@ -41,10 +42,13 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
Language.CPP_CCLS: Language.CPP,
Language.PHP_PHPACTOR: Language.PHP,
Language.PYTHON_JEDI: Language.PYTHON,
Language.PYTHON_TY: Language.PYTHON,
Language.RUBY_SOLARGRAPH: Language.RUBY,
Language.PYTHON_TY: Language.PYTHON,
}
PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY]
def get_repo_path(language: Language) -> Path:
repo_language = _LANGUAGE_REPO_ALIASES.get(language, language)
@@ -334,3 +338,60 @@ def language_tests_enabled(language: Language) -> bool:
:return: True if tests for the language are enabled, False otherwise
"""
return language not in _disabled_languages
def language_supports_implementation(language: Language) -> bool:
return language.supports_implementation_request()
def languages_supporting_implementation(*languages: Language) -> list[Language]:
return [language for language in languages if language_supports_implementation(language)]
_VERIFIED_IMPLEMENTATION_LANGUAGES = {
Language.CSHARP,
Language.GO,
Language.JAVA,
Language.RUST,
Language.TYPESCRIPT,
}
def language_has_verified_implementation_support(language: Language) -> bool:
"""
True only for languages where the server advertises implementation support and
the repo fixtures contain a verified working go-to-implementation scenario.
"""
return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language)
def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None:
pattern = re.compile(r"\b" + re.escape(identifier) + r"\b")
with file_path.open(encoding="utf-8") as f:
for line_idx, line in enumerate(f):
match = pattern.search(line)
if match:
return line_idx, match.start()
return None
def find_identifier_pos(
file_path: Path,
identifier: str,
occurrence_index: int = 0,
column_offset: int = 0,
) -> tuple[int, int] | None:
if occurrence_index < 0:
raise ValueError("occurrence_index must be non-negative")
if column_offset < 0:
raise ValueError("column_offset must be non-negative")
pattern = re.compile(r"\b" + re.escape(identifier) + r"\b")
current_index = 0
with file_path.open(encoding="utf-8") as f:
for line_idx, line in enumerate(f):
for match in pattern.finditer(line):
if current_index == occurrence_index:
return line_idx, match.start() + column_offset
current_index += 1
return None
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
if then
echo "$missingGreeting"
fi
@@ -0,0 +1,9 @@
(ns test-app.diagnostics-sample)
(defn broken-factory []
missing-greeting)
(defn broken-consumer []
(let [value (broken-factory)]
(println value)
missing-consumer-value))
@@ -8,5 +8,10 @@
"directory": ".",
"command": "g++ -std=c++17 -I . -c b.cpp",
"file": "b.cpp"
},
{
"directory": ".",
"command": "g++ -std=c++17 -I . -c diagnostics_sample.cpp",
"file": "diagnostics_sample.cpp"
}
]
]
@@ -0,0 +1,8 @@
int brokenFactory() {
return missingGreeting;
}
int brokenConsumer() {
int value = brokenFactory();
return value + missingConsumerValue;
}
@@ -0,0 +1,5 @@
def broken_factory : String
missingGreeting
end
broken_consumer = missingConsumerValue
@@ -0,0 +1,19 @@
using System;
namespace TestProject
{
public static class DiagnosticsSample
{
public static string BrokenFactory()
{
return missingGreeting;
}
public static void BrokenConsumer()
{
string value = BrokenFactory();
Console.WriteLine(value);
Console.WriteLine(missingConsumerValue);
}
}
}
@@ -1,4 +1,5 @@
using System;
using TestProject.Services;
namespace TestProject
{
@@ -7,10 +8,13 @@ namespace TestProject
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
var calculator = new Calculator();
int result = calculator.Add(5, 3);
Console.WriteLine($"5 + 3 = {result}");
IGreeter greeter = new ConsoleGreeter();
Console.WriteLine(greeter.FormatGreeting("World"));
}
}
@@ -40,4 +44,4 @@ namespace TestProject
return (double)a / b;
}
}
}
}
@@ -0,0 +1,10 @@
namespace TestProject.Services
{
public class ConsoleGreeter : IGreeter
{
public string FormatGreeting(string name)
{
return $"Hello, {name}!";
}
}
}
@@ -0,0 +1,7 @@
namespace TestProject.Services
{
public interface IGreeter
{
string FormatGreeting(string name);
}
}
@@ -0,0 +1,3 @@
String brokenFactory() {
return 'broken'
}
@@ -0,0 +1,10 @@
defmodule DiagnosticsSample do
def broken_factory do
missing_greeting
end
def broken_consumer do
value = broken_factory()
{value, missing_consumer_value}
end
end
@@ -0,0 +1,5 @@
module DiagnosticsSample exposing (brokenFactory)
brokenFactory : String
brokenFactory =
"unterminated
@@ -0,0 +1,5 @@
-module(diagnostics_sample).
-export([broken_factory/0, broken_consumer/0]).
broken_factory() -> MissingGreeting.
broken_consumer() -> MissingConsumerValue.
@@ -0,0 +1,5 @@
program diagnostics_sample
implicit none
integer :: duplicated_value
real :: duplicated_value
end program diagnostics_sample
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more