From f018b4baf676db7f72735b38b6bac45d1b6030e4 Mon Sep 17 00:00:00 2001
From: Darin Morrison
Date: Fri, 24 Apr 2026 13:05:09 -0600
Subject: [PATCH 01/32] Improve cpp log level classification
---
.../clangd_language_server.py | 33 ++++++++++++++
test/solidlsp/cpp/test_clangd_logging.py | 43 +++++++++++++++++++
2 files changed, 76 insertions(+)
create mode 100644 test/solidlsp/cpp/test_clangd_logging.py
diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py
index 0dc5ed9c..7b7d6839 100644
--- a/src/solidlsp/language_servers/clangd_language_server.py
+++ b/src/solidlsp/language_servers/clangd_language_server.py
@@ -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.
diff --git a/test/solidlsp/cpp/test_clangd_logging.py b/test/solidlsp/cpp/test_clangd_logging.py
new file mode 100644
index 00000000..8cef777e
--- /dev/null
+++ b/test/solidlsp/cpp/test_clangd_logging.py
@@ -0,0 +1,43 @@
+import logging
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer
+
+
+@pytest.mark.cpp
+class TestClangdLogging:
+ @pytest.mark.parametrize(
+ "expected, line",
+ [
+ # Parametrize names instead of codes for human-readable output in test logs.
+ (logging.getLevelName(logging.INFO), "I[12:00:00.000] clangd version 22.1.3"),
+ (logging.getLevelName(logging.INFO), "V[12:00:00.000] verbose detail"),
+ (logging.getLevelName(logging.DEBUG), "D[12:00:00.000] debug detail"),
+ (logging.getLevelName(logging.ERROR), "E[12:00:00.000] something bad"),
+ (logging.getLevelName(logging.ERROR), "F[12:00:00.000] fatal"),
+ ],
+ )
+ def test_log_record_detection(self, expected: str, line: str) -> None:
+ """
+ Verifies clangd's stderr log classifier maps each level prefix to the
+ expected Python logging level and avoids misclassifying compile-command
+ payloads as ERROR.
+ """
+ assert ClangdLanguageServer._determine_log_level(line) == logging.getLevelNamesMapping()[expected]
+
+ def test_log_record_info_payload_mentions_fno_exceptions(self) -> None:
+ line = """clang++ -fno-exceptions -c no_exceptions.cpp"""
+ # Basic classifier incorrectly detects line as an error log.
+ assert SolidLanguageServer._determine_log_level(line) == logging.ERROR
+ # Clangd classifier correctly detects line as an info log.
+ assert ClangdLanguageServer._determine_log_level(line) == logging.INFO
+
+ def test_log_record_info_payload_without_prefix(self) -> None:
+ # Multi-line records: first line carries the I[...] prefix, subsequent
+ # lines do not. Treat lack of prefix as continuation.
+ assert ClangdLanguageServer._determine_log_level("[/home/user/project/build]") == logging.INFO
+
+ def test_log_record_info_payload_empty(self) -> None:
+ assert ClangdLanguageServer._determine_log_level("") == logging.INFO
From 3a8393bcaff2c2f54053733e3cd9d0d611c16cc3 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Tue, 28 Apr 2026 09:26:26 +0200
Subject: [PATCH 02/32] Tests: removed some unnecessary hook tests
---
test/serena/test_hooks.py | 23 -----------------------
1 file changed, 23 deletions(-)
diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py
index 25408f0d..24af5523 100644
--- a/test/serena/test_hooks.py
+++ b/test/serena/test_hooks.py
@@ -13,7 +13,6 @@ from serena.hooks import (
PreToolUseAutoApproveSerenaHook,
PreToolUseRemindAboutSerenaHook,
SessionEndCleanupHook,
- SessionStartActivateProjectHook,
hook_commands,
)
@@ -479,19 +478,6 @@ class TestPreToolUseAutoApproveSerenaHook:
assert capsys.readouterr().out == ""
-class TestSessionStartActivateProjectHook:
- def test_outputs_activation_message(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
- stdin_data = {"session_id": "s1"}
- with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
- SessionStartActivateProjectHook(HookClient.VSCODE).execute()
-
- output = capsys.readouterr().out.strip()
- result = json.loads(output)
- context = result["hookSpecificOutput"]["additionalContext"]
- assert "Activate" in context
- assert "Serena Instructions Manual" in context
-
-
class TestSessionEndCleanupHook:
def test_removes_session_dir(self, tmp_path: Path):
session_dir = tmp_path / "hook_data" / "cleanup-session"
@@ -515,15 +501,6 @@ class TestSessionEndCleanupHook:
class TestHookCli:
"""Tests for the Click CLI entry point (serena-hooks)."""
- def test_activate_command(self, tmp_path: Path):
- stdin_json = json.dumps({"session_id": "cli-test"})
- runner = CliRunner()
- with patch("serena.hooks.serena_home_dir", str(tmp_path)):
- result = runner.invoke(hook_commands, ["activate", "--client", "vscode"], input=stdin_json)
- assert result.exit_code == 0
- output = json.loads(result.output)
- assert "Activate" in output["hookSpecificOutput"]["additionalContext"]
-
def test_cleanup_command(self, tmp_path: Path):
session_dir = tmp_path / "hook_data" / "cli-cleanup"
session_dir.mkdir(parents=True)
From a3565ef60ebde175e1f120fd67775792ed52f77b Mon Sep 17 00:00:00 2001
From: a-simeshin <89605031+a-simeshin@users.noreply.github.com>
Date: Tue, 28 Apr 2026 10:31:53 +0300
Subject: [PATCH 03/32] Merge pull request #1415 from
a-simeshin/feat/jdtls-path-override
Allows setting paths for JDTLS and Lombok, thus allowing using Serena with JDTLS without forcing downloads
---
CHANGELOG.md | 1 +
docs/02-usage/050_configuration.md | 58 ++-
.../language_servers/eclipse_jdtls.py | 381 ++++++++++++++-
.../java/test_jdtls_path_resolution.py | 443 ++++++++++++++++++
4 files changed, 853 insertions(+), 30 deletions(-)
create mode 100644 test/solidlsp/java/test_jdtls_path_resolution.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6747ece8..5eadd611 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,7 @@ Status of the `main` branch. Changes prior to the next official version change w
- Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356
- 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
+ - 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
* Dashboard:
- Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md
index 37fb55c0..d0b4c342 100644
--- a/docs/02-usage/050_configuration.md
+++ b/docs/02-usage/050_configuration.md
@@ -504,28 +504,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_/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from . 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//lombok-.jar` or download from . |
+| `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:
diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py
index d90afd6d..eb6fbf02 100644
--- a/src/solidlsp/language_servers/eclipse_jdtls.py
+++ b/src/solidlsp/language_servers/eclipse_jdtls.py
@@ -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
@@ -42,28 +46,69 @@ INTELLICODE_ALLOWED_HOSTS = (
)
INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
+# Mapping from Serena's platform identifiers to upstream JDTLS config_ 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_/).
+ 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//lombok-.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 +122,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:
@@ -154,7 +208,29 @@ 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")
@@ -346,11 +422,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_/``)
+ :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_/, features/) at the root. "
+ f"If you pointed at a vscode-java extension, use '/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//lombok-.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_...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_.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_/`` 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 +1140,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 +1159,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 +1229,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 ...")
diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py
new file mode 100644
index 00000000..2ce735d0
--- /dev/null
+++ b/test/solidlsp/java/test_jdtls_path_resolution.py
@@ -0,0 +1,443 @@
+"""
+Unit tests for the offline (upstream) JDTLS resolution helpers in
+``solidlsp.language_servers.eclipse_jdtls``.
+
+These tests cover only the path/version/validation logic; they do not start
+JDTLS and do not require Java to be installed. Subprocess interactions are
+mocked.
+"""
+
+from __future__ import annotations
+
+import platform
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+_JAVA_EXE_NAME = "java.exe" if platform.system() == "Windows" else "java"
+
+from solidlsp.language_servers.eclipse_jdtls import (
+ JDTLS_CONFIG_DIR_BY_PLATFORM,
+ JDTLS_MIN_JDK_VERSION,
+ EclipseJDTLS,
+)
+from solidlsp.ls_exceptions import SolidLSPException
+from solidlsp.settings import SolidLSPSettings
+
+
+@pytest.fixture
+def custom_settings() -> SolidLSPSettings.CustomLSSettings:
+ """Empty CustomLSSettings instance for tests that don't need any keys set."""
+ return SolidLSPSettings.CustomLSSettings({})
+
+
+def _make_fake_jdtls_install(
+ root: Path, *, with_launcher: bool = True, with_native_fragments: bool = True, with_configs: bool = True
+) -> Path:
+ """
+ Builds a minimal fake upstream JDTLS layout under ``root``: ``plugins/`` with
+ a main equinox launcher jar (and optionally native fragments) and
+ ``config_/`` directories. Returns ``root``.
+ """
+ plugins = root / "plugins"
+ plugins.mkdir(parents=True, exist_ok=True)
+ if with_launcher:
+ (plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar").touch()
+ if with_native_fragments:
+ (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch()
+ (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch()
+ (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch()
+ if with_configs:
+ for config_name in set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()):
+ (root / config_name).mkdir(exist_ok=True)
+ return root
+
+
+# ----------------------------------------------------------------------------
+# _resolve_launcher_jar
+# ----------------------------------------------------------------------------
+
+
+class TestResolveLauncherJar:
+ def test_picks_main_launcher_excluding_native_fragments(self, tmp_path: Path) -> None:
+ plugins = tmp_path / "plugins"
+ plugins.mkdir()
+ main_jar = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar"
+ main_jar.touch()
+ # native fragments should NOT be picked
+ (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch()
+ (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch()
+ (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch()
+
+ result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins)
+ assert result == main_jar
+
+ def test_picks_highest_version_when_multiple_main_launchers(self, tmp_path: Path) -> None:
+ plugins = tmp_path / "plugins"
+ plugins.mkdir()
+ (plugins / "org.eclipse.equinox.launcher_1.6.500.v20240916-1115.jar").touch()
+ newer = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar"
+ newer.touch()
+ (plugins / "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar").touch()
+
+ result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins)
+ assert result == newer, "Expected the lexicographically highest launcher version to be selected"
+
+ def test_raises_when_no_launcher_present(self, tmp_path: Path) -> None:
+ plugins = tmp_path / "plugins"
+ plugins.mkdir()
+ # only native fragments, no main launcher
+ (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch()
+
+ with pytest.raises(SolidLSPException, match="No main Equinox launcher jar found"):
+ EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins)
+
+
+# ----------------------------------------------------------------------------
+# _resolve_config_dir
+# ----------------------------------------------------------------------------
+
+
+class TestResolveConfigDir:
+ @pytest.mark.parametrize(
+ "platform_id,expected_dir",
+ [
+ ("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"),
+ ],
+ )
+ def test_maps_platform_to_correct_config_dir(self, tmp_path: Path, platform_id: str, expected_dir: str) -> None:
+ _make_fake_jdtls_install(tmp_path)
+ with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid:
+ mock_get_pid.return_value.value = platform_id
+ result = EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path)
+ assert result.name == expected_dir
+ assert result.is_dir()
+
+ def test_raises_when_config_dir_missing(self, tmp_path: Path) -> None:
+ # plugins/ exists but no config_/ for current OS
+ (tmp_path / "plugins").mkdir()
+ with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid:
+ mock_get_pid.return_value.value = "linux-x64"
+ with pytest.raises(SolidLSPException, match="Config directory .* not found"):
+ EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path)
+
+ def test_raises_for_unsupported_platform(self, tmp_path: Path) -> None:
+ _make_fake_jdtls_install(tmp_path)
+ with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid:
+ mock_get_pid.return_value.value = "freebsd-riscv64"
+ with pytest.raises(SolidLSPException, match="Unsupported platform"):
+ EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path)
+
+
+# ----------------------------------------------------------------------------
+# _inspect_java
+# ----------------------------------------------------------------------------
+
+
+class TestInspectJava:
+ @staticmethod
+ def _fake_subprocess_result(stderr: str, stdout: str = "", returncode: int = 0):
+ """Build a minimal CompletedProcess-like object."""
+
+ class _Result:
+ def __init__(self) -> None:
+ self.returncode = returncode
+ self.stdout = stdout
+ self.stderr = stderr
+
+ return _Result()
+
+ def test_parses_temurin_21_output(self) -> None:
+ # Real Temurin 21.0.10 output (java -XshowSettings:properties -version writes to stderr)
+ stderr = (
+ "Property settings:\n"
+ " java.home = /Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home\n"
+ " java.version = 21.0.10\n"
+ 'openjdk version "21.0.10" 2026-01-20 LTS\n'
+ "OpenJDK Runtime Environment Temurin-21.0.10+7 (build 21.0.10+7-LTS)\n"
+ )
+ with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)):
+ home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java")
+ assert home == "/Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home"
+ assert major == 21
+
+ def test_parses_openjdk_17_output(self) -> None:
+ stderr = 'Property settings:\n java.home = /usr/lib/jvm/java-17-openjdk-amd64\nopenjdk version "17.0.5" 2023-10-17\n'
+ with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)):
+ home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java")
+ assert home == "/usr/lib/jvm/java-17-openjdk-amd64"
+ assert major == 17
+
+ def test_raises_when_java_home_property_missing(self) -> None:
+ stderr = 'java version "21.0.0"\n'
+ with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)):
+ with pytest.raises(SolidLSPException, match="Could not parse java.home"):
+ EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava")
+
+ def test_raises_when_version_string_missing(self) -> None:
+ stderr = " java.home = /opt/jdk\n"
+ with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)):
+ with pytest.raises(SolidLSPException, match="Could not parse Java version"):
+ EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava")
+
+ def test_raises_on_subprocess_error(self) -> None:
+ with patch("subprocess.run", side_effect=OSError("permission denied")):
+ with pytest.raises(SolidLSPException, match="Failed to run"):
+ EclipseJDTLS.DependencyProvider._inspect_java("/nonexistent/java")
+
+
+# ----------------------------------------------------------------------------
+# _resolve_system_jdk
+# ----------------------------------------------------------------------------
+
+
+class TestResolveSystemJdk:
+ """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH."""
+
+ @staticmethod
+ def _make_jdk_layout(root: Path, java_exe_name: str = _JAVA_EXE_NAME) -> Path:
+ bin_dir = root / "bin"
+ bin_dir.mkdir(parents=True, exist_ok=True)
+ (bin_dir / java_exe_name).touch()
+ return root
+
+ def _patch_inspect_java(self, real_home: str, major: int):
+ return patch.object(EclipseJDTLS.DependencyProvider, "_inspect_java", return_value=(real_home, major))
+
+ def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ explicit_jdk = self._make_jdk_layout(tmp_path / "explicit-jdk")
+ monkeypatch.delenv("JAVA_HOME", raising=False)
+
+ with self._patch_inspect_java(str(explicit_jdk), 21):
+ settings = SolidLSPSettings.CustomLSSettings({"java_home": str(explicit_jdk)})
+ home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings)
+
+ assert Path(home) == explicit_jdk
+ assert Path(java_path).name == _JAVA_EXE_NAME
+
+ def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ env_jdk = self._make_jdk_layout(tmp_path / "env-jdk")
+ monkeypatch.setenv("JAVA_HOME", str(env_jdk))
+
+ with self._patch_inspect_java(str(env_jdk), 21):
+ settings = SolidLSPSettings.CustomLSSettings({})
+ home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings)
+
+ assert Path(home) == env_jdk
+
+ def test_falls_back_to_which_java(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ path_jdk = self._make_jdk_layout(tmp_path / "path-jdk")
+ java_path = path_jdk / "bin" / _JAVA_EXE_NAME
+ monkeypatch.delenv("JAVA_HOME", raising=False)
+
+ with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)):
+ with self._patch_inspect_java(str(path_jdk), 21):
+ home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings)
+
+ assert Path(home) == path_jdk
+
+ def test_raises_when_no_java_anywhere(
+ self, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ monkeypatch.delenv("JAVA_HOME", raising=False)
+ with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=None):
+ with pytest.raises(SolidLSPException, match="Could not locate a Java installation"):
+ EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings)
+
+ def test_raises_for_invalid_explicit_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ # path exists but no bin/java
+ broken = tmp_path / "broken-jdk"
+ broken.mkdir()
+ monkeypatch.delenv("JAVA_HOME", raising=False)
+
+ settings = SolidLSPSettings.CustomLSSettings({"java_home": str(broken)})
+ with pytest.raises(SolidLSPException, match=r"java_home=.*invalid"):
+ EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings)
+
+ def test_raises_for_too_old_jdk(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ old_jdk = self._make_jdk_layout(tmp_path / "jdk-17")
+ monkeypatch.setenv("JAVA_HOME", str(old_jdk))
+
+ with self._patch_inspect_java(str(old_jdk), 17):
+ settings = SolidLSPSettings.CustomLSSettings({})
+ with pytest.raises(SolidLSPException, match=f"requires JDK {JDTLS_MIN_JDK_VERSION}"):
+ EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings)
+
+ def test_uses_real_jdk_home_when_locator_is_macos_stub(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ """
+ Simulates the macOS /usr/bin/java stub: the locator points to /usr/bin/java but the JVM's
+ java.home is the actual JDK. The resolver should report the real JDK home.
+ """
+ real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21")
+ macos_stub = tmp_path / "fake-usr" / "bin" / _JAVA_EXE_NAME
+ macos_stub.parent.mkdir(parents=True)
+ macos_stub.touch()
+ monkeypatch.delenv("JAVA_HOME", raising=False)
+
+ with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(macos_stub)):
+ with self._patch_inspect_java(str(real_jdk), 21):
+ home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings)
+
+ # the resolver must trust the JVM's reported java.home, not parent.parent of the stub
+ assert Path(home) == real_jdk
+ # and prefer the real-home java executable over the stub
+ assert Path(java_path) == real_jdk / "bin" / _JAVA_EXE_NAME
+
+
+# ----------------------------------------------------------------------------
+# _setup_from_existing_install
+# ----------------------------------------------------------------------------
+
+
+class TestSetupFromExistingInstall:
+ @pytest.fixture
+ def lombok_jar(self, tmp_path: Path) -> Path:
+ jar = tmp_path / "lombok-1.18.44.jar"
+ jar.touch()
+ return jar
+
+ @pytest.fixture
+ def jdtls_root(self, tmp_path: Path) -> Path:
+ return _make_fake_jdtls_install(tmp_path / "jdtls")
+
+ def _fake_jdk(self, tmp_path: Path) -> Path:
+ jdk = tmp_path / "jdk-21"
+ (jdk / "bin").mkdir(parents=True)
+ (jdk / "bin" / "java").touch()
+ return jdk
+
+ def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode(
+ self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ jdk = self._fake_jdk(tmp_path)
+ with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_pid:
+ mock_pid.return_value.value = "linux-x64"
+ with patch.object(EclipseJDTLS.DependencyProvider, "_resolve_system_jdk", return_value=(str(jdk), str(jdk / "bin" / "java"))):
+ result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings)
+
+ assert result.gradle_path is None
+ assert result.intellicode_jar_path is None
+ assert result.intellisense_members_path is None
+ assert result.lombok_jar_path == str(lombok_jar)
+ assert result.jre_home_path == str(jdk)
+ assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_")
+ assert Path(result.jdtls_readonly_config_path).name == "config_linux"
+
+ def test_raises_for_nonexistent_jdtls_path(
+ self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ with pytest.raises(SolidLSPException, match="not an existing directory"):
+ EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(tmp_path / "does-not-exist"), str(lombok_jar), custom_settings)
+
+ def test_raises_when_plugins_dir_missing(
+ self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ empty_root = tmp_path / "no-plugins"
+ empty_root.mkdir()
+ with pytest.raises(SolidLSPException, match="'plugins/' directory not found"):
+ EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(empty_root), str(lombok_jar), custom_settings)
+
+ def test_raises_when_lombok_jar_missing(
+ self, jdtls_root: Path, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings
+ ) -> None:
+ with pytest.raises(SolidLSPException, match="lombok_path .* does not exist"):
+ EclipseJDTLS.DependencyProvider._setup_from_existing_install(
+ str(jdtls_root), str(tmp_path / "no-such-lombok.jar"), custom_settings
+ )
+
+
+# ----------------------------------------------------------------------------
+# _setup_runtime_dependencies (mode-switch logic)
+# ----------------------------------------------------------------------------
+
+
+class TestSetupRuntimeDependenciesModeSwitch:
+ """Verifies the activation trigger: both jdtls_path and lombok_path => upstream mode."""
+
+ def test_both_set_invokes_upstream_mode(self) -> None:
+ settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x", "lombok_path": "/y"})
+ with patch.object(EclipseJDTLS.DependencyProvider, "_setup_from_existing_install", return_value="upstream-result") as mock_upstream:
+ result = EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings)
+ mock_upstream.assert_called_once_with("/x", "/y", settings)
+ assert result == "upstream-result"
+
+ def test_only_jdtls_path_set_raises(self) -> None:
+ settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x"})
+ with pytest.raises(SolidLSPException, match="must be set together"):
+ EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings)
+
+ def test_only_lombok_path_set_raises(self) -> None:
+ settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"})
+ with pytest.raises(SolidLSPException, match="must be set together"):
+ EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings)
+
+
+# ----------------------------------------------------------------------------
+# _compute_workspace_hash
+# ----------------------------------------------------------------------------
+
+
+class TestComputeWorkspaceHash:
+ """
+ Backwards-compatibility contract: users on the default route (no ``jdtls_path``)
+ must receive the *exact* same hash format that existed before upstream-JDTLS support
+ (i.e. ``md5(repository_root_path)``), so existing JDTLS workspaces and project caches
+ are reused without a one-time reindex after upgrading Serena. Upstream mode mixes the
+ launcher path into the hash to isolate it from the default workspace.
+ """
+
+ REPO = "/home/me/projects/widgets"
+ DEFAULT_LAUNCHER = "/srv/serena/static/eclipse-jdtls-1.49.0/plugins/org.eclipse.equinox.launcher_1.7.100.jar"
+ UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar"
+
+ def test_default_mode_matches_pre_upstream_format(self) -> None:
+ """The hash MUST equal md5(repository_root_path) — the format produced by PR #1214."""
+ import hashlib
+
+ expected = hashlib.md5(self.REPO.encode()).hexdigest()
+ result = EclipseJDTLS.DependencyProvider._compute_workspace_hash(
+ self.REPO, self.DEFAULT_LAUNCHER, SolidLSPSettings.CustomLSSettings({})
+ )
+ assert result == expected
+
+ def test_default_mode_ignores_launcher_path(self) -> None:
+ """Default-mode hash must not depend on the launcher jar path (so default users keep cache)."""
+ empty_settings = SolidLSPSettings.CustomLSSettings({})
+ h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, empty_settings)
+ h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, empty_settings)
+ assert h1 == h2
+
+ def test_upstream_mode_includes_launcher_path(self) -> None:
+ """When jdtls_path is set, different launcher paths must produce different hashes."""
+ settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"})
+ h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings)
+ h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, settings)
+ assert h1 != h2
+
+ def test_upstream_and_default_produce_different_hashes(self) -> None:
+ """Same repo + same launcher path but different mode → different ws_dir (isolation)."""
+ default_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash(
+ self.REPO, self.UPSTREAM_LAUNCHER, SolidLSPSettings.CustomLSSettings({})
+ )
+ upstream_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash(
+ self.REPO,
+ self.UPSTREAM_LAUNCHER,
+ SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}),
+ )
+ assert default_h != upstream_h
+
+ def test_different_repo_paths_produce_different_hashes(self) -> None:
+ empty_settings = SolidLSPSettings.CustomLSSettings({})
+ h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/a/repo", self.DEFAULT_LAUNCHER, empty_settings)
+ h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/b/repo", self.DEFAULT_LAUNCHER, empty_settings)
+ assert h1 != h2
From 5dd1a410000142ce0d9f1acbbbe9dd9dd9d2fb13 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Tue, 28 Apr 2026 09:33:13 +0200
Subject: [PATCH 04/32] Changelog [ci skip]
---
CHANGELOG.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5eadd611..96330eaa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
+* 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:
@@ -57,7 +61,6 @@ Status of the `main` branch. Changes prior to the next official version change w
- Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356
- 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
- - 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
* Dashboard:
- Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
From df0f476614a0c7a1242123956f2766dd771e26df Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 25 Mar 2026 11:03:14 +0100
Subject: [PATCH 05/32] LS: mark LS that support implementation request
---
src/solidlsp/language_servers/csharp_language_server.py | 4 ++++
src/solidlsp/language_servers/eclipse_jdtls.py | 4 ++++
src/solidlsp/language_servers/gopls.py | 4 ++++
src/solidlsp/language_servers/rust_analyzer.py | 4 ++++
src/solidlsp/language_servers/typescript_language_server.py | 4 ++++
src/solidlsp/ls_config.py | 6 ++++++
6 files changed, 26 insertions(+)
diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py
index 59b1fc9e..51bed54e 100644
--- a/src/solidlsp/language_servers/csharp_language_server.py
+++ b/src/solidlsp/language_servers/csharp_language_server.py
@@ -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.
diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py
index eb6fbf02..50deb1b0 100644
--- a/src/solidlsp/language_servers/eclipse_jdtls.py
+++ b/src/solidlsp/language_servers/eclipse_jdtls.py
@@ -152,6 +152,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.
diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py
index c855b836..3c098178 100644
--- a/src/solidlsp/language_servers/gopls.py
+++ b/src/solidlsp/language_servers/gopls.py
@@ -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:
diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py
index f0ff00e6..2e222b5a 100644
--- a/src/solidlsp/language_servers/rust_analyzer.py
+++ b/src/solidlsp/language_servers/rust_analyzer.py
@@ -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."""
diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py
index 5b7ec4fe..32e9ac22 100644
--- a/src/solidlsp/language_servers/typescript_language_server.py
+++ b/src/solidlsp/language_servers/typescript_language_server.py
@@ -67,6 +67,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
diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py
index eb527dc3..1eb1295a 100644
--- a/src/solidlsp/ls_config.py
+++ b/src/solidlsp/ls_config.py
@@ -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:
From 6dfda8c99ee12153d64d6769e1a73c085dfad15d Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 29 Apr 2026 12:14:12 +0200
Subject: [PATCH 06/32] Fix: Skip broken links in _iter_gitignore_files #1435
---
src/serena/util/file_system.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py
index 8240653d..6ec1582e 100644
--- a/src/serena/util/file_system.py
+++ b/src/serena/util/file_system.py
@@ -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)
From c79e9d65f2a4531ddeee83ab53232b01ad713e79 Mon Sep 17 00:00:00 2001
From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com>
Date: Wed, 29 Apr 2026 14:33:02 +0200
Subject: [PATCH 07/32] Update header text in README.md [ci skip]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 0c819fb4..58c04aea 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
- Serena is the IDE for your coding agent.
+ The IDE for Your Coding Agent
From 68300e94ac076c725ec4b56b4ffe171cffabd58b Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 16:23:10 +0200
Subject: [PATCH 08/32] RestartLanguageServerTool: Improve docstring
---
src/serena/tools/symbol_tools.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index a19a99b9..3881eb23 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -19,7 +19,7 @@ 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.
From 842d5b93748afbbb663298e3b97860d0cfd5b11b Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 16:23:40 +0200
Subject: [PATCH 09/32] Mode jetbrains: Improve prompt, fixing typo [skip ci]
---
src/serena/resources/config/internal_modes/jetbrains.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml
index 34baf6ce..47606f72 100644
--- a/src/serena/resources/config/internal_modes/jetbrains.yml
+++ b/src/serena/resources/config/internal_modes/jetbrains.yml
@@ -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
From 28c2866bf4970e5cee9f9dd9e1f07fc3e59d8f4a Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 16:31:21 +0200
Subject: [PATCH 10/32] SolidLanguageServer: Improve docstrings [skip ci]
---
src/solidlsp/ls.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py
index 6ae69a39..5f3d7d43 100644
--- a/src/solidlsp/ls.py
+++ b/src/solidlsp/ls.py
@@ -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
@@ -729,10 +733,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
From 37c837ffd337d054bcada6879c94008781db5a0d Mon Sep 17 00:00:00 2001
From: Robin Jehn <71655429+RobinJehn@users.noreply.github.com>
Date: Wed, 29 Apr 2026 18:03:41 +0200
Subject: [PATCH 11/32] Send languageId 'typescriptreact' / 'javascriptreact'
for .tsx / .jsx files (#1436)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Fix: send languageId 'typescriptreact' / 'javascriptreact' for .tsx / .jsx
Without this, .tsx files are opened with languageId 'typescript', so
tsserver parses JSX as syntax errors and recovers by truncating the
enclosing symbol's range at the first multi-line JSX expression. The
resulting symptoms:
- find_symbol(name, include_body=True) on a React component returns a
body that ends mid-component, hiding everything below.
- The same component shows up as multiple Method-kind symbols (one per
call site) plus a separately-tracked Constant for the definition,
because tsserver mistakes JSX-tag tokens for member access.
Reproducer (before fix, real-world TSX file):
SidebarPDF range = [11:1-25:207] (truncated at first JSX, line 25)
After fix:
SidebarPDF range = [11:1-281:2] (full body)
The base SolidLanguageServer._get_language_id_for_file is explicitly
designed for this — its docstring says "Override in subclasses to
return file-specific language IDs." This change just exercises that
hook for the React file extensions.
Adds a regression test + jsx_component.tsx fixture covering the same
arrow-function-with-multi-line-JSX-ternary pattern that triggers the
truncation in real codebases (see oraios/serena#1029 for an earlier
report whose root cause was not identified).
---
.../typescript_language_server.py | 9 ++++
.../typescript/test_repo/jsx_component.tsx | 44 +++++++++++++++++++
.../repos/typescript/test_repo/tsconfig.json | 5 ++-
.../typescript/test_typescript_basic.py | 31 +++++++++++++
4 files changed, 87 insertions(+), 2 deletions(-)
create mode 100644 test/resources/repos/typescript/test_repo/jsx_component.tsx
diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py
index 5b7ec4fe..ddf3f605 100644
--- a/src/solidlsp/language_servers/typescript_language_server.py
+++ b/src/solidlsp/language_servers/typescript_language_server.py
@@ -217,6 +217,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.
diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx
new file mode 100644
index 00000000..c8dea77d
--- /dev/null
+++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx
@@ -0,0 +1,44 @@
+// Regression fixture: mirrors the real-world pattern that caused
+// languageId=typescript (instead of typescriptreact) to truncate symbol
+// ranges at the first multi-line JSX expression.
+
+import * as React from "react"
+
+type SectionProps = { title: string; emphasised?: boolean }
+
+function Section({ title, emphasised }: SectionProps) {
+ return emphasised ? (
+ {title}
+ ) : (
+ {title}
+ )
+}
+
+export function JsxComponent({ heading, items }: { heading: string; items: string[] }) {
+ const renderHeader = (title: string) =>
+ title.length > 10 ? (
+
+ ) : (
+
+ )
+
+ // The truncation bug used to cut JsxComponent's range right around here,
+ // hiding everything below from find_symbol. Keep this comment so the
+ // regression test can assert the function body extends past it.
+ const renderItem = (item: string, idx: number) => (
+
+ {item}
+
+ )
+
+ return (
+
+ {renderHeader(heading)}
+
+
+ )
+}
+
+export function trailingHelper(): string {
+ return "this symbol is invisible to find_symbol when JsxComponent's range is truncated"
+}
diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json
index ba0514f5..7267a686 100644
--- a/test/resources/repos/typescript/test_repo/tsconfig.json
+++ b/test/resources/repos/typescript/test_repo/tsconfig.json
@@ -5,7 +5,8 @@
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
- "skipLibCheck": true
+ "skipLibCheck": true,
+ "jsx": "react"
},
- "include": ["**/*.ts"]
+ "include": ["**/*.ts", "**/*.tsx"]
}
diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py
index ba86ba04..2caf6b11 100644
--- a/test/solidlsp/typescript/test_typescript_basic.py
+++ b/test/solidlsp/typescript/test_typescript_basic.py
@@ -33,6 +33,37 @@ class TestTypescriptLanguageServer:
"index.ts should reference helperFunction (tried all positions in selectionRange)"
)
+ @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
+ def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None:
+ # Regression: when the language id is sent as "typescript" instead of
+ # "typescriptreact" for .tsx files, tsserver parses JSX as syntax
+ # errors and recovers by truncating the enclosing symbol's range at
+ # the first multi-line JSX expression. find_symbol then returns a
+ # body that ends mid-component and hides everything below.
+ file_path = "jsx_component.tsx"
+ roots = language_server.request_document_symbols(file_path).root_symbols
+
+ jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None)
+ assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx"
+
+ end_line = jsx_component["location"]["range"]["end"]["line"]
+ # JsxComponent's body extends to line 38 (0-based 37) in the fixture;
+ # the truncation bug cut it at the first multi-line JSX (~line 21).
+ # Use a generous lower bound so the test survives small fixture edits
+ # that don't affect the regression behaviour we care about.
+ assert end_line >= 30, (
+ f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); "
+ f"expected end at or past line 31 (1-based). "
+ f"This indicates the .tsx file was opened with the wrong languageId."
+ )
+
+ # The trailing helper must be visible as a top-level symbol — it lives
+ # past the truncation point and disappears entirely when the bug is
+ # active because tsserver stops emitting symbols after the parse error.
+ assert any(s.get("name") == "trailingHelper" for s in roots), (
+ "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression."
+ )
+
@pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
all_symbols = request_all_symbols(language_server)
From dfe1c3d7d16e8a301b2d3df6564b9621014db86b Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 17:46:50 +0200
Subject: [PATCH 12/32] Revamp mode selection
* Breaking change: 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.
This allows `base_modes` to be fully applied for the computation of the base toolset
as they are no longer dynamic.
* Serena's default configuration now uses `interactive` and `editing` as `base_modes`
instead of as `default_modes`.
---
CHANGELOG.md | 6 +++
docs/02-usage/050_configuration.md | 36 ++++++--------
src/serena/agent.py | 49 +++++++++++++------
src/serena/cli.py | 36 ++++++++++----
src/serena/config/serena_config.py | 26 +++++++---
src/serena/mcp.py | 9 ++--
src/serena/resources/project.template.yml | 22 ++++-----
.../resources/serena_config.template.yml | 22 +++++----
8 files changed, 129 insertions(+), 77 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96330eaa..158a82b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
+* 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`.
+
* 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
diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md
index d0b4c342..6efedcfc 100644
--- a/docs/02-usage/050_configuration.md
+++ b/docs/02-usage/050_configuration.md
@@ -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 `; 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`).
diff --git a/src/serena/agent.py b/src/serena/agent.py
index 2daf0b4e..db67577d 100644
--- a/src/serena/agent.py
+++ b/src/serena/agent.py
@@ -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:
@@ -986,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)}")
diff --git a/src/serena/cli.py b/src/serena/cli.py
index e7407545..8591d712 100644
--- a/src/serena/cli.py
+++ b/src/serena/cli.py
@@ -23,6 +23,7 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
LanguageBackend,
ModeSelectionDefinition,
+ ModeSelectionDefinitionWithAddedModes,
ProjectConfig,
RegisteredProject,
SerenaConfig,
@@ -37,7 +38,6 @@ from serena.constants import (
)
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
@@ -46,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:
@@ -228,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]),
@@ -300,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,
@@ -346,11 +358,15 @@ class TopLevelCommands(AutoRegisteringGroup):
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,
diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py
index 329e0e86..bade9a28 100644
--- a/src/serena/config/serena_config.py
+++ b/src/serena/config/serena_config.py
@@ -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)
@@ -471,6 +483,9 @@ class ProjectConfig(SharedConfig):
excluded_tools = data["excluded_tools"] or []
included_optional_tools = data["included_optional_tools"] 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,
@@ -486,7 +501,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", {}),
@@ -677,7 +692,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.
@@ -731,7 +746,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
"""
diff --git a/src/serena/mcp.py b/src/serena/mcp.py
index 13b9a00c..130f432a 100644
--- a/src/serena/mcp.py
+++ b/src/serena/mcp.py
@@ -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:
diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml
index ada6fd67..fb1ddbd7 100644
--- a/src/serena/resources/project.template.yml
+++ b/src/serena/resources/project.template.yml
@@ -115,21 +115,21 @@ 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 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: ""
diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml
index 18974358..40c668cb 100644
--- a/src/serena/resources/serena_config.template.yml
+++ b/src/serena/resources/serena_config.template.yml
@@ -112,19 +112,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.
From 1184a9b5bdba56ee27d4021e5221bce2a6100e87 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 17:52:27 +0200
Subject: [PATCH 13/32] Remove outdated list of tools, add link to tool list
instead
---
src/serena/resources/project.template.yml | 43 ++---------------------
1 file changed, 3 insertions(+), 40 deletions(-)
diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml
index fb1ddbd7..b6ae1f2e 100644
--- a/src/serena/resources/project.template.yml
+++ b/src/serena/resources/project.template.yml
@@ -65,54 +65,17 @@ 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 that are to be activated by default, overriding the setting in the global configuration.
From b9996cd6630e2d18a98df8ab8e0b5cee20f9c89c Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 17:53:57 +0200
Subject: [PATCH 14/32] Update language list (based on
scripts/print_language_list.py)
---
src/serena/resources/project.template.yml | 22 ++++++++++++----------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml
index b6ae1f2e..cfa204ec 100644
--- a/src/serena/resources/project.template.yml
+++ b/src/serena/resources/project.template.yml
@@ -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.)
From 4991b025a5d9f70966bf8dc1f9ac92c4372cdd10 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 17:57:58 +0200
Subject: [PATCH 15/32] Update project.yml
---
.serena/project.yml | 74 +++++++++++++++------------------------------
1 file changed, 25 insertions(+), 49 deletions(-)
diff --git a/.serena/project.yml b/.serena/project.yml
index 84b6fcf8..c9fd4467 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -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.)
@@ -44,49 +47,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 +87,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 +135,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:
From dc491db2d4342474ec8ff4a941f006f8ba9d04ec Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 19:12:34 +0200
Subject: [PATCH 16/32] Improve comment on web_dashboard_open_on_launch
---
src/serena/resources/serena_config.template.yml | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml
index 40c668cb..93036b96 100644
--- a/src/serena/resources/serena_config.template.yml
+++ b/src/serena/resources/serena_config.template.yml
@@ -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).
From c55e0c900e9b1249ad0bd682fe2dc7407a6e547b Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 25 Mar 2026 11:03:14 +0100
Subject: [PATCH 17/32] Diagnostics, implementation and definition tools (WIP)
---
scripts/demo_diagnostics.py | 170 +++++
scripts/demo_find_defining_symbol.py | 128 ++++
scripts/demo_find_implementing_symbol.py | 76 +++
src/serena/code_editor.py | 53 ++
src/serena/symbol.py | 261 +++++++-
src/serena/tools/file_tools.py | 55 +-
src/serena/tools/symbol_tools.py | 471 +++++++++++++-
src/serena/tools/tools_base.py | 200 +++++-
src/solidlsp/ls.py | 275 +++++++-
src/solidlsp/ls_process.py | 17 +
src/solidlsp/ls_types.py | 2 +-
test/conftest.py | 61 ++
test/diagnostics_cases.py | 230 +++++++
.../src/test_app/diagnostics_sample.clj | 9 +
.../repos/cpp/test_repo/compile_commands.json | 7 +-
.../cpp/test_repo/diagnostics_sample.cpp | 8 +
.../csharp/test_repo/DiagnosticsSample.cs | 19 +
.../repos/csharp/test_repo/Program.cs | 8 +-
.../test_repo/Services/ConsoleGreeter.cs | 10 +
.../csharp/test_repo/Services/IGreeter.cs | 7 +
.../fsharp/test_repo/DiagnosticsSample.fs | 11 +
.../repos/fsharp/test_repo/Formatter.fs | 8 +
.../repos/fsharp/test_repo/Program.fs | 10 +-
.../repos/fsharp/test_repo/Shapes.fs | 10 +
.../repos/fsharp/test_repo/TestProject.fsproj | 5 +-
.../repos/go/test_repo/diagnostics_sample.go | 11 +
test/resources/repos/go/test_repo/main.go | 12 +
.../main/java/test_repo/ConsoleGreeter.java | 8 +
.../java/test_repo/DiagnosticsSample.java | 13 +
.../src/main/java/test_repo/Greeter.java | 5 +
.../src/main/java/test_repo/Main.java | 2 +
.../gradle/wrapper/gradle-wrapper.properties | 7 +
test/resources/repos/kotlin/test_repo/gradlew | 251 ++++++++
.../repos/kotlin/test_repo/gradlew.bat | 94 +++
.../kotlin/test_repo/DiagnosticsSample.kt | 11 +
.../lean4/test_repo/DiagnosticsSample.lean | 6 +
test/resources/repos/lua/test_repo/main.lua | 11 +-
.../repos/lua/test_repo/src/animals.lua | 37 ++
.../php/test_repo/diagnostics_sample.php | 11 +
.../test_repo/diagnostics_sample.ps1 | 9 +
.../test_repo/test_repo/diagnostics_sample.py | 11 +
test/resources/repos/ruby/test_repo/lib.rb | 12 +
test/resources/repos/ruby/test_repo/main.rb | 2 +
.../rust/test_repo/src/diagnostics_sample.rs | 9 +
.../resources/repos/rust/test_repo/src/lib.rs | 14 +
.../repos/rust/test_repo/src/main.rs | 4 +-
.../typescript/test_repo/.serena/project.yml | 148 ++++-
.../test_repo/diagnostics_sample.ts | 9 +
.../repos/typescript/test_repo/formatters.ts | 9 +
.../repos/typescript/test_repo/index.ts | 5 +
.../__snapshots__/test_symbol_editing.ambr | 32 +-
test/serena/test_serena_agent.py | 596 +++++++++++++++++-
test/solidlsp/clojure/__init__.py | 4 +-
test/solidlsp/csharp/test_csharp_basic.py | 28 +
test/solidlsp/fortran/test_fortran_basic.py | 28 +
test/solidlsp/fsharp/test_fsharp_basic.py | 29 +-
test/solidlsp/go/test_go_basic.py | 28 +
test/solidlsp/java/test_java_basic.py | 34 +-
test/solidlsp/lua/test_lua_basic.py | 38 +-
test/solidlsp/python/test_python_basic.py | 21 +
test/solidlsp/rust/test_rust_basic.py | 28 +
test/solidlsp/solidity/test_solidity_basic.py | 28 +
test/solidlsp/test_defining_symbol_matrix.py | 139 ++++
test/solidlsp/test_diagnostics_matrix.py | 79 +++
test/solidlsp/test_ls_common.py | 4 +-
.../typescript/test_typescript_basic.py | 28 +
66 files changed, 3928 insertions(+), 38 deletions(-)
create mode 100644 scripts/demo_diagnostics.py
create mode 100644 scripts/demo_find_defining_symbol.py
create mode 100644 scripts/demo_find_implementing_symbol.py
create mode 100644 test/diagnostics_cases.py
create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj
create mode 100644 test/resources/repos/cpp/test_repo/diagnostics_sample.cpp
create mode 100644 test/resources/repos/csharp/test_repo/DiagnosticsSample.cs
create mode 100644 test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs
create mode 100644 test/resources/repos/csharp/test_repo/Services/IGreeter.cs
create mode 100644 test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs
create mode 100644 test/resources/repos/fsharp/test_repo/Formatter.fs
create mode 100644 test/resources/repos/fsharp/test_repo/Shapes.fs
create mode 100644 test/resources/repos/go/test_repo/diagnostics_sample.go
create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java
create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java
create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java
create mode 100644 test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties
create mode 100644 test/resources/repos/kotlin/test_repo/gradlew
create mode 100644 test/resources/repos/kotlin/test_repo/gradlew.bat
create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt
create mode 100644 test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
create mode 100644 test/resources/repos/lua/test_repo/src/animals.lua
create mode 100644 test/resources/repos/php/test_repo/diagnostics_sample.php
create mode 100644 test/resources/repos/powershell/test_repo/diagnostics_sample.ps1
create mode 100644 test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py
create mode 100644 test/resources/repos/rust/test_repo/src/diagnostics_sample.rs
create mode 100644 test/resources/repos/typescript/test_repo/diagnostics_sample.ts
create mode 100644 test/resources/repos/typescript/test_repo/formatters.ts
create mode 100644 test/solidlsp/test_defining_symbol_matrix.py
create mode 100644 test/solidlsp/test_diagnostics_matrix.py
diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py
new file mode 100644
index 00000000..7aa85361
--- /dev/null
+++ b/scripts/demo_diagnostics.py
@@ -0,0 +1,170 @@
+"""
+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, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool
+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__":
+ Tool._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:
+ agent.shutdown()
+ shutil.rmtree(temp_dir, ignore_errors=True)
diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py
new file mode 100644
index 00000000..f65b820c
--- /dev/null
+++ b/scripts/demo_find_defining_symbol.py
@@ -0,0 +1,128 @@
+"""
+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 FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool
+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 exact location
+ line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1)
+ find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool)
+ location_result = agent.execute_task(
+ lambda: find_by_location_tool.apply(
+ relative_path=relative_path,
+ line=line,
+ column=column,
+ include_info=True,
+ )
+ )
+
+ print_section("FindDefiningSymbolAtLocationTool")
+ location_symbol = json.loads(location_result)
+ pprint(location_symbol, width=200)
+
+ # resolving via regex over the full file
+ find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool)
+ 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 [location_symbol, 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()
diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py
new file mode 100644
index 00000000..a19d4b94
--- /dev/null
+++ b/scripts/demo_find_implementing_symbol.py
@@ -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()
diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py
index 2392d2ba..7d52f2b8 100644
--- a/src/serena/code_editor.py
+++ b/src/serena/code_editor.py
@@ -4,6 +4,7 @@ import os
from abc import ABC, abstractmethod
from collections.abc import Iterable, Iterator, Reversible
from contextlib import contextmanager
+from dataclasses import dataclass
from typing import Generic, TypeVar, cast
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
@@ -18,11 +19,18 @@ log = logging.getLogger(__name__)
TSymbol = TypeVar("TSymbol", bound=Symbol)
+@dataclass(frozen=True)
+class EditedFilePath:
+ before_relative_path: str
+ after_relative_path: str
+
+
class CodeEditor(Generic[TSymbol], ABC):
def __init__(self, project: Project) -> None:
self.project_root = project.project_root
self.encoding = project.project_config.encoding
self.newline = project.line_ending.newline_str
+ self._last_edited_file_paths: list[EditedFilePath] = []
class EditedFile(ABC):
def __init__(self, relative_path: str) -> None:
@@ -83,6 +91,20 @@ class CodeEditor(Generic[TSymbol], ABC):
with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f:
f.write(new_contents)
+ def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None:
+ unique_paths: list[EditedFilePath] = []
+ seen_keys: set[tuple[str, str]] = set()
+ for edited_file_path in edited_file_paths:
+ key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path)
+ if key in seen_keys:
+ continue
+ seen_keys.add(key)
+ unique_paths.append(edited_file_path)
+ self._last_edited_file_paths = unique_paths
+
+ def get_last_edited_file_paths(self) -> list[EditedFilePath]:
+ return list(self._last_edited_file_paths)
+
@abstractmethod
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol:
"""
@@ -114,6 +136,8 @@ class CodeEditor(Generic[TSymbol], ABC):
edited_file.delete_text_between_positions(start_pos, end_pos)
edited_file.insert_text_at_position(start_pos, body)
+ self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
+
@staticmethod
def _count_leading_newlines(text: Iterable) -> int:
cnt = 0
@@ -171,6 +195,8 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line, col), body)
+ self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
+
def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None:
"""
Inserts content before the symbol with the given name in the given file.
@@ -199,6 +225,8 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body)
+ self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
+
def insert_at_line(self, relative_path: str, line: int, content: str) -> None:
"""
Inserts content at the given line in the given file.
@@ -210,6 +238,8 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line, 0), content)
+ self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)])
+
def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None:
"""
Deletes lines in the given file.
@@ -226,6 +256,8 @@ class CodeEditor(Generic[TSymbol], ABC):
end_pos = PositionInFile(line=end_line_for_delete, col=end_col)
edited_file.delete_text_between_positions(start_pos, end_pos)
+ self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)])
+
def delete_symbol(self, name_path: str, relative_file_path: str) -> None:
"""
Deletes the symbol with the given name in the given file.
@@ -236,6 +268,8 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.delete_text_between_positions(start_pos, end_pos)
+ self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
+
@abstractmethod
def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str:
pass
@@ -292,6 +326,10 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
def apply(self) -> None:
pass
+ @abstractmethod
+ def get_edited_file_paths(self) -> list[EditedFilePath]:
+ pass
+
class EditOperationFileTextEdits(EditOperation):
def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]):
self._code_editor = code_editor
@@ -303,6 +341,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file)
edited_file.apply_text_edits(self._text_edits)
+ def get_edited_file_paths(self) -> list[EditedFilePath]:
+ return [EditedFilePath(self._relative_path, self._relative_path)]
+
class EditOperationRenameFile(EditOperation):
def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str):
self._code_editor = code_editor
@@ -314,6 +355,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path)
os.rename(old_abs_path, new_abs_path)
+ def get_edited_file_paths(self) -> list[EditedFilePath]:
+ return [EditedFilePath(self._old_relative_path, self._new_relative_path)]
+
def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]:
operations: list[LanguageServerCodeEditor.EditOperation] = []
@@ -343,6 +387,15 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
:return: number of edit operations applied
"""
operations = self._workspace_edit_to_edit_operations(workspace_edit)
+
+ # recording the affected files
+ edited_file_paths: list[EditedFilePath] = []
+ for operation in operations:
+ edited_file_paths.extend(operation.get_edited_file_paths())
+
+ self._set_last_edited_file_paths(edited_file_paths)
+
+ # applying the edit operations
for operation in operations:
operation.apply()
return len(operations)
diff --git a/src/serena/symbol.py b/src/serena/symbol.py
index 4a1b14cc..b44339e1 100644
--- a/src/serena/symbol.py
+++ b/src/serena/symbol.py
@@ -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
@@ -869,6 +870,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_defining_symbol(
+ self,
+ relative_file_path: str,
+ line: int,
+ column: int,
+ include_body: bool = False,
+ ) -> LanguageServerSymbol | None:
+ """
+ Find the symbol that defines 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
diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py
index 4e7268e0..42923a03 100644
--- a/src/serena/tools/file_tools.py
+++ b/src/serena/tools/file_tools.py
@@ -12,6 +12,7 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
+from serena.code_editor import EditedFilePath
from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional
from serena.util.file_system import scan_directory
from serena.util.text_utils import ContentReplacer, search_files
@@ -61,6 +62,11 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit):
:param content: the (appropriately encoded) content to write to the file
:return: a message indicating success or failure
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # validating the destination path
project_root = self.get_project_root()
abs_path = (Path(project_root) / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
@@ -72,12 +78,14 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit):
f"Cannot create file outside of the project directory, got {relative_path=}"
)
+ # 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 answer
+
+ return self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot)
class ListDirTool(Tool):
@@ -161,6 +169,8 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
Replaces content in a file (optionally using regular expressions).
"""
+ _CAPTURE_DIAGNOSTICS: bool = False
+
def apply(
self,
relative_path: str,
@@ -212,13 +222,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
Performs the replacement, with additional options not exposed in the tool.
This function can be used internally by other tools.
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the content replacement
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
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
@@ -241,9 +257,15 @@ 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
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the edit
code_editor = self.create_code_editor()
code_editor.delete_lines(relative_path, start_line, end_line)
- return SUCCESS_RESULT
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
@@ -268,13 +290,20 @@ 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
+
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the replacement
+ 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 self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
@@ -298,11 +327,19 @@ 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"
+
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the insertion
code_editor = self.create_code_editor()
code_editor.insert_at_line(relative_path, line, content)
- return SUCCESS_RESULT
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class SearchForPatternTool(Tool):
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index a19a99b9..2a44a14e 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -4,9 +4,12 @@ Language server-related tools
import copy
import os
+import re
from collections import Counter, defaultdict
from collections.abc import Sequence
+from typing import Any
+from serena.code_editor import EditedFilePath
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper
from serena.tools import (
SUCCESS_RESULT,
@@ -15,7 +18,72 @@ from serena.tools import (
ToolMarkerSymbolicRead,
)
from serena.tools.tools_base import ToolMarkerOptional
+from solidlsp import ls_types
from solidlsp.ls_types import SymbolKind
+from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
+
+FILE_LEVEL_DIAGNOSTIC_BUCKET = ""
+
+
+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}"
+
+
+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
+
+
+def _add_grouped_diagnostic(
+ grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
+ relative_path: str,
+ severity_name: str,
+ name_path: str,
+ diagnostic: ls_types.Diagnostic,
+) -> None:
+ grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
+ _diagnostic_output_dict(diagnostic)
+ )
+
+
+def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]:
+ if offset < 0 or offset > len(text):
+ raise ValueError(f"Offset out of range: {offset}")
+ prefix = text[:offset]
+ line = prefix.count("\n")
+ previous_newline_offset = prefix.rfind("\n")
+ column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1
+ return line, column
+
+
+def _line_and_column_to_offset(text: str, line: int, column: int) -> int:
+ if line < 0 or column < 0:
+ raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}")
+
+ line_start_offsets = [0]
+ for match in re.finditer("\n", text):
+ line_start_offsets.append(match.end())
+
+ if line >= len(line_start_offsets):
+ raise ValueError(f"Line out of range: {line}")
+
+ line_start_offset = line_start_offsets[line]
+ line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text)
+ if column > line_end_offset - line_start_offset:
+ raise ValueError(f"Column out of range for line {line}: {column}")
+ return line_start_offset + column
class RestartLanguageServerTool(Tool, ToolMarkerOptional):
@@ -324,6 +392,385 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results)
+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 FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
+ """
+ Finds the symbol that defines the symbol at the given file position using the language server backend.
+ """
+
+ @staticmethod
+ def _defining_symbol_to_result_dict(
+ symbol_retriever: Any,
+ defining_symbol: LanguageServerSymbol | None,
+ include_body: bool,
+ include_info: bool,
+ ) -> dict[str, Any] | None:
+ if defining_symbol is None:
+ return None
+
+ 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
+
+ # noinspection PyDefaultArgument
+ def apply(
+ self,
+ relative_path: str,
+ line: int,
+ column: int,
+ include_body: bool = False,
+ include_info: bool = False,
+ max_answer_chars: int = -1,
+ ) -> str:
+ """
+ Finds the defining symbol for the symbol at the given position.
+
+ :param relative_path: the relative path to the file containing the symbol usage.
+ :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 source code of the defining symbol.
+ :param include_info: whether to include additional info (hover-like, typically including docstring and signature)
+ about the defining symbol. Ignored if no defining symbol is found.
+ :param max_answer_chars: same as in the `find_symbol` tool.
+ :return: a JSON object representing the defining symbol, or `null` if no definition was found.
+ """
+ symbol_retriever = self.create_language_server_symbol_retriever()
+ defining_symbol = symbol_retriever.find_defining_symbol(
+ relative_file_path=relative_path,
+ line=line,
+ column=column,
+ include_body=include_body,
+ )
+
+ if defining_symbol is None:
+ result = self._to_json(None)
+ return self._limit_length(result, max_answer_chars)
+
+ symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info)
+ assert symbol_dict is not None
+ result = self._to_json(symbol_dict)
+ return self._limit_length(result, max_answer_chars)
+
+
+class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead):
+ """
+ Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body.
+ """
+
+ @staticmethod
+ def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str:
+ if containing_symbol_name_path:
+ return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'"
+ return f"file '{relative_path}'"
+
+ @classmethod
+ def _format_match_preview(cls, match: re.Match[str]) -> str:
+ matched_text = match.group(0).replace("\n", "\\n")
+ return matched_text[:120]
+
+ @classmethod
+ def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str:
+ if match.re.groups == 0:
+ return (
+ f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in "
+ f"{search_scope_description}."
+ )
+
+ matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)]
+ if len(matched_capture_spans) != 1:
+ return (
+ f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, "
+ f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'."
+ )
+
+ capture_start_offset, capture_end_offset = matched_capture_spans[0]
+ if capture_start_offset == capture_end_offset:
+ return (
+ f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; "
+ "the capture must select the referenced symbol text."
+ )
+ return capture_start_offset, capture_end_offset
+
+ def _find_unique_captured_location(
+ self,
+ relative_path: str,
+ regex: str,
+ containing_symbol_name_path: str | None,
+ ) -> tuple[int, int] | str:
+ # retrieving the search region
+ file_content = self.project.read_file(relative_path)
+ symbol_retriever = self.create_language_server_symbol_retriever()
+ search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path)
+ search_start_offset = 0
+ search_text = file_content
+
+ if containing_symbol_name_path:
+ try:
+ containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path)
+ except Exception as e:
+ return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}"
+
+ body_start_position = containing_symbol.get_body_start_position_or_raise()
+ body_end_position = containing_symbol.get_body_end_position_or_raise()
+ search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col)
+ search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col)
+ search_text = file_content[search_start_offset:search_end_offset]
+
+ # finding regex matches
+ try:
+ compiled_regex = re.compile(regex, re.MULTILINE)
+ except re.error as e:
+ return f"Error: Invalid regex '{regex}': {e}"
+
+ if compiled_regex.groups == 0:
+ return (
+ f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in "
+ f"{search_scope_description}."
+ )
+
+ matches = list(compiled_regex.finditer(search_text))
+ if len(matches) != 1:
+ match_previews = [self._format_match_preview(match) for match in matches[:3]]
+ preview_suffix = f" Matches: {match_previews}" if match_previews else ""
+ return (
+ f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, "
+ f"but found {len(matches)}.{preview_suffix}"
+ )
+
+ capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description)
+ if isinstance(capture_span_or_error, str):
+ return capture_span_or_error
+
+ capture_start_offset, _ = capture_span_or_error
+ absolute_capture_start_offset = search_start_offset + capture_start_offset
+ return _offset_to_line_and_column(file_content, absolute_capture_start_offset)
+
+ # noinspection PyDefaultArgument
+ def apply(
+ self,
+ regex: str,
+ relative_path: str,
+ containing_symbol_name_path: str = "",
+ include_body: bool = False,
+ include_info: bool = False,
+ max_answer_chars: int = -1,
+ ) -> str:
+ r"""
+ Finds the defining symbol for a uniquely captured regex match in a file.
+
+ The regex must contain exactly one capturing group, and exactly one overall match must be found.
+ The capture identifies the symbol usage whose definition should be resolved.
+
+ The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common
+ single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)``
+ or ``[\\s\\S]*?``.
+
+ :param regex: a Python regular expression containing exactly one capturing group.
+ :param relative_path: the relative path to the file containing the symbol usage.
+ :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 source code of the defining symbol.
+ :param include_info: whether to include additional info (hover-like, typically including docstring and signature)
+ about the defining symbol. Ignored if no defining symbol is found.
+ :param max_answer_chars: same as in the `find_symbol` tool.
+ :return: a JSON object representing the defining symbol, or ``null`` if no definition was found.
+ If the regex does not identify a unique captured match, an informative error string is returned.
+ """
+ captured_location_or_error = self._find_unique_captured_location(
+ relative_path=relative_path,
+ regex=regex,
+ containing_symbol_name_path=containing_symbol_name_path or None,
+ )
+ if isinstance(captured_location_or_error, str):
+ return captured_location_or_error
+
+ line, column = captured_location_or_error
+ symbol_retriever = self.create_language_server_symbol_retriever()
+ defining_symbol = symbol_retriever.find_defining_symbol(
+ relative_file_path=relative_path,
+ line=line,
+ column=column,
+ include_body=include_body,
+ )
+ symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict(
+ symbol_retriever,
+ defining_symbol,
+ include_body,
+ include_info,
+ )
+ result = self._to_json(symbol_dict)
+ return self._limit_length(result, max_answer_chars)
+
+
+class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
+ """
+ Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol.
+ """
+
+ _ENABLE_DIAGNOSTICS: bool = True
+
+ 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 ``.
+
+ :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_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ for diagnostic in diagnostics:
+ diag_range = diagnostic["range"]["start"]
+ name_path = 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()
+ _add_grouped_diagnostic(
+ grouped_result,
+ relative_path=relative_path,
+ severity_name=_diagnostic_severity_name(diagnostic.get("severity")),
+ name_path=name_path,
+ diagnostic=diagnostic,
+ )
+
+ result = self._to_json(grouped_result)
+ 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.
+ """
+
+ _ENABLE_DIAGNOSTICS: bool = True
+
+ 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_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ 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:
+ _add_grouped_diagnostic(
+ grouped_result,
+ relative_path=relative_path,
+ severity_name=_diagnostic_severity_name(diagnostic.get("severity")),
+ name_path=symbol_name_path,
+ diagnostic=diagnostic,
+ )
+
+ result = self._to_json(grouped_result)
+ return self._limit_length(result, max_answer_chars)
+
+
class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
"""
Replaces the full definition of a symbol using the language server backend.
@@ -348,13 +795,19 @@ 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.
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the symbol replacement
code_editor = self.create_code_editor()
code_editor.replace_body(
name_path,
relative_file_path=relative_path,
body=body,
)
- return SUCCESS_RESULT
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
@@ -377,9 +830,15 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
:param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol.
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the insertion
code_editor = self.create_code_editor()
code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
- return SUCCESS_RESULT
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit):
@@ -402,9 +861,15 @@ 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
"""
+ # capturing diagnostics before the edit
+ edited_file_paths = [EditedFilePath(relative_path, relative_path)]
+ diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+
+ # applying the insertion
code_editor = self.create_code_editor()
code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
- return SUCCESS_RESULT
+
+ return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit):
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index c13e4cd4..7ef28c3c 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -18,11 +18,13 @@ 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 solidlsp import ls_types
from solidlsp.ls_exceptions import SolidLSPException
+from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
if TYPE_CHECKING:
from serena.agent import SerenaAgent
- from serena.code_editor import CodeEditor, LanguageServerCodeEditor
+ from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
log = logging.getLogger(__name__)
@@ -30,6 +32,24 @@ T = TypeVar("T")
SUCCESS_RESULT = "OK"
+@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
+
+
+@dataclass
+class PublishedDiagnosticsSnapshot:
+ generation_by_after_path: dict[str, int]
+ warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]]
+
+
class Component(ABC):
def __init__(self, agent: "SerenaAgent"):
self.agent = agent
@@ -145,6 +165,8 @@ class Tool(Component):
_last_tool_call_client_str: str | None = None
"""We can only get the client info from within a tool call. Each tool call will update this variable."""
+ _ENABLE_DIAGNOSTICS: bool = False
+
def __init__(self, agent: "SerenaAgent"):
super().__init__(agent)
@@ -304,6 +326,180 @@ class Tool(Component):
def is_symbolic(self) -> bool:
return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit)
+ @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)
+
+ @classmethod
+ def _diagnostic_identity(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_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
+
+ @classmethod
+ def _add_grouped_diagnostic(
+ cls,
+ grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
+ relative_path: str,
+ name_path: str,
+ diagnostic: ls_types.Diagnostic,
+ ) -> None:
+ severity_name = cls._diagnostic_severity_name(diagnostic.get("severity"))
+ grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
+ cls._diagnostic_output_dict(diagnostic)
+ )
+
+ def _capture_published_lsp_diagnostics_snapshot(
+ self,
+ edited_file_paths: Iterable["EditedFilePath"],
+ ) -> PublishedDiagnosticsSnapshot | None:
+ if not self._ENABLE_DIAGNOSTICS:
+ return None
+ if self.agent.get_language_backend() != LanguageBackend.LSP:
+ return None
+
+ # collecting diagnostics state before the edit
+ symbol_retriever = self.create_language_server_symbol_retriever()
+ 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:
+ return None
+
+ 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] = {
+ self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or []
+ }
+
+ return PublishedDiagnosticsSnapshot(
+ generation_by_after_path=generation_by_after_path,
+ warning_identities_by_before_path=warning_identities_by_before_path,
+ )
+
+ def _format_lsp_edit_result_with_new_diagnostics(
+ self,
+ default_result: str,
+ edited_file_paths: Iterable["EditedFilePath"],
+ before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None,
+ ) -> str:
+ if not self._ENABLE_DIAGNOSTICS:
+ return default_result
+
+ # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics
+ # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code
+ if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP:
+ return default_result
+
+ # collecting diagnostics state after the edit
+ symbol_retriever = self.create_language_server_symbol_retriever()
+ grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ saw_diagnostics_result = False
+
+ for edited_file_path in edited_file_paths:
+ try:
+ language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path)
+ except Exception:
+ return default_result
+
+ published_diagnostics = language_server.request_published_text_document_diagnostics(
+ relative_file_path=edited_file_path.after_relative_path,
+ after_generation=before_edit_diagnostics_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 Exception:
+ published_diagnostics = None
+ if published_diagnostics is None:
+ continue
+
+ saw_diagnostics_result = True
+ existing_warning_identities = before_edit_diagnostics_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 = self._diagnostic_identity(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 ""
+ self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic)
+
+ # preserving the normal success result when no new diagnostics were introduced
+ if not saw_diagnostics_result or not grouped_result:
+ return default_result
+
+ return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}"
+
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
"""
Applies the tool with logging and exception handling, using the given keyword arguments
@@ -319,7 +515,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:
diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py
index 6ae69a39..546c6a9f 100644
--- a/src/solidlsp/ls.py
+++ b/src/solidlsp/ls.py
@@ -14,7 +14,7 @@ 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 typing import Any, Self, Union, cast
import pathspec
from sensai.util.pickle import getstate, load_pickle
@@ -511,6 +511,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 +554,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
@@ -567,6 +572,274 @@ class SolidLanguageServer(ABC):
self._has_waited_for_cross_file_references = False
+ 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))
+
+ with self._published_diagnostics_condition:
+ self._published_diagnostics_generation += 1
+ self._published_diagnostics[uri] = normalized_diagnostics
+ self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation
+ self._published_diagnostics_condition.notify_all()
+
+ def _get_published_diagnostics_generation(self, uri: str) -> int:
+ with self._published_diagnostics_condition:
+ return self._published_diagnostics_generation_by_uri.get(uri, -1)
+
+ def _wait_for_published_diagnostics(
+ self,
+ uri: str,
+ after_generation: int,
+ timeout: float,
+ ) -> list[ls_types.Diagnostic] | None:
+ deadline = perf_counter() + timeout
+ with self._published_diagnostics_condition:
+ while True:
+ current_generation = self._published_diagnostics_generation_by_uri.get(uri, -1)
+ if current_generation > after_generation:
+ return list(self._published_diagnostics.get(uri, []))
+
+ 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:
+ with self._published_diagnostics_condition:
+ diagnostics = self._published_diagnostics.get(uri)
+ 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"]
+ effective_end_line = end_line if end_line >= 0 else diagnostic_end_line
+ return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= 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)
+ diagnostics: list[ls_types.Diagnostic] | None = None
+
+ # keeping the document open
+ with self.open_file(relative_file_path):
+ diagnostics = self._wait_for_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout)
+
+ # falling back to cached diagnostics
+ if diagnostics is None and allow_cached:
+ 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_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)
+ diagnostics_before_request = self._get_published_diagnostics_generation(uri)
+ ret: list[ls_types.Diagnostic] | None = None
+ pull_diagnostics_failed = False
+
+ with self.open_file(relative_file_path):
+ 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["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_published_diagnostics(
+ uri=uri,
+ after_generation=diagnostics_before_request,
+ timeout=2.5 if pull_diagnostics_failed else 0.5,
+ )
+ if published_diagnostics is None:
+ published_diagnostics = self._get_cached_published_diagnostics(uri)
+ 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.
diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py
index 6b979a92..6319a9d7 100644
--- a/src/solidlsp/ls_process.py
+++ b/src/solidlsp/ls_process.py
@@ -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)
diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py
index 78446bc5..24d51266 100644
--- a/src/solidlsp/ls_types.py
+++ b/src/solidlsp/ls_types.py
@@ -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. """
diff --git a/test/conftest.py b/test/conftest.py
index 4d3f1154..5e55b180 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -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)
@@ -329,3 +333,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_occurrence_position(
+ 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
diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py
new file mode 100644
index 00000000..8cfcfacb
--- /dev/null
+++ b/test/diagnostics_cases.py
@@ -0,0 +1,230 @@
+import os
+from dataclasses import dataclass
+from typing import cast
+
+import pytest
+from _pytest.mark import Mark, MarkDecorator
+
+from solidlsp.ls_config import Language
+from test.conftest import get_pytest_markers
+
+
+@dataclass(frozen=True)
+class DiagnosticCase:
+ language: Language
+ relative_path: str
+ primary_symbol_name_path: str
+ primary_symbol_identifier: str
+ reference_symbol_name_path: str
+ reference_symbol_identifier: str
+ primary_message_fragment: str
+ reference_message_fragment: str
+
+
+def diagnostic_case_param(
+ case: DiagnosticCase,
+ *marks: MarkDecorator | Mark,
+ id: str,
+):
+ return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id)
+
+
+DIAGNOSTIC_CASE_PARAMS = [
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.PYTHON,
+ relative_path=os.path.join("test_repo", "diagnostics_sample.py"),
+ primary_symbol_name_path="broken_factory",
+ primary_symbol_identifier="broken_factory",
+ reference_symbol_name_path="broken_consumer",
+ reference_symbol_identifier="broken_consumer",
+ primary_message_fragment="missing_user",
+ reference_message_fragment="undefined_name",
+ ),
+ id="python_missing_user",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.PYTHON_TY,
+ relative_path=os.path.join("test_repo", "diagnostics_sample.py"),
+ primary_symbol_name_path="broken_factory",
+ primary_symbol_identifier="broken_factory",
+ reference_symbol_name_path="broken_consumer",
+ reference_symbol_identifier="broken_consumer",
+ primary_message_fragment="missing_user",
+ reference_message_fragment="undefined_name",
+ ),
+ id="python_ty_missing_user",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.GO,
+ relative_path="diagnostics_sample.go",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ id="go_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.JAVA,
+ relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"),
+ primary_symbol_name_path="DiagnosticsSample/brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="DiagnosticsSample/brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ id="java_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.KOTLIN,
+ relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"),
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ id="kotlin_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.RUST,
+ relative_path=os.path.join("src", "diagnostics_sample.rs"),
+ primary_symbol_name_path="broken_factory",
+ primary_symbol_identifier="broken_factory",
+ reference_symbol_name_path="broken_consumer",
+ reference_symbol_identifier="broken_consumer",
+ primary_message_fragment="missing_greeting",
+ reference_message_fragment="missing_consumer_value",
+ ),
+ pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"),
+ id="rust_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.PHP,
+ relative_path="diagnostics_sample.php",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"),
+ id="php_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.CLOJURE,
+ relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"),
+ primary_symbol_name_path="broken-factory",
+ primary_symbol_identifier="broken-factory",
+ reference_symbol_name_path="broken-consumer",
+ reference_symbol_identifier="broken-consumer",
+ primary_message_fragment="missing-greeting",
+ reference_message_fragment="missing-consumer-value",
+ ),
+ id="clojure_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.CSHARP,
+ relative_path="DiagnosticsSample.cs",
+ primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory",
+ primary_symbol_identifier="BrokenFactory",
+ reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer",
+ reference_symbol_identifier="BrokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ id="csharp_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.POWERSHELL,
+ relative_path="diagnostics_sample.ps1",
+ primary_symbol_name_path="function Invoke-BrokenFactory ()",
+ primary_symbol_identifier="Invoke-BrokenFactory",
+ reference_symbol_name_path="function Invoke-BrokenConsumer ()",
+ reference_symbol_identifier="Invoke-BrokenConsumer",
+ primary_message_fragment="MissingGreeting",
+ reference_message_fragment="MissingConsumerValue",
+ ),
+ pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"),
+ id="powershell_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.CPP_CCLS,
+ relative_path="diagnostics_sample.cpp",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"),
+ id="cpp_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.LEAN4,
+ relative_path="DiagnosticsSample.lean",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"),
+ id="lean_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.TYPESCRIPT,
+ relative_path="diagnostics_sample.ts",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ pytest.mark.typescript,
+ pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"),
+ id="typescript_missing_greeting",
+ ),
+ diagnostic_case_param(
+ DiagnosticCase(
+ language=Language.FSHARP,
+ relative_path="DiagnosticsSample.fs",
+ primary_symbol_name_path="brokenFactory",
+ primary_symbol_identifier="brokenFactory",
+ reference_symbol_name_path="brokenConsumer",
+ reference_symbol_identifier="brokenConsumer",
+ primary_message_fragment="missingGreeting",
+ reference_message_fragment="missingConsumerValue",
+ ),
+ pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"),
+ id="fsharp_missing_greeting",
+ ),
+]
+
+WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [
+ case_param
+ for case_param in DIAGNOSTIC_CASE_PARAMS
+ if cast(DiagnosticCase, case_param.values[1]).language
+ in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP}
+]
diff --git a/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj
new file mode 100644
index 00000000..ecc0c79c
--- /dev/null
+++ b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj
@@ -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))
diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json
index 21c02f62..cb096a60 100644
--- a/test/resources/repos/cpp/test_repo/compile_commands.json
+++ b/test/resources/repos/cpp/test_repo/compile_commands.json
@@ -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"
}
-]
\ No newline at end of file
+]
diff --git a/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp
new file mode 100644
index 00000000..78075fc8
--- /dev/null
+++ b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp
@@ -0,0 +1,8 @@
+int brokenFactory() {
+ return missingGreeting;
+}
+
+int brokenConsumer() {
+ int value = brokenFactory();
+ return value + missingConsumerValue;
+}
diff --git a/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs
new file mode 100644
index 00000000..1356e26d
--- /dev/null
+++ b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs
@@ -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);
+ }
+ }
+}
diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs
index 55c8d228..4bcf0f6a 100644
--- a/test/resources/repos/csharp/test_repo/Program.cs
+++ b/test/resources/repos/csharp/test_repo/Program.cs
@@ -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;
}
}
-}
\ No newline at end of file
+}
diff --git a/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs
new file mode 100644
index 00000000..d80d8ba8
--- /dev/null
+++ b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs
@@ -0,0 +1,10 @@
+namespace TestProject.Services
+{
+ public class ConsoleGreeter : IGreeter
+ {
+ public string FormatGreeting(string name)
+ {
+ return $"Hello, {name}!";
+ }
+ }
+}
diff --git a/test/resources/repos/csharp/test_repo/Services/IGreeter.cs b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs
new file mode 100644
index 00000000..2f64ca0e
--- /dev/null
+++ b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs
@@ -0,0 +1,7 @@
+namespace TestProject.Services
+{
+ public interface IGreeter
+ {
+ string FormatGreeting(string name);
+ }
+}
diff --git a/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs
new file mode 100644
index 00000000..815b844b
--- /dev/null
+++ b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs
@@ -0,0 +1,11 @@
+module DiagnosticsSample
+
+let brokenConsumerValue = 1
+
+let brokenFactory () =
+ missingGreeting
+
+let brokenConsumer () =
+ let value = brokenFactory ()
+ printfn "%A" value
+ missingConsumerValue
diff --git a/test/resources/repos/fsharp/test_repo/Formatter.fs b/test/resources/repos/fsharp/test_repo/Formatter.fs
new file mode 100644
index 00000000..52085d3f
--- /dev/null
+++ b/test/resources/repos/fsharp/test_repo/Formatter.fs
@@ -0,0 +1,8 @@
+module Formatter
+
+type IGreeter =
+ abstract member FormatGreeting: string -> string
+
+type ConsoleGreeter() =
+ interface IGreeter with
+ member _.FormatGreeting(name: string) = sprintf "Hello, %s!" name
diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs
index e5328ceb..3305598c 100644
--- a/test/resources/repos/fsharp/test_repo/Program.fs
+++ b/test/resources/repos/fsharp/test_repo/Program.fs
@@ -1,7 +1,9 @@
module Program
open Calculator
+open Formatter
open Models
+open Shapes
[]
let main argv =
@@ -24,6 +26,12 @@ let main argv =
let calc = CalculatorClass()
let classResult = calc.Add(20, 5)
printfn "Calculator class: 20 + 5 = %d" classResult
+
+ let greeter: IGreeter = ConsoleGreeter() :> IGreeter
+ printfn "%s" (greeter.FormatGreeting("World"))
+
+ let shape: Shape = Circle(2.0) :> Shape
+ printfn "Area: %.2f" (shape.Area())
// Test person module
let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com")
@@ -34,4 +42,4 @@ let main argv =
let fact5 = factorial 5
printfn "5! = %d" fact5
- 0 // return success
\ No newline at end of file
+ 0 // return success
diff --git a/test/resources/repos/fsharp/test_repo/Shapes.fs b/test/resources/repos/fsharp/test_repo/Shapes.fs
new file mode 100644
index 00000000..d67a98aa
--- /dev/null
+++ b/test/resources/repos/fsharp/test_repo/Shapes.fs
@@ -0,0 +1,10 @@
+module Shapes
+
+[]
+type Shape() =
+ abstract member Area: unit -> float
+
+type Circle(radius: float) =
+ inherit Shape()
+
+ override _.Area() = System.Math.PI * radius * radius
diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj
index 39b621ff..e44baee0 100644
--- a/test/resources/repos/fsharp/test_repo/TestProject.fsproj
+++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj
@@ -7,8 +7,11 @@
+
+
+
-
\ No newline at end of file
+
diff --git a/test/resources/repos/go/test_repo/diagnostics_sample.go b/test/resources/repos/go/test_repo/diagnostics_sample.go
new file mode 100644
index 00000000..15f7d6a2
--- /dev/null
+++ b/test/resources/repos/go/test_repo/diagnostics_sample.go
@@ -0,0 +1,11 @@
+package main
+
+func brokenFactory() string {
+ return missingGreeting
+}
+
+func brokenConsumer() {
+ value := brokenFactory()
+ _ = value
+ _ = missingConsumerValue
+}
diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go
index a5716358..44412892 100644
--- a/test/resources/repos/go/test_repo/main.go
+++ b/test/resources/repos/go/test_repo/main.go
@@ -5,6 +5,8 @@ import "fmt"
func main() {
fmt.Println("Hello, Go!")
Helper()
+ var greeter Greeter = ConsoleGreeter{}
+ fmt.Println(greeter.FormatGreeting("Go"))
}
func Helper() {
@@ -22,3 +24,13 @@ func (d *DemoStruct) Value() int {
func UsingHelper() {
Helper()
}
+
+type Greeter interface {
+ FormatGreeting(name string) string
+}
+
+type ConsoleGreeter struct{}
+
+func (ConsoleGreeter) FormatGreeting(name string) string {
+ return "Hello, " + name + "!"
+}
diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java
new file mode 100644
index 00000000..e8b85c99
--- /dev/null
+++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java
@@ -0,0 +1,8 @@
+package test_repo;
+
+public class ConsoleGreeter implements Greeter {
+ @Override
+ public String formatGreeting(String name) {
+ return "Hello, " + name + "!";
+ }
+}
diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java
new file mode 100644
index 00000000..53f8406b
--- /dev/null
+++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java
@@ -0,0 +1,13 @@
+package test_repo;
+
+public class DiagnosticsSample {
+ public static String brokenFactory() {
+ return missingGreeting;
+ }
+
+ public static void brokenConsumer() {
+ String value = brokenFactory();
+ System.out.println(value);
+ System.out.println(missingConsumerValue);
+ }
+}
diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java
new file mode 100644
index 00000000..569d5006
--- /dev/null
+++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java
@@ -0,0 +1,5 @@
+package test_repo;
+
+public interface Greeter {
+ String formatGreeting(String name);
+}
diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java
index ed79b9fe..29c0688e 100644
--- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java
+++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java
@@ -6,6 +6,8 @@ public class Main {
Model model = new Model("Cascade");
System.out.println(model.getName());
acceptModel(model);
+ Greeter greeter = new ConsoleGreeter();
+ System.out.println(greeter.formatGreeting("Cascade"));
}
public static void acceptModel(Model m) {
// Do nothing, just for LSP reference
diff --git a/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..2a84e188
--- /dev/null
+++ b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/test/resources/repos/kotlin/test_repo/gradlew b/test/resources/repos/kotlin/test_repo/gradlew
new file mode 100644
index 00000000..ef07e016
--- /dev/null
+++ b/test/resources/repos/kotlin/test_repo/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/test/resources/repos/kotlin/test_repo/gradlew.bat b/test/resources/repos/kotlin/test_repo/gradlew.bat
new file mode 100644
index 00000000..db3a6ac2
--- /dev/null
+++ b/test/resources/repos/kotlin/test_repo/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt
new file mode 100644
index 00000000..93f0b939
--- /dev/null
+++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt
@@ -0,0 +1,11 @@
+package test_repo
+
+fun brokenFactory(): String {
+ return missingGreeting
+}
+
+fun brokenConsumer() {
+ val value = brokenFactory()
+ println(value)
+ println(missingConsumerValue)
+}
diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
new file mode 100644
index 00000000..a2c570ec
--- /dev/null
+++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
@@ -0,0 +1,6 @@
+def brokenFactory : Nat :=
+ missingGreeting
+
+def brokenConsumer : Nat :=
+ let value := brokenFactory
+ value + missingConsumerValue
diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua
index d19d5212..cd6ecc97 100644
--- a/test/resources/repos/lua/test_repo/main.lua
+++ b/test/resources/repos/lua/test_repo/main.lua
@@ -3,6 +3,7 @@
-- main.lua: Entry point for the test application
local calculator = require("src.calculator")
+local animals = require("src.animals")
local utils = require("src.utils")
local function print_banner()
@@ -50,6 +51,12 @@ local function test_utils()
logger:warn("This is a warning")
end
+local function test_animals()
+ print("\nTesting Animal Module:")
+ local dog = animals.Dog:new("Rex")
+ print(dog:speak())
+end
+
local function interactive_calculator()
print("\nInteractive Calculator (type 'quit' to exit):")
while true do
@@ -99,11 +106,13 @@ local function main(args)
if #args == 0 then
test_calculator()
test_utils()
+ test_animals()
elseif args[1] == "interactive" then
interactive_calculator()
elseif args[1] == "test" then
test_calculator()
test_utils()
+ test_animals()
print("\nAll tests completed!")
else
print("Usage: lua main.lua [interactive|test]")
@@ -111,4 +120,4 @@ local function main(args)
end
-- Run main function
-main(arg or {})
\ No newline at end of file
+main(arg or {})
diff --git a/test/resources/repos/lua/test_repo/src/animals.lua b/test/resources/repos/lua/test_repo/src/animals.lua
new file mode 100644
index 00000000..92f08f26
--- /dev/null
+++ b/test/resources/repos/lua/test_repo/src/animals.lua
@@ -0,0 +1,37 @@
+---@class Animal
+local Animal = {}
+Animal.__index = Animal
+
+---@param name string
+---@return Animal
+function Animal:new(name)
+ local self = setmetatable({}, Animal)
+ self.name = name
+ return self
+end
+
+---@return string
+function Animal:speak()
+ return self.name .. " makes a sound"
+end
+
+---@class Dog: Animal
+local Dog = setmetatable({}, { __index = Animal })
+Dog.__index = Dog
+
+---@param name string
+---@return Dog
+function Dog:new(name)
+ local self = Animal.new(self, name)
+ return setmetatable(self, Dog)
+end
+
+---@return string
+function Dog:speak()
+ return self.name .. " says woof"
+end
+
+return {
+ Animal = Animal,
+ Dog = Dog,
+}
diff --git a/test/resources/repos/php/test_repo/diagnostics_sample.php b/test/resources/repos/php/test_repo/diagnostics_sample.php
new file mode 100644
index 00000000..b1a6be19
--- /dev/null
+++ b/test/resources/repos/php/test_repo/diagnostics_sample.php
@@ -0,0 +1,11 @@
+ User:
+ return missing_user
+
+
+def broken_consumer() -> None:
+ created_user = broken_factory()
+ print(created_user)
+ print(undefined_name)
diff --git a/test/resources/repos/ruby/test_repo/lib.rb b/test/resources/repos/ruby/test_repo/lib.rb
index 5dd8504b..65eba9de 100644
--- a/test/resources/repos/ruby/test_repo/lib.rb
+++ b/test/resources/repos/ruby/test_repo/lib.rb
@@ -7,3 +7,15 @@ class Calculator
a - b
end
end
+
+class Greeter
+ def format_greeting(name)
+ name
+ end
+end
+
+class ConsoleGreeter < Greeter
+ def format_greeting(name)
+ "Hello, #{name}!"
+ end
+end
diff --git a/test/resources/repos/ruby/test_repo/main.rb b/test/resources/repos/ruby/test_repo/main.rb
index dcf1b0f2..b9a98c92 100644
--- a/test/resources/repos/ruby/test_repo/main.rb
+++ b/test/resources/repos/ruby/test_repo/main.rb
@@ -15,6 +15,8 @@ end
def helper_function(number = 42)
demo = DemoClass.new(number)
Calculator.new.add(demo.value, 10)
+ greeter = ConsoleGreeter.new
+ puts greeter.format_greeting("Ruby")
demo.print_value
end
diff --git a/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs
new file mode 100644
index 00000000..120e9e36
--- /dev/null
+++ b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs
@@ -0,0 +1,9 @@
+pub fn broken_factory() -> String {
+ missing_greeting
+}
+
+pub fn broken_consumer() {
+ let value = broken_factory();
+ println!("{value}");
+ println!("{}", missing_consumer_value);
+}
diff --git a/test/resources/repos/rust/test_repo/src/lib.rs b/test/resources/repos/rust/test_repo/src/lib.rs
index 8ad43ead..178d0165 100644
--- a/test/resources/repos/rust/test_repo/src/lib.rs
+++ b/test/resources/repos/rust/test_repo/src/lib.rs
@@ -1,3 +1,5 @@
+pub mod diagnostics_sample;
+
// This function returns the sum of 2 + 2
pub fn add() -> i32 {
let res = 2 + 2;
@@ -7,3 +9,15 @@ pub fn multiply() -> i32 {
2 * 3
}
+pub trait Greeter {
+ fn format_greeting(&self, name: &str) -> String;
+}
+
+pub struct ConsoleGreeter;
+
+impl Greeter for ConsoleGreeter {
+ fn format_greeting(&self, name: &str) -> String {
+ format!("Hello, {name}!")
+ }
+}
+
diff --git a/test/resources/repos/rust/test_repo/src/main.rs b/test/resources/repos/rust/test_repo/src/main.rs
index 7385c9d9..a5bb8063 100644
--- a/test/resources/repos/rust/test_repo/src/main.rs
+++ b/test/resources/repos/rust/test_repo/src/main.rs
@@ -1,8 +1,10 @@
-use rsandbox::add;
+use rsandbox::{add, ConsoleGreeter, Greeter};
fn main() {
println!("Hello, World!");
println!("Good morning!");
println!("add result: {}", add());
+ let greeter = ConsoleGreeter;
+ println!("{}", greeter.format_greeting("Rust"));
println!("inserted line");
}
diff --git a/test/resources/repos/typescript/test_repo/.serena/project.yml b/test/resources/repos/typescript/test_repo/.serena/project.yml
index b9f472b6..942b95a1 100644
--- a/test/resources/repos/typescript/test_repo/.serena/project.yml
+++ b/test/resources/repos/typescript/test_repo/.serena/project.yml
@@ -1,7 +1,153 @@
+
+# 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 by name.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_lines`: Deletes a range of lines within a file.
+# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+# * `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`: Gets the initial instructions for the current project.
+# Should only be used in settings where the system prompt cannot be set,
+# e.g. in clients you have no control over, like Claude Desktop.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_at_line`: Inserts content at a given line in a file.
+# * `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`: Lists memories in Serena's project-specific memory store.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+# * `remove_project`: Removes a project from the Serena configuration.
+# * `replace_lines`: Replaces a range of lines within a file with new content.
+# * `replace_symbol_body`: Replaces the full definition of a symbol.
+# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+# * `switch_modes`: Activates modes by providing a list of their names
+# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
+
+# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
+
+# 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: ''
-language: typescript
+# the name by which the project can be referenced within Serena
project_name: test_repo
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
read_only: false
+
+# 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).
+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.
+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.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# 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: {}
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: utf-8
+
+
+# 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 python_ty
+# r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- typescript
diff --git a/test/resources/repos/typescript/test_repo/diagnostics_sample.ts b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts
new file mode 100644
index 00000000..566643e1
--- /dev/null
+++ b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts
@@ -0,0 +1,9 @@
+export function brokenFactory(): string {
+ return missingGreeting;
+}
+
+export function brokenConsumer(): void {
+ const value = brokenFactory();
+ console.log(value);
+ console.log(missingConsumerValue);
+}
diff --git a/test/resources/repos/typescript/test_repo/formatters.ts b/test/resources/repos/typescript/test_repo/formatters.ts
new file mode 100644
index 00000000..fe233f93
--- /dev/null
+++ b/test/resources/repos/typescript/test_repo/formatters.ts
@@ -0,0 +1,9 @@
+export interface Greeter {
+ formatGreeting(name: string): string;
+}
+
+export class ConsoleGreeter implements Greeter {
+ formatGreeting(name: string): string {
+ return `Hello, ${name}!`;
+ }
+}
diff --git a/test/resources/repos/typescript/test_repo/index.ts b/test/resources/repos/typescript/test_repo/index.ts
index 724a67c6..417e5863 100644
--- a/test/resources/repos/typescript/test_repo/index.ts
+++ b/test/resources/repos/typescript/test_repo/index.ts
@@ -1,3 +1,5 @@
+import { ConsoleGreeter, Greeter } from "./formatters";
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -11,6 +13,9 @@ export class DemoClass {
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr
index 6c688345..8fd3a73b 100644
--- a/test/serena/__snapshots__/test_symbol_editing.ambr
+++ b/test/serena/__snapshots__/test_symbol_editing.ambr
@@ -67,11 +67,16 @@
# ---
# name: test_delete_symbol[test_case1]
'''
-
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
+
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
@@ -515,6 +520,8 @@
# ---
# name: test_insert_in_rel_to_symbol[test_case1-after]
'''
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -532,6 +539,9 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
@@ -544,6 +554,8 @@
# ---
# name: test_insert_in_rel_to_symbol[test_case1-before]
'''
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
function newFunctionAfterClass(): void {
console.log("This function is after DemoClass.");
}
@@ -561,6 +573,9 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
@@ -573,6 +588,8 @@
# ---
# name: test_insert_in_rel_to_symbol[test_case2-after]
'''
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -586,6 +603,9 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
function newInsertedFunction(): void {
@@ -602,6 +622,8 @@
# ---
# name: test_insert_in_rel_to_symbol[test_case2-before]
'''
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -619,6 +641,9 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
@@ -1686,6 +1711,8 @@
# ---
# name: test_replace_body[test_case1]
'''
+ import { ConsoleGreeter, Greeter } from "./formatters";
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -1700,6 +1727,9 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
+
+ const greeter: Greeter = new ConsoleGreeter();
+ console.log(greeter.formatGreeting("World"));
}
helperFunction();
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index 896b030f..62dc1a53 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -2,12 +2,15 @@ import json
import logging
import os
import re
+import shutil
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Literal
import pytest
+from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase
+from _pytest.mark import Mark, MarkDecorator
from serena.agent import SerenaAgent
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
@@ -15,8 +18,13 @@ from serena.project import Project
from serena.tools import (
SUCCESS_RESULT,
ActivateProjectTool,
+ FindDefiningSymbolAtLocationTool,
+ FindDefiningSymbolTool,
+ FindImplementationsTool,
FindReferencingSymbolsTool,
FindSymbolTool,
+ GetDiagnosticsForFileTool,
+ GetDiagnosticsForSymbolTool,
InitialInstructionsTool,
ReplaceContentTool,
ReplaceSymbolBodyTool,
@@ -25,9 +33,208 @@ from serena.tools import (
)
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
-from test.conftest import get_repo_path, is_ci, language_tests_enabled
+from test.conftest import (
+ find_identifier_occurrence_position,
+ get_repo_path,
+ is_ci,
+ language_has_verified_implementation_support,
+ language_tests_enabled,
+)
+from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase
from test.solidlsp import clojure as clj
+DEFINING_SYMBOL_TOOL_TEST_CASES = [
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ "User",
+ 1,
+ 1,
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.PYTHON_TY,
+ os.path.join("test_repo", "services.py"),
+ "User",
+ 1,
+ 1,
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go),
+ pytest.param(
+ Language.JAVA,
+ os.path.join("src", "main", "java", "test_repo", "Main.java"),
+ "Model",
+ 0,
+ 1,
+ "Model",
+ "Model.java",
+ marks=pytest.mark.java,
+ ),
+ pytest.param(
+ Language.KOTLIN,
+ os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
+ "Model",
+ 0,
+ 1,
+ "Model",
+ "Model.kt",
+ marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
+ ),
+ pytest.param(
+ Language.RUST,
+ os.path.join("src", "main.rs"),
+ "format_greeting",
+ 0,
+ 1,
+ "format_greeting",
+ "lib.rs",
+ marks=pytest.mark.rust,
+ ),
+ pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php),
+ pytest.param(
+ Language.CLOJURE,
+ clj.UTILS_PATH,
+ "multiply",
+ 0,
+ 1,
+ "multiply",
+ clj.CORE_PATH,
+ marks=[
+ pytest.mark.clojure,
+ pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"),
+ ],
+ ),
+ pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp),
+ pytest.param(
+ Language.POWERSHELL,
+ "main.ps1",
+ "Convert-ToUpperCase",
+ 0,
+ 1,
+ "function Convert-ToUpperCase ()",
+ "utils.ps1",
+ marks=pytest.mark.powershell,
+ ),
+ pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp),
+ pytest.param(
+ Language.LEAN4,
+ "Main.lean",
+ "add",
+ 0,
+ 1,
+ "add",
+ "Helper.lean",
+ marks=[
+ pytest.mark.lean4,
+ pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"),
+ ],
+ ),
+ pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript),
+ pytest.param(
+ Language.FSHARP,
+ "Program.fs",
+ "add",
+ 0,
+ 1,
+ "add",
+ "Calculator.fs",
+ marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")],
+ ),
+]
+
+REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ r"from \.models import Item, (User)",
+ "",
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ r"=\s+(User)\(",
+ "UserService/create_user",
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.PYTHON_TY,
+ os.path.join("test_repo", "services.py"),
+ r"=\s+(User)\(",
+ "UserService/create_user",
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.GO,
+ "main.go",
+ r"var greeter (Greeter) =",
+ "main",
+ "Greeter",
+ "main.go",
+ marks=pytest.mark.go,
+ ),
+]
+
+IMPLEMENTATION_TOOL_TEST_CASE_DATA = [
+ (
+ Language.CSHARP,
+ "IGreeter/FormatGreeting",
+ os.path.join("Services", "IGreeter.cs"),
+ os.path.join("Services", "ConsoleGreeter.cs"),
+ "FormatGreeting",
+ pytest.mark.csharp,
+ ),
+ (
+ Language.GO,
+ "Greeter/FormatGreeting",
+ "main.go",
+ "main.go",
+ "(ConsoleGreeter).FormatGreeting",
+ pytest.mark.go,
+ ),
+ (
+ Language.JAVA,
+ "Greeter/formatGreeting",
+ os.path.join("src", "main", "java", "test_repo", "Greeter.java"),
+ os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"),
+ "formatGreeting",
+ pytest.mark.java,
+ ),
+ (
+ Language.RUST,
+ "Greeter/format_greeting",
+ os.path.join("src", "lib.rs"),
+ os.path.join("src", "lib.rs"),
+ "format_greeting",
+ pytest.mark.rust,
+ ),
+ (
+ Language.TYPESCRIPT,
+ "Greeter/formatGreeting",
+ "formatters.ts",
+ "formatters.ts",
+ "formatGreeting",
+ pytest.mark.typescript,
+ ),
+]
+
+IMPLEMENTATION_TOOL_TEST_CASES = [
+ pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark)
+ for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA
+ if language_has_verified_implementation_support(language)
+]
+
@pytest.fixture
def serena_config():
@@ -37,6 +244,7 @@ def serena_config():
test_projects = []
for language in [
Language.PYTHON,
+ Language.PYTHON_TY,
Language.GO,
Language.JAVA,
Language.KOTLIN,
@@ -82,6 +290,13 @@ def read_project_file(project: Project, relative_path: str) -> str:
return f.read()
+def parse_edit_diagnostics_result(result: str) -> dict:
+ """Utility function to parse the diagnostic payload returned by edit tools."""
+ prefix = "Edit introduced new warning-or-higher diagnostics: "
+ assert result.startswith(prefix), result
+ return json.loads(result[len(prefix) :])
+
+
@contextmanager
def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]:
"""Context manager to modify a project file and revert the changes after use."""
@@ -267,6 +482,174 @@ class TestSerenaAgent:
refs = json.loads(result)
assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
+ def _assert_find_symbol_implementations(
+ self,
+ serena_agent: SerenaAgent,
+ symbol_name: str,
+ def_file: str,
+ impl_file: str,
+ expected_symbol_name: str,
+ ) -> None:
+ agent = serena_agent
+
+ find_symbol_tool = agent.get_tool(FindSymbolTool)
+ result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file)
+ symbols = json.loads(result)
+ assert symbols, f"Expected to find symbol {symbol_name} in {def_file}"
+
+ def_symbol = symbols[0]
+ find_impl_tool = agent.get_tool(FindImplementationsTool)
+ result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True)
+ implementations = json.loads(result)
+
+ assert any(
+ impl_file in implementation["relative_path"]
+ and (
+ implementation.get("name") == expected_symbol_name
+ or implementation.get("name_path") == expected_symbol_name
+ or expected_symbol_name in implementation.get("info", "")
+ )
+ for implementation in implementations
+ ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}"
+
+ for implementation in implementations:
+ if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name):
+ continue
+ symbol_info = implementation.get("info")
+ assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}"
+
+ def _assert_find_defining_symbol(
+ self,
+ serena_agent: SerenaAgent,
+ relative_path: str,
+ identifier: str,
+ occurrence_index: int,
+ column_offset: int,
+ expected_name: str,
+ expected_definition_file: str,
+ ) -> None:
+ project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0])
+ position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset)
+ assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}"
+
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool)
+ result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True)
+ defining_symbol = json.loads(result)
+
+ assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}"
+ assert defining_symbol.get("relative_path") is not None
+ assert expected_definition_file in defining_symbol["relative_path"], (
+ f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
+ )
+ assert (
+ defining_symbol.get("name") == expected_name
+ or defining_symbol.get("name_path") == expected_name
+ or expected_name in defining_symbol.get("info", "")
+ ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
+
+ if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]:
+ return
+ if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name):
+ assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}"
+
+ def _assert_find_defining_symbol_by_regex(
+ self,
+ serena_agent: SerenaAgent,
+ relative_path: str,
+ regex: str,
+ containing_symbol_name_path: str,
+ expected_name: str,
+ expected_definition_file: str,
+ ) -> None:
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
+ result = find_defining_symbol_tool.apply(
+ regex=regex,
+ relative_path=relative_path,
+ containing_symbol_name_path=containing_symbol_name_path,
+ include_info=True,
+ )
+ defining_symbol = json.loads(result)
+
+ assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}"
+ assert defining_symbol.get("relative_path") is not None
+ assert expected_definition_file in defining_symbol["relative_path"], (
+ f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
+ )
+ assert (
+ defining_symbol.get("name") == expected_name
+ or defining_symbol.get("name_path") == expected_name
+ or expected_name in defining_symbol.get("info", "")
+ ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
+
+ if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]:
+ return
+ if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name):
+ assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}"
+
+ def _assert_diagnostics_for_file(
+ self,
+ serena_agent: SerenaAgent,
+ diagnostic_case: DiagnosticCase,
+ start_line: int = 0,
+ end_line: int = -1,
+ ) -> None:
+ diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool)
+ result = diagnostics_tool.apply(
+ relative_path=diagnostic_case.relative_path,
+ start_line=start_line,
+ end_line=end_line,
+ min_severity=1,
+ )
+ grouped_diagnostics = json.loads(result)
+
+ assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics
+ severity_group = grouped_diagnostics[diagnostic_case.relative_path]
+ assert "Error" in severity_group, severity_group
+ name_path_group = severity_group["Error"]
+
+ for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]:
+ assert expected_name_path in name_path_group, name_path_group
+
+ diagnostic_messages = [
+ diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path
+ ]
+ for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]:
+ assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages
+
+ def _assert_diagnostics_for_symbol(
+ self,
+ serena_agent: SerenaAgent,
+ diagnostic_case: DiagnosticCase,
+ check_symbol_references: bool,
+ ) -> None:
+ diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool)
+ result = diagnostics_tool.apply(
+ name_path=diagnostic_case.primary_symbol_name_path,
+ reference_file=diagnostic_case.relative_path,
+ check_symbol_references=check_symbol_references,
+ min_severity=1,
+ )
+ grouped_diagnostics = json.loads(result)
+ diagnostics_file = diagnostic_case.relative_path
+
+ assert diagnostics_file in grouped_diagnostics, grouped_diagnostics
+ severity_group = grouped_diagnostics[diagnostics_file]
+ assert "Error" in severity_group, severity_group
+ name_path_group = severity_group["Error"]
+
+ expected_name_paths = [diagnostic_case.primary_symbol_name_path]
+ expected_message_fragments = [diagnostic_case.primary_message_fragment]
+ if check_symbol_references:
+ expected_name_paths.append(diagnostic_case.reference_symbol_name_path)
+ expected_message_fragments.append(diagnostic_case.reference_message_fragment)
+
+ assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group
+ diagnostic_messages = [
+ diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path
+ ]
+ for expected_fragment in expected_message_fragments:
+ assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages
+
@pytest.mark.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
[
@@ -341,6 +724,161 @@ class TestSerenaAgent:
def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
+ @pytest.mark.parametrize(
+ "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file",
+ DEFINING_SYMBOL_TOOL_TEST_CASES,
+ indirect=["serena_agent"],
+ )
+ def test_find_defining_symbol(
+ self,
+ serena_agent: SerenaAgent,
+ relative_path: str,
+ identifier: str,
+ occurrence_index: int,
+ column_offset: int,
+ expected_name: str,
+ expected_definition_file: str,
+ ) -> None:
+ self._assert_find_defining_symbol(
+ serena_agent,
+ relative_path,
+ identifier,
+ occurrence_index,
+ column_offset,
+ expected_name,
+ expected_definition_file,
+ )
+
+ @pytest.mark.parametrize(
+ "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file",
+ REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES,
+ indirect=["serena_agent"],
+ )
+ def test_find_defining_symbol_by_regex(
+ self,
+ serena_agent: SerenaAgent,
+ relative_path: str,
+ regex: str,
+ containing_symbol_name_path: str,
+ expected_name: str,
+ expected_definition_file: str,
+ ) -> None:
+ self._assert_find_defining_symbol_by_regex(
+ serena_agent,
+ relative_path,
+ regex,
+ containing_symbol_name_path,
+ expected_name,
+ expected_definition_file,
+ )
+
+ @pytest.mark.parametrize(
+ "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment",
+ [
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ r"(User)",
+ "",
+ "Expected exactly one regex match",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ r"User",
+ "UserService/create_user",
+ "must contain exactly one capturing group",
+ marks=pytest.mark.python,
+ ),
+ ],
+ indirect=["serena_agent"],
+ )
+ def test_find_defining_symbol_by_regex_error(
+ self,
+ serena_agent: SerenaAgent,
+ relative_path: str,
+ regex: str,
+ containing_symbol_name_path: str,
+ error_fragment: str,
+ ) -> None:
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
+ result = find_defining_symbol_tool.apply(
+ regex=regex,
+ relative_path=relative_path,
+ containing_symbol_name_path=containing_symbol_name_path,
+ )
+ assert result.startswith("Error: "), result
+ assert error_fragment in result, result
+
+ @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
+ def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
+ self._assert_diagnostics_for_file(
+ serena_agent,
+ diagnostic_case=diagnostic_case,
+ )
+
+ @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
+ def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
+ project_root = get_repo_path(diagnostic_case.language)
+ primary_position = find_identifier_occurrence_position(
+ project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier
+ )
+ reference_position = find_identifier_occurrence_position(
+ project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier
+ )
+ assert primary_position is not None
+ assert reference_position is not None
+
+ self._assert_diagnostics_for_file(
+ serena_agent,
+ diagnostic_case=DiagnosticCase(
+ language=diagnostic_case.language,
+ relative_path=diagnostic_case.relative_path,
+ primary_symbol_name_path=diagnostic_case.primary_symbol_name_path,
+ primary_symbol_identifier=diagnostic_case.primary_symbol_identifier,
+ reference_symbol_name_path=diagnostic_case.primary_symbol_name_path,
+ reference_symbol_identifier=diagnostic_case.reference_symbol_identifier,
+ primary_message_fragment=diagnostic_case.primary_message_fragment,
+ reference_message_fragment=diagnostic_case.primary_message_fragment,
+ ),
+ start_line=primary_position[0],
+ end_line=reference_position[0] - 1,
+ )
+
+ @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
+ def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
+ self._assert_diagnostics_for_symbol(
+ serena_agent,
+ diagnostic_case=diagnostic_case,
+ check_symbol_references=False,
+ )
+
+ @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
+ def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
+ self._assert_diagnostics_for_symbol(
+ serena_agent,
+ diagnostic_case=diagnostic_case,
+ check_symbol_references=True,
+ )
+
+ if IMPLEMENTATION_TOOL_TEST_CASES:
+
+ @pytest.mark.parametrize(
+ "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name",
+ IMPLEMENTATION_TOOL_TEST_CASES,
+ indirect=["serena_agent"],
+ )
+ def test_find_symbol_implementations(
+ self,
+ serena_agent: SerenaAgent,
+ symbol_name: str,
+ def_file: str,
+ impl_file: str,
+ expected_symbol_name: str,
+ ) -> None:
+ self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name)
+
@pytest.mark.parametrize(
"serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
[
@@ -588,6 +1126,62 @@ class TestSerenaAgent:
new_content = read_project_file(serena_agent.get_active_project(), relative_path)
assert repl in new_content
+ @pytest.mark.parametrize(
+ "serena_agent",
+ [
+ pytest.param(Language.PYTHON, marks=pytest.mark.python),
+ pytest.param(Language.PYTHON_TY, marks=pytest.mark.python),
+ ],
+ indirect=["serena_agent"],
+ )
+ def test_replace_content_reports_new_diagnostics(self, serena_agent: SerenaAgent):
+ """Tests that file-level edits report newly introduced diagnostics."""
+ relative_path = os.path.join("test_repo", "services.py")
+ replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
+
+ with project_file_modification_context(serena_agent, relative_path):
+ result = replace_content_tool.apply(
+ relative_path=relative_path,
+ needle="return container",
+ repl="return missing_container",
+ mode="literal",
+ )
+
+ diagnostics = parse_edit_diagnostics_result(result)
+ relative_path_result = diagnostics[relative_path]
+ diagnostic_messages = json.dumps(relative_path_result)
+ assert "missing_container" in diagnostic_messages
+ assert "create_service_container" in diagnostic_messages
+
+ @pytest.mark.parametrize(
+ "serena_agent",
+ [
+ pytest.param(Language.PYTHON, marks=pytest.mark.python),
+ pytest.param(Language.PYTHON_TY, marks=pytest.mark.python),
+ ],
+ indirect=["serena_agent"],
+ )
+ def test_replace_symbol_body_reports_new_diagnostics(self, serena_agent: SerenaAgent):
+ """Tests that symbol-level edits report newly introduced diagnostics."""
+ relative_path = os.path.join("test_repo", "services.py")
+ replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
+
+ with project_file_modification_context(serena_agent, relative_path):
+ result = replace_symbol_body_tool.apply(
+ name_path="create_service_container",
+ relative_path=relative_path,
+ body="""
+def create_service_container() -> dict[str, Any]:
+ return missing_container
+""",
+ )
+
+ diagnostics = parse_edit_diagnostics_result(result)
+ relative_path_result = diagnostics[relative_path]
+ diagnostic_messages = json.dumps(relative_path_result)
+ assert "missing_container" in diagnostic_messages
+ assert "create_service_container" in diagnostic_messages
+
@pytest.mark.parametrize(
"serena_agent",
[
diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py
index f7d9dad6..5e8e1c93 100644
--- a/test/solidlsp/clojure/__init__.py
+++ b/test/solidlsp/clojure/__init__.py
@@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool:
CLI_FAIL = _test_clojure_cli()
TEST_APP_PATH = Path("src") / "test_app"
-CORE_PATH = str(TEST_APP_PATH / "core.clj")
-UTILS_PATH = str(TEST_APP_PATH / "utils.clj")
+CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/")
+UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/")
def is_clojure_cli_available() -> bool:
diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py
index 91f67781..97e44e5e 100644
--- a/test/solidlsp/csharp/test_csharp_basic.py
+++ b/test/solidlsp/csharp/test_csharp_basic.py
@@ -18,6 +18,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
from solidlsp.settings import SolidLSPSettings
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -176,6 +177,33 @@ class TestCSharpLanguageServer:
assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}"
assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}"
+ if language_has_verified_implementation_support(Language.CSHARP):
+
+ @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.CSHARP)
+ pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting")
+ assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture"
+
+ implementations = language_server.request_implementation("Services/IGreeter.cs", *pos)
+ assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting"
+ assert any("ConsoleGreeter.cs" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.CSHARP)
+ pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting")
+ assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("Services/IGreeter.cs", *pos)
+ assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting"
+ assert any(
+ symbol.get("name") == "FormatGreeting" and "ConsoleGreeter.cs" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}"
+
@pytest.mark.csharp
class TestCSharpSolutionProjectOpening:
diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py
index e7a45235..9a73ece1 100644
--- a/test/solidlsp/fortran/test_fortran_basic.py
+++ b/test/solidlsp/fortran/test_fortran_basic.py
@@ -11,6 +11,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
# Mark all tests in this module as fortran tests
@@ -38,6 +39,33 @@ class TestFortranLanguageServer:
# Verify subroutine symbol
assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree"
+ if language_has_verified_implementation_support(Language.FORTRAN):
+
+ @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.FORTRAN)
+ pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance")
+ assert pos is not None, "Could not find interface distance in geometry.f90"
+
+ implementations = language_server.request_implementation("modules/geometry.f90", *pos)
+ assert implementations, "Expected implementations for geometry_types.distance"
+ implementation_files = {implementation.get("relativePath", "") for implementation in implementations}
+ assert implementation_files == {"modules/geometry.f90"}, f"Unexpected implementation locations: {implementations}"
+ assert len(implementations) >= 2, f"Expected module procedure implementations, got: {implementations}"
+
+ @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.FORTRAN)
+ pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance")
+ assert pos is not None, "Could not find interface distance in geometry.f90"
+
+ implementing_symbols = language_server.request_implementing_symbols("modules/geometry.f90", *pos)
+ assert implementing_symbols, "Expected implementing symbols for geometry_types.distance"
+ implementing_symbol_names = {symbol.get("name") for symbol in implementing_symbols}
+ assert {"distance_2d", "distance_3d"}.issubset(implementing_symbol_names), (
+ f"Expected distance_2d and distance_3d, got: {implementing_symbols}"
+ )
+
@pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True)
def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test that document symbols can be retrieved from Fortran files."""
diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py
index d0f52443..f58ec0b1 100644
--- a/test/solidlsp/fsharp/test_fsharp_basic.py
+++ b/test/solidlsp/fsharp/test_fsharp_basic.py
@@ -7,7 +7,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
-from test.conftest import is_ci
+from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -39,6 +39,33 @@ class TestFSharpLanguageServer:
symbol_names = [s.get("name") for s in symbols]
assert "main" in symbol_names, "main function not found in Program.fs symbols"
+ if language_has_verified_implementation_support(Language.FSHARP):
+
+ @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.FSHARP)
+ pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting")
+ assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture"
+
+ implementations = language_server.request_implementation("Formatter.fs", *pos)
+ assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting"
+ assert any("Formatter.fs" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.FSHARP)
+ pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting")
+ assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("Formatter.fs", *pos)
+ assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting"
+ assert any(
+ symbol.get("name") == "FormatGreeting" and "Formatter.fs" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None:
"""Test getting document symbols from Calculator.fs file."""
diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py
index 93fcd98e..02aa3b9b 100644
--- a/test/solidlsp/go/test_go_basic.py
+++ b/test/solidlsp/go/test_go_basic.py
@@ -7,6 +7,7 @@ from serena.symbol import LanguageServerSymbol
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -47,6 +48,33 @@ class TestGoLanguageServer:
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go"
+ if language_has_verified_implementation_support(Language.GO):
+
+ @pytest.mark.parametrize("language_server", [Language.GO], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.GO)
+ pos = find_identifier_position(repo_path / "main.go", "FormatGreeting")
+ assert pos is not None, "Could not find Greeter.FormatGreeting in fixture"
+
+ implementations = language_server.request_implementation("main.go", *pos)
+ assert implementations, "Expected at least one implementation of Greeter.FormatGreeting"
+ assert any("main.go" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.GO], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.GO)
+ pos = find_identifier_position(repo_path / "main.go", "FormatGreeting")
+ assert pos is not None, "Could not find Greeter.FormatGreeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("main.go", *pos)
+ assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting"
+ assert any(
+ symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}"
+
def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list:
"""Filter workspace symbols to exact name matches in the test repo."""
diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py
index 56b3bbf4..4c50c3c4 100644
--- a/test/solidlsp/java/test_java_basic.py
+++ b/test/solidlsp/java/test_java_basic.py
@@ -5,7 +5,12 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
-from test.conftest import language_tests_enabled
+from test.conftest import (
+ find_identifier_position,
+ get_repo_path,
+ language_has_verified_implementation_support,
+ language_tests_enabled,
+)
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")]
@@ -52,6 +57,33 @@ class TestJavaLanguageServer:
assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview"
+ if language_has_verified_implementation_support(Language.JAVA):
+
+ @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.JAVA)
+ pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting")
+ assert pos is not None, "Could not find Greeter.formatGreeting in fixture"
+
+ implementations = language_server.request_implementation("src/main/java/test_repo/Greeter.java", *pos)
+ assert implementations, "Expected at least one implementation of Greeter.formatGreeting"
+ assert any("ConsoleGreeter.java" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.JAVA)
+ pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting")
+ assert pos is not None, "Could not find Greeter.formatGreeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("src/main/java/test_repo/Greeter.java", *pos)
+ assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting"
+ assert any(
+ symbol.get("name") == "formatGreeting" and "ConsoleGreeter.java" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
all_symbols = request_all_symbols(language_server)
diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py
index f140881b..5465fda1 100644
--- a/test/solidlsp/lua/test_lua_basic.py
+++ b/test/solidlsp/lua/test_lua_basic.py
@@ -10,6 +10,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -82,6 +83,33 @@ class TestLuaLanguageServer:
# Check for Logger class/table
assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols"
+ if language_has_verified_implementation_support(Language.LUA):
+
+ @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.LUA)
+ pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak")
+ assert pos is not None, "Could not find Animal:speak in fixture"
+
+ implementations = language_server.request_implementation("src/animals.lua", *pos)
+ assert implementations, "Expected at least one implementation of Animal:speak"
+ assert any("animals.lua" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected Dog:speak in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.LUA)
+ pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak")
+ assert pos is not None, "Could not find Animal:speak in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("src/animals.lua", *pos)
+ assert implementing_symbols, "Expected implementing symbols for Animal:speak"
+ assert any(
+ symbol.get("name") == "speak" and "animals.lua" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected Dog:speak symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.LUA], indirect=True)
def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None:
"""Test finding functions in main.lua."""
@@ -133,7 +161,7 @@ class TestLuaLanguageServer:
assert refs is not None
assert isinstance(refs, list)
- # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24)
+ # add function appears in: main.lua (lines 17, 78), test_calculator.lua (lines 22, 23, 24)
# Note: The declaration itself may or may not be included as a reference
assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}"
@@ -153,7 +181,7 @@ class TestLuaLanguageServer:
# Check main.lua has usages
assert "main.lua" in ref_files, "Should find add usages in main.lua"
- assert 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"], (
+ assert 16 in ref_files["main.lua"] or 77 in ref_files["main.lua"], (
f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}"
)
@@ -189,7 +217,7 @@ class TestLuaLanguageServer:
assert refs is not None
assert isinstance(refs, list)
- # trim function appears in: usage (line 32 in main.lua)
+ # trim function appears in: usage (line 33 in main.lua)
# Note: The declaration itself may or may not be included as a reference
assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}"
@@ -209,8 +237,8 @@ class TestLuaLanguageServer:
# Check main.lua has usage
assert "main.lua" in ref_files, "Should find trim usage in main.lua"
- assert 31 in ref_files["main.lua"], (
- f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}"
+ assert 32 in ref_files["main.lua"], (
+ f"Should find trim usage at line 33 (0-indexed: 32) in main.lua, found at lines {ref_files.get('main.lua', [])}"
)
# Check for cross-file references from main.lua
diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py
index f57b37bd..f3d46f0f 100644
--- a/test/solidlsp/python/test_python_basic.py
+++ b/test/solidlsp/python/test_python_basic.py
@@ -12,6 +12,7 @@ import pytest
from serena.project import Project
from serena.util.text_utils import LineType
from solidlsp import SolidLanguageServer
+from test.conftest import PYTHON_LANGUAGE_BACKENDS
from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -79,6 +80,26 @@ class TestPythonLanguageServerBasics:
references = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)"
+ @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True)
+ def test_request_text_document_diagnostics_with_filters(self, language_server: SolidLanguageServer) -> None:
+ file_path = os.path.join("test_repo", "diagnostics_sample.py")
+
+ diagnostics = language_server.request_text_document_diagnostics(file_path)
+ assert len(diagnostics) >= 2
+ diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics]
+ assert any("missing_user" in message for message in diagnostic_messages), diagnostic_messages
+ assert any("undefined_name" in message for message in diagnostic_messages), diagnostic_messages
+
+ factory_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=3, end_line=5, min_severity=1)
+ factory_messages = [diagnostic["message"] for diagnostic in factory_diagnostics]
+ assert factory_messages, "Expected diagnostics in broken_factory range"
+ assert all("missing_user" in message for message in factory_messages), factory_messages
+
+ consumer_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=7, end_line=10, min_severity=1)
+ consumer_messages = [diagnostic["message"] for diagnostic in consumer_diagnostics]
+ assert consumer_messages, "Expected diagnostics in broken_consumer range"
+ assert all("undefined_name" in message for message in consumer_messages), consumer_messages
+
class TestProjectBasics:
@pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True)
diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py
index 814892d0..29fce938 100644
--- a/test/solidlsp/rust/test_rust_basic.py
+++ b/test/solidlsp/rust/test_rust_basic.py
@@ -5,6 +5,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -57,6 +58,33 @@ class TestRustLanguageServer:
assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview"
assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview"
+ if language_has_verified_implementation_support(Language.RUST):
+
+ @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.RUST)
+ pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting")
+ assert pos is not None, "Could not find Greeter.format_greeting in fixture"
+
+ implementations = language_server.request_implementation(os.path.join("src", "lib.rs"), *pos)
+ assert implementations, "Expected at least one implementation of Greeter.format_greeting"
+ assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), (
+ f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.RUST)
+ pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting")
+ assert pos is not None, "Could not find Greeter.format_greeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols(os.path.join("src", "lib.rs"), *pos)
+ assert implementing_symbols, "Expected implementing symbols for Greeter.format_greeting"
+ assert any(
+ symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.RUST], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
all_symbols = request_all_symbols(language_server)
diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py
index e095871f..2279140e 100644
--- a/test/solidlsp/solidity/test_solidity_basic.py
+++ b/test/solidlsp/solidity/test_solidity_basic.py
@@ -13,6 +13,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
+from test.conftest import language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -165,6 +166,33 @@ class TestSolidityLanguageServerBasics:
ref_files = {ref.get("uri", "") for ref in references}
assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol"
+ if language_has_verified_implementation_support(Language.SOLIDITY):
+
+ @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True)
+ @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
+ pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer")
+ assert pos is not None, "Should find 'transfer' identifier in IERC20.sol"
+
+ implementations = language_server.request_implementation("contracts/interfaces/IERC20.sol", *pos)
+ assert implementations, "Expected Token.transfer to be returned as an implementation"
+ assert any("Token.sol" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected Token.transfer implementation, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True)
+ @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
+ pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer")
+ assert pos is not None, "Should find 'transfer' identifier in IERC20.sol"
+
+ implementing_symbols = language_server.request_implementing_symbols("contracts/interfaces/IERC20.sol", *pos)
+ assert implementing_symbols, "Expected implementing symbols for IERC20.transfer"
+ assert any(
+ symbol.get("name") == "transfer" and "Token.sol" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected Token.transfer symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
all_symbols = request_all_symbols(language_server)
diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py
new file mode 100644
index 00000000..c0509a2f
--- /dev/null
+++ b/test/solidlsp/test_defining_symbol_matrix.py
@@ -0,0 +1,139 @@
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.conftest import find_identifier_occurrence_position, is_ci
+from test.solidlsp import clojure as clj
+
+
+@pytest.mark.parametrize(
+ "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file",
+ [
+ pytest.param(
+ Language.PYTHON,
+ os.path.join("test_repo", "services.py"),
+ "User",
+ 1,
+ 1,
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(
+ Language.PYTHON_TY,
+ os.path.join("test_repo", "services.py"),
+ "User",
+ 1,
+ 1,
+ "User",
+ "models.py",
+ marks=pytest.mark.python,
+ ),
+ pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go),
+ pytest.param(
+ Language.JAVA,
+ os.path.join("src", "main", "java", "test_repo", "Main.java"),
+ "Model",
+ 0,
+ 1,
+ "Model",
+ "Model.java",
+ marks=pytest.mark.java,
+ ),
+ pytest.param(
+ Language.KOTLIN,
+ os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
+ "Model",
+ 0,
+ 1,
+ "Model",
+ "Model.kt",
+ marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
+ ),
+ pytest.param(
+ Language.RUST,
+ os.path.join("src", "main.rs"),
+ "format_greeting",
+ 0,
+ 1,
+ "format_greeting",
+ "lib.rs",
+ marks=pytest.mark.rust,
+ ),
+ pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php),
+ pytest.param(
+ Language.CLOJURE,
+ clj.UTILS_PATH,
+ "multiply",
+ 0,
+ 1,
+ "multiply",
+ clj.CORE_PATH,
+ marks=[
+ pytest.mark.clojure,
+ pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"),
+ ],
+ ),
+ pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp),
+ pytest.param(
+ Language.POWERSHELL,
+ "main.ps1",
+ "Convert-ToUpperCase",
+ 0,
+ 1,
+ "function Convert-ToUpperCase ()",
+ "utils.ps1",
+ marks=pytest.mark.powershell,
+ ),
+ pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp),
+ pytest.param(
+ Language.LEAN4,
+ "Main.lean",
+ "add",
+ 0,
+ 1,
+ "add",
+ "Helper.lean",
+ marks=[
+ pytest.mark.lean4,
+ pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"),
+ pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"),
+ ],
+ ),
+ pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript),
+ pytest.param(
+ Language.FSHARP,
+ "Program.fs",
+ "add",
+ 0,
+ 1,
+ "add",
+ "Calculator.fs",
+ marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")],
+ ),
+ ],
+ indirect=["language_server"],
+)
+def test_request_defining_symbol_matrix(
+ language_server: SolidLanguageServer,
+ relative_path: str,
+ identifier: str,
+ occurrence_index: int,
+ column_offset: int,
+ expected_name: str,
+ expected_definition_file: str,
+) -> None:
+ repo_root = Path(language_server.repository_root_path)
+ position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset)
+ assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}"
+
+ defining_symbol = language_server.request_defining_symbol(relative_path, *position)
+ assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}"
+ assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
+ assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), (
+ f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
+ )
diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py
new file mode 100644
index 00000000..2d1b50fe
--- /dev/null
+++ b/test/solidlsp/test_diagnostics_matrix.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+
+import pytest
+
+from serena.symbol import LanguageServerSymbolRetriever
+from solidlsp import SolidLanguageServer
+from test.conftest import find_identifier_position
+from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase
+
+
+@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"])
+def test_request_text_document_diagnostics_matrix(
+ language_server: SolidLanguageServer,
+ diagnostic_case: DiagnosticCase,
+) -> None:
+ diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1)
+ assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}"
+
+ diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics]
+ assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages
+ assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages
+
+ repo_root = language_server.repository_root_path
+ primary_symbol_position = find_identifier_position(
+ Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier
+ )
+ reference_symbol_position = find_identifier_position(
+ Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier
+ )
+ assert primary_symbol_position is not None
+ assert reference_symbol_position is not None
+
+ primary_diagnostics = language_server.request_text_document_diagnostics(
+ diagnostic_case.relative_path,
+ start_line=primary_symbol_position[0],
+ end_line=reference_symbol_position[0] - 1,
+ min_severity=1,
+ )
+ primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics]
+ assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}"
+ assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages
+ assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages
+
+
+@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"])
+def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None:
+ symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
+
+ diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics(
+ diagnostic_case.primary_symbol_name_path,
+ reference_file=diagnostic_case.relative_path,
+ min_severity=1,
+ )
+ diagnostic_messages_by_symbol = {
+ symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics]
+ for symbol, diagnostics in diagnostics_by_symbol.items()
+ }
+ assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol
+ assert any(
+ diagnostic_case.primary_message_fragment in message
+ for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path]
+ ), diagnostic_messages_by_symbol
+
+ diagnostics_with_references = symbol_retriever.get_symbol_diagnostics(
+ diagnostic_case.primary_symbol_name_path,
+ reference_file=diagnostic_case.relative_path,
+ check_symbol_references=True,
+ min_severity=1,
+ )
+ diagnostic_messages_with_references = {
+ symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics]
+ for symbol, diagnostics in diagnostics_with_references.items()
+ }
+ assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references
+ assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references
+ assert any(
+ diagnostic_case.reference_message_fragment in message
+ for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path]
+ ), diagnostic_messages_with_references
diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py
index e5e00915..cfa1d11d 100644
--- a/test/solidlsp/test_ls_common.py
+++ b/test/solidlsp/test_ls_common.py
@@ -3,13 +3,13 @@ import os
import pytest
from solidlsp import SolidLanguageServer
-from solidlsp.ls_config import Language
+from test.conftest import PYTHON_LANGUAGE_BACKENDS
class TestLanguageServerCommonFunctionality:
"""Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour)."""
- @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
+ @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True)
def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None:
"""
Tests that the file buffer cache is invalidated when the file is changed on disk.
diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py
index ba86ba04..fa9b1566 100644
--- a/test/solidlsp/typescript/test_typescript_basic.py
+++ b/test/solidlsp/typescript/test_typescript_basic.py
@@ -5,6 +5,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
+from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -33,6 +34,33 @@ class TestTypescriptLanguageServer:
"index.ts should reference helperFunction (tried all positions in selectionRange)"
)
+ if language_has_verified_implementation_support(Language.TYPESCRIPT):
+
+ @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
+ def test_find_implementations(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.TYPESCRIPT)
+ pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting")
+ assert pos is not None, "Could not find Greeter.formatGreeting in fixture"
+
+ implementations = language_server.request_implementation("formatters.ts", *pos)
+ assert implementations, "Expected at least one implementation of Greeter.formatGreeting"
+ assert any("formatters.ts" in implementation.get("relativePath", "") for implementation in implementations), (
+ f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}"
+ )
+
+ @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
+ def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None:
+ repo_path = get_repo_path(Language.TYPESCRIPT)
+ pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting")
+ assert pos is not None, "Could not find Greeter.formatGreeting in fixture"
+
+ implementing_symbols = language_server.request_implementing_symbols("formatters.ts", *pos)
+ assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting"
+ assert any(
+ symbol.get("name") == "formatGreeting" and "formatters.ts" in symbol["location"].get("relativePath", "")
+ for symbol in implementing_symbols
+ ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}"
+
@pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
all_symbols = request_all_symbols(language_server)
From 940bbb31def37893426b459a2da4805c3a4848a8 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 25 Mar 2026 17:01:23 +0100
Subject: [PATCH 18/32] Restructured serena_agent tests, reifying the cases
Removed some cases (haxe, msl) during cherry pick due to conflicts. We don't need to cover these languages in this test
---
test/conftest.py | 2 +-
test/diagnostics_cases.py | 230 ---
test/serena/test_serena_agent.py | 1818 +++++++++---------
test/solidlsp/test_defining_symbol_matrix.py | 139 --
test/solidlsp/test_diagnostics_matrix.py | 79 -
5 files changed, 912 insertions(+), 1356 deletions(-)
delete mode 100644 test/diagnostics_cases.py
delete mode 100644 test/solidlsp/test_defining_symbol_matrix.py
delete mode 100644 test/solidlsp/test_diagnostics_matrix.py
diff --git a/test/conftest.py b/test/conftest.py
index 5e55b180..7672bde6 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -370,7 +370,7 @@ def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int
return None
-def find_identifier_occurrence_position(
+def find_identifier_pos(
file_path: Path,
identifier: str,
occurrence_index: int = 0,
diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py
deleted file mode 100644
index 8cfcfacb..00000000
--- a/test/diagnostics_cases.py
+++ /dev/null
@@ -1,230 +0,0 @@
-import os
-from dataclasses import dataclass
-from typing import cast
-
-import pytest
-from _pytest.mark import Mark, MarkDecorator
-
-from solidlsp.ls_config import Language
-from test.conftest import get_pytest_markers
-
-
-@dataclass(frozen=True)
-class DiagnosticCase:
- language: Language
- relative_path: str
- primary_symbol_name_path: str
- primary_symbol_identifier: str
- reference_symbol_name_path: str
- reference_symbol_identifier: str
- primary_message_fragment: str
- reference_message_fragment: str
-
-
-def diagnostic_case_param(
- case: DiagnosticCase,
- *marks: MarkDecorator | Mark,
- id: str,
-):
- return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id)
-
-
-DIAGNOSTIC_CASE_PARAMS = [
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.PYTHON,
- relative_path=os.path.join("test_repo", "diagnostics_sample.py"),
- primary_symbol_name_path="broken_factory",
- primary_symbol_identifier="broken_factory",
- reference_symbol_name_path="broken_consumer",
- reference_symbol_identifier="broken_consumer",
- primary_message_fragment="missing_user",
- reference_message_fragment="undefined_name",
- ),
- id="python_missing_user",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.PYTHON_TY,
- relative_path=os.path.join("test_repo", "diagnostics_sample.py"),
- primary_symbol_name_path="broken_factory",
- primary_symbol_identifier="broken_factory",
- reference_symbol_name_path="broken_consumer",
- reference_symbol_identifier="broken_consumer",
- primary_message_fragment="missing_user",
- reference_message_fragment="undefined_name",
- ),
- id="python_ty_missing_user",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.GO,
- relative_path="diagnostics_sample.go",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- id="go_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.JAVA,
- relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"),
- primary_symbol_name_path="DiagnosticsSample/brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="DiagnosticsSample/brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- id="java_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.KOTLIN,
- relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"),
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- id="kotlin_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.RUST,
- relative_path=os.path.join("src", "diagnostics_sample.rs"),
- primary_symbol_name_path="broken_factory",
- primary_symbol_identifier="broken_factory",
- reference_symbol_name_path="broken_consumer",
- reference_symbol_identifier="broken_consumer",
- primary_message_fragment="missing_greeting",
- reference_message_fragment="missing_consumer_value",
- ),
- pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"),
- id="rust_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.PHP,
- relative_path="diagnostics_sample.php",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"),
- id="php_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.CLOJURE,
- relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"),
- primary_symbol_name_path="broken-factory",
- primary_symbol_identifier="broken-factory",
- reference_symbol_name_path="broken-consumer",
- reference_symbol_identifier="broken-consumer",
- primary_message_fragment="missing-greeting",
- reference_message_fragment="missing-consumer-value",
- ),
- id="clojure_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.CSHARP,
- relative_path="DiagnosticsSample.cs",
- primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory",
- primary_symbol_identifier="BrokenFactory",
- reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer",
- reference_symbol_identifier="BrokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- id="csharp_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.POWERSHELL,
- relative_path="diagnostics_sample.ps1",
- primary_symbol_name_path="function Invoke-BrokenFactory ()",
- primary_symbol_identifier="Invoke-BrokenFactory",
- reference_symbol_name_path="function Invoke-BrokenConsumer ()",
- reference_symbol_identifier="Invoke-BrokenConsumer",
- primary_message_fragment="MissingGreeting",
- reference_message_fragment="MissingConsumerValue",
- ),
- pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"),
- id="powershell_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.CPP_CCLS,
- relative_path="diagnostics_sample.cpp",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"),
- id="cpp_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.LEAN4,
- relative_path="DiagnosticsSample.lean",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"),
- id="lean_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.TYPESCRIPT,
- relative_path="diagnostics_sample.ts",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- pytest.mark.typescript,
- pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"),
- id="typescript_missing_greeting",
- ),
- diagnostic_case_param(
- DiagnosticCase(
- language=Language.FSHARP,
- relative_path="DiagnosticsSample.fs",
- primary_symbol_name_path="brokenFactory",
- primary_symbol_identifier="brokenFactory",
- reference_symbol_name_path="brokenConsumer",
- reference_symbol_identifier="brokenConsumer",
- primary_message_fragment="missingGreeting",
- reference_message_fragment="missingConsumerValue",
- ),
- pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"),
- id="fsharp_missing_greeting",
- ),
-]
-
-WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [
- case_param
- for case_param in DIAGNOSTIC_CASE_PARAMS
- if cast(DiagnosticCase, case_param.values[1]).language
- in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP}
-]
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index 62dc1a53..a577b849 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -2,15 +2,15 @@ import json
import logging
import os
import re
-import shutil
import time
from collections.abc import Iterator
from contextlib import contextmanager
-from typing import Literal
+from copy import copy
+from dataclasses import dataclass
+from typing import Literal, cast
import pytest
-from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase
-from _pytest.mark import Mark, MarkDecorator
+from _pytest.mark import Mark, MarkDecorator, ParameterSet
from serena.agent import SerenaAgent
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
@@ -24,7 +24,6 @@ from serena.tools import (
FindReferencingSymbolsTool,
FindSymbolTool,
GetDiagnosticsForFileTool,
- GetDiagnosticsForSymbolTool,
InitialInstructionsTool,
ReplaceContentTool,
ReplaceSymbolBodyTool,
@@ -34,205 +33,726 @@ from serena.tools import (
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.conftest import (
- find_identifier_occurrence_position,
+ find_identifier_pos,
+ get_pytest_markers,
get_repo_path,
is_ci,
- language_has_verified_implementation_support,
language_tests_enabled,
)
-from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase
-from test.solidlsp import clojure as clj
-DEFINING_SYMBOL_TOOL_TEST_CASES = [
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- "User",
- 1,
- 1,
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON_TY,
- os.path.join("test_repo", "services.py"),
- "User",
- 1,
- 1,
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go),
- pytest.param(
- Language.JAVA,
- os.path.join("src", "main", "java", "test_repo", "Main.java"),
- "Model",
- 0,
- 1,
- "Model",
- "Model.java",
- marks=pytest.mark.java,
- ),
- pytest.param(
- Language.KOTLIN,
- os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
- "Model",
- 0,
- 1,
- "Model",
- "Model.kt",
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(
- Language.RUST,
- os.path.join("src", "main.rs"),
- "format_greeting",
- 0,
- 1,
- "format_greeting",
- "lib.rs",
- marks=pytest.mark.rust,
- ),
- pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php),
- pytest.param(
- Language.CLOJURE,
- clj.UTILS_PATH,
- "multiply",
- 0,
- 1,
- "multiply",
- clj.CORE_PATH,
- marks=[
- pytest.mark.clojure,
- pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"),
- ],
- ),
- pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp),
- pytest.param(
- Language.POWERSHELL,
- "main.ps1",
- "Convert-ToUpperCase",
- 0,
- 1,
- "function Convert-ToUpperCase ()",
- "utils.ps1",
- marks=pytest.mark.powershell,
- ),
- pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp),
- pytest.param(
- Language.LEAN4,
- "Main.lean",
- "add",
- 0,
- 1,
- "add",
- "Helper.lean",
- marks=[
- pytest.mark.lean4,
- pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"),
- ],
- ),
- pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript),
- pytest.param(
- Language.FSHARP,
- "Program.fs",
- "add",
- 0,
- 1,
- "add",
- "Calculator.fs",
- marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")],
+
+@dataclass
+class BaseCase:
+ language: Language
+ id: str
+
+ def to_pytest_param(self, *marks: MarkDecorator | Mark) -> ParameterSet:
+ return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) # type: ignore
+
+
+@dataclass
+class FindSymbolCase(BaseCase):
+ symbol_name: str
+ expected_kind: str
+ expected_file: str
+
+
+@dataclass
+class FindReferenceCase(BaseCase):
+ symbol_name: str
+ definition_file: str
+ reference_file: str
+
+
+@dataclass
+class FindDefiningSymbolCase(BaseCase):
+ relative_path: str
+ identifier: str
+ occurrence_index: int
+ column_offset: int
+ expected_name: str
+ expected_definition_file: str
+
+
+@dataclass
+class RegexDefiningSymbolCase(BaseCase):
+ relative_path: str
+ regex: str
+ containing_symbol_name_path: str
+ expected_name: str
+ expected_definition_file: str
+
+
+@dataclass
+class RegexDefiningSymbolErrorCase(BaseCase):
+ relative_path: str
+ regex: str
+ containing_symbol_name_path: str
+ error_fragment: str
+
+
+@dataclass
+class FindImplementationCase(BaseCase):
+ symbol_name: str
+ definition_file: str
+ implementation_file: str
+ expected_symbol_name: str
+
+
+@dataclass
+class FindSymbolNamePathCase(BaseCase):
+ name_path: str
+ substring_matching: bool
+ expected_symbol_name: str
+ expected_kind: str
+ expected_file: str
+
+
+@dataclass
+class FindSymbolNoMatchCase(BaseCase):
+ name_path: str
+
+
+@dataclass
+class FindSymbolOverloadedCase(BaseCase):
+ name_path: str
+ num_expected: int
+
+
+@dataclass
+class NonUniqueSymbolReferenceCase(BaseCase):
+ name_path: str
+ relative_path: str
+ expected_error_fragment: str = "multiple"
+
+
+@dataclass
+class SafeDeleteCase(BaseCase):
+ name_path: str
+ relative_path: str
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "relative_path", self.relative_path.replace("\\", "/"))
+
+
+@dataclass
+class DiagnosticCase(BaseCase):
+ language: Language
+ relative_path: str
+ name_path1: str
+ name_path2: str | None
+ message_fragment1: str
+ message_fragment2: str | None
+
+ @property
+ def symbol1_id_str(self) -> str:
+ return self.name_path1.split("/")[-1]
+
+ @property
+ def symbol2_id_str(self) -> str | None:
+ if self.name_path2 is None:
+ return None
+ return self.name_path2.split("/")[-1]
+
+ def without_second_symbol(self) -> "DiagnosticCase":
+ result = copy(self)
+ result.name_path2 = None
+ result.message_fragment2 = None
+ return result
+
+ def assert_matches(self, tool_output: dict) -> None:
+ """
+
+ :param tool_output: Output of diagnostics tool, representing the mapping `relative_path -> severity -> name_path -> diagnostics_results`.
+ :return:
+ """
+ assert self.relative_path in tool_output, (
+ f"Missing diagnostics for relative path {self.relative_path} in tool output keys: {list(tool_output.keys())}"
+ )
+ severity_group = tool_output[self.relative_path]
+ assert "Error" in severity_group, severity_group
+ name_path_group = severity_group["Error"]
+
+ for expected_name_path in [self.name_path1, self.name_path2]:
+ if expected_name_path is not None:
+ assert expected_name_path in name_path_group, name_path_group
+
+ diagnostic_messages = [
+ diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path
+ ]
+ for expected_fragment in [self.message_fragment1, self.message_fragment2]:
+ if expected_fragment is not None:
+ assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages
+
+
+DIAGNOSTIC_CASES = [
+ DiagnosticCase(
+ language=Language.PYTHON,
+ id=f"{Language.PYTHON.value}_missing_user",
+ relative_path=os.path.join("test_repo", "diagnostics_sample.py"),
+ name_path1="broken_factory",
+ name_path2="broken_consumer",
+ message_fragment1="missing_user",
+ message_fragment2="undefined_name",
+ ).to_pytest_param(),
+ DiagnosticCase(
+ language=Language.CLOJURE,
+ id=f"{Language.CLOJURE.value}_missing-greeting",
+ relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"),
+ name_path1="broken-factory",
+ name_path2="broken-consumer",
+ message_fragment1="missing-greeting",
+ message_fragment2="missing-consumer-value",
+ ).to_pytest_param(),
+ DiagnosticCase(
+ language=Language.GO,
+ id=f"{Language.GO.value}_missingGreeting",
+ relative_path="diagnostics_sample.go",
+ name_path1="brokenFactory",
+ name_path2="brokenConsumer",
+ message_fragment1="missingGreeting",
+ message_fragment2="missingConsumerValue",
+ ).to_pytest_param(),
+ DiagnosticCase(
+ language=Language.TYPESCRIPT,
+ id=f"{Language.TYPESCRIPT.value}_missingGreeting",
+ relative_path="diagnostics_sample.ts",
+ name_path1="brokenFactory",
+ name_path2="brokenConsumer",
+ message_fragment1="missingGreeting",
+ message_fragment2="missingConsumerValue",
+ ).to_pytest_param(),
+]
+
+
+FIND_DEFINING_SYMBOL_CASES = [
+ FindDefiningSymbolCase(
+ language=Language.PYTHON,
+ id="python_user_in_services",
+ relative_path=os.path.join("test_repo", "services.py"),
+ identifier="User",
+ occurrence_index=1,
+ column_offset=1,
+ expected_name="User",
+ expected_definition_file="models.py",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.PYTHON_TY,
+ id="python_ty_user_in_services",
+ relative_path=os.path.join("test_repo", "services.py"),
+ identifier="User",
+ occurrence_index=1,
+ column_offset=1,
+ expected_name="User",
+ expected_definition_file="models.py",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.GO,
+ id="go_helper_in_main",
+ relative_path="main.go",
+ identifier="Helper",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="Helper",
+ expected_definition_file="main.go",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.JAVA,
+ id="java_model_in_main",
+ relative_path=os.path.join("src", "main", "java", "test_repo", "Main.java"),
+ identifier="Model",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="Model",
+ expected_definition_file="Model.java",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.KOTLIN,
+ id="kotlin_model_in_main",
+ relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
+ identifier="Model",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="Model",
+ expected_definition_file="Model.kt",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.RUST,
+ id="rust_format_greeting",
+ relative_path=os.path.join("src", "main.rs"),
+ identifier="format_greeting",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="format_greeting",
+ expected_definition_file="lib.rs",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.PHP,
+ id="php_helper_function",
+ relative_path="index.php",
+ identifier="helperFunction",
+ occurrence_index=0,
+ column_offset=5,
+ expected_name="helperFunction",
+ expected_definition_file="helper.php",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.CLOJURE,
+ id="clojure_multiply_in_utils",
+ relative_path=os.path.join("src", "test_app", "utils.clj"),
+ identifier="multiply",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="multiply",
+ expected_definition_file=os.path.join("src", "test_app", "core.clj"),
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.CSHARP,
+ id="csharp_add_in_program",
+ relative_path="Program.cs",
+ identifier="Add",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="Add",
+ expected_definition_file="Program.cs",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.POWERSHELL,
+ id="powershell_convert_to_uppercase",
+ relative_path="main.ps1",
+ identifier="Convert-ToUpperCase",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="function Convert-ToUpperCase ()",
+ expected_definition_file="utils.ps1",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.CPP_CCLS,
+ id="cpp_add_in_a",
+ relative_path="a.cpp",
+ identifier="add",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="add",
+ expected_definition_file="b.cpp",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.LEAN4,
+ id="lean_add_in_main",
+ relative_path="Main.lean",
+ identifier="add",
+ occurrence_index=1,
+ column_offset=1,
+ expected_name="add",
+ expected_definition_file="Helper.lean",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_helper_function",
+ relative_path="index.ts",
+ identifier="helperFunction",
+ occurrence_index=1,
+ column_offset=1,
+ expected_name="helperFunction",
+ expected_definition_file="index.ts",
+ ).to_pytest_param(),
+ FindDefiningSymbolCase(
+ language=Language.FSHARP,
+ id="fsharp_add_in_program",
+ relative_path="Program.fs",
+ identifier="add",
+ occurrence_index=0,
+ column_offset=1,
+ expected_name="add",
+ expected_definition_file="Calculator.fs",
+ ).to_pytest_param(
+ pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols"),
),
]
-REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- r"from \.models import Item, (User)",
- "",
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- r"=\s+(User)\(",
- "UserService/create_user",
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON_TY,
- os.path.join("test_repo", "services.py"),
- r"=\s+(User)\(",
- "UserService/create_user",
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.GO,
- "main.go",
- r"var greeter (Greeter) =",
- "main",
- "Greeter",
- "main.go",
- marks=pytest.mark.go,
+FIND_DEFINING_SYMBOL_REGEX_CASES = [
+ RegexDefiningSymbolCase(
+ language=Language.PYTHON,
+ id="python_import_user",
+ relative_path=os.path.join("test_repo", "services.py"),
+ regex=r"from \.models import Item, (User)",
+ containing_symbol_name_path="",
+ expected_name="User",
+ expected_definition_file="models.py",
+ ).to_pytest_param(),
+ RegexDefiningSymbolCase(
+ language=Language.PYTHON,
+ id="python_create_user_call",
+ relative_path=os.path.join("test_repo", "services.py"),
+ regex=r"=\s+(User)\(",
+ containing_symbol_name_path="UserService/create_user",
+ expected_name="User",
+ expected_definition_file="models.py",
+ ).to_pytest_param(),
+ RegexDefiningSymbolCase(
+ language=Language.PYTHON_TY,
+ id="python_ty_create_user_call",
+ relative_path=os.path.join("test_repo", "services.py"),
+ regex=r"=\s+(User)\(",
+ containing_symbol_name_path="UserService/create_user",
+ expected_name="User",
+ expected_definition_file="models.py",
+ ).to_pytest_param(),
+ RegexDefiningSymbolCase(
+ language=Language.GO,
+ id="go_greeter_var",
+ relative_path="main.go",
+ regex=r"var greeter (Greeter) =",
+ containing_symbol_name_path="main",
+ expected_name="Greeter",
+ expected_definition_file="main.go",
+ ).to_pytest_param(),
+]
+
+FIND_IMPLEMENTATION_CASES = [
+ FindImplementationCase(
+ language=Language.CSHARP,
+ id="csharp_greeter_format",
+ symbol_name="IGreeter/FormatGreeting",
+ definition_file=os.path.join("Services", "IGreeter.cs"),
+ implementation_file=os.path.join("Services", "ConsoleGreeter.cs"),
+ expected_symbol_name="FormatGreeting",
+ ).to_pytest_param(),
+ FindImplementationCase(
+ language=Language.GO,
+ id="go_greeter_format",
+ symbol_name="Greeter/FormatGreeting",
+ definition_file="main.go",
+ implementation_file="main.go",
+ expected_symbol_name="FormatGreeting",
+ ).to_pytest_param(),
+ FindImplementationCase(
+ language=Language.JAVA,
+ id="java_greeter_format",
+ symbol_name="Greeter/formatGreeting",
+ definition_file=os.path.join("src", "main", "java", "test_repo", "Greeter.java"),
+ implementation_file=os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"),
+ expected_symbol_name="formatGreeting",
+ ).to_pytest_param(),
+ FindImplementationCase(
+ language=Language.RUST,
+ id="rust_greeter_format",
+ symbol_name="Greeter/format_greeting",
+ definition_file=os.path.join("src", "lib.rs"),
+ implementation_file=os.path.join("src", "lib.rs"),
+ expected_symbol_name="format_greeting",
+ ).to_pytest_param(),
+ FindImplementationCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_greeter_format",
+ symbol_name="Greeter/formatGreeting",
+ definition_file="formatters.ts",
+ implementation_file="formatters.ts",
+ expected_symbol_name="formatGreeting",
+ ).to_pytest_param(),
+]
+
+
+FIND_SYMBOL_REFERENCES_CASES = [
+ FindSymbolCase(
+ language=Language.PYTHON, id="python_user_class", symbol_name="User", expected_kind="Class", expected_file="models.py"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.GO, id="go_helper_function", symbol_name="Helper", expected_kind="Function", expected_file="main.go"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.JAVA, id="java_model_class", symbol_name="Model", expected_kind="Class", expected_file="Model.java"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.KOTLIN, id="kotlin_model_struct", symbol_name="Model", expected_kind="Struct", expected_file="Model.kt"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_demo_class",
+ symbol_name="DemoClass",
+ expected_kind="Class",
+ expected_file="index.ts",
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.PHP,
+ id="php_helper_function",
+ symbol_name="helperFunction",
+ expected_kind="Function",
+ expected_file="helper.php",
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.CLOJURE,
+ id="clojure_greet_function",
+ symbol_name="greet",
+ expected_kind="Function",
+ expected_file=os.path.join("src", "test_app", "core.clj"),
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.CSHARP,
+ id="csharp_calculator_class",
+ symbol_name="Calculator",
+ expected_kind="Class",
+ expected_file="Program.cs",
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.POWERSHELL,
+ id="powershell_greet_user",
+ symbol_name="Greet-User",
+ expected_kind="Function",
+ expected_file="main.ps1",
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.CPP_CCLS, id="cpp_add_function", symbol_name="add", expected_kind="Function", expected_file="b.cpp"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.LEAN4, id="lean_add_method", symbol_name="add", expected_kind="Method", expected_file="Helper.lean"
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.FSHARP,
+ id="fsharp_calculator_module",
+ symbol_name="Calculator",
+ expected_kind="Module",
+ expected_file="Calculator.fs",
+ ).to_pytest_param(),
+ FindSymbolCase(
+ language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs"
+ ).to_pytest_param(),
+]
+
+FIND_REFERENCE_CASES = [
+ FindReferenceCase(
+ language=Language.PYTHON,
+ id="python_user_refs",
+ symbol_name="User",
+ definition_file=os.path.join("test_repo", "models.py"),
+ reference_file=os.path.join("test_repo", "services.py"),
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.GO, id="go_helper_refs", symbol_name="Helper", definition_file="main.go", reference_file="main.go"
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.JAVA,
+ id="java_model_refs",
+ symbol_name="Model",
+ definition_file=os.path.join("src", "main", "java", "test_repo", "Model.java"),
+ reference_file=os.path.join("src", "main", "java", "test_repo", "Main.java"),
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.KOTLIN,
+ id="kotlin_model_refs",
+ symbol_name="Model",
+ definition_file=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
+ reference_file=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.RUST,
+ id="rust_add_refs",
+ symbol_name="add",
+ definition_file=os.path.join("src", "lib.rs"),
+ reference_file=os.path.join("src", "main.rs"),
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.PHP,
+ id="php_helper_refs",
+ symbol_name="helperFunction",
+ definition_file="helper.php",
+ reference_file="index.php",
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.CLOJURE,
+ id="clojure_multiply_refs",
+ symbol_name="multiply",
+ definition_file=os.path.join("src", "test_app", "core.clj"),
+ reference_file=os.path.join("src", "test_app", "utils.clj"),
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.CSHARP,
+ id="csharp_calculator_refs",
+ symbol_name="Calculator",
+ definition_file="Program.cs",
+ reference_file="Program.cs",
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.POWERSHELL,
+ id="powershell_greet_user_refs",
+ symbol_name="Greet-User",
+ definition_file="main.ps1",
+ reference_file="main.ps1",
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.CPP_CCLS, id="cpp_add_refs", symbol_name="add", definition_file="b.cpp", reference_file="a.cpp"
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.LEAN4, id="lean_add_refs", symbol_name="add", definition_file="Helper.lean", reference_file="Main.lean"
+ ).to_pytest_param(),
+ FindReferenceCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_helper_refs",
+ symbol_name="helperFunction",
+ definition_file="index.ts",
+ reference_file="use_helper.ts",
+ ).to_pytest_param(pytest.mark.xfail(False, reason="TypeScript language server is unreliable")),
+ FindReferenceCase(
+ language=Language.FSHARP, id="fsharp_add_refs", symbol_name="add", definition_file="Calculator.fs", reference_file="Program.fs"
+ ).to_pytest_param(
+ pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040
),
]
-IMPLEMENTATION_TOOL_TEST_CASE_DATA = [
- (
- Language.CSHARP,
- "IGreeter/FormatGreeting",
- os.path.join("Services", "IGreeter.cs"),
- os.path.join("Services", "ConsoleGreeter.cs"),
- "FormatGreeting",
- pytest.mark.csharp,
- ),
- (
- Language.GO,
- "Greeter/FormatGreeting",
- "main.go",
- "main.go",
- "(ConsoleGreeter).FormatGreeting",
- pytest.mark.go,
- ),
- (
- Language.JAVA,
- "Greeter/formatGreeting",
- os.path.join("src", "main", "java", "test_repo", "Greeter.java"),
- os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"),
- "formatGreeting",
- pytest.mark.java,
- ),
- (
- Language.RUST,
- "Greeter/format_greeting",
- os.path.join("src", "lib.rs"),
- os.path.join("src", "lib.rs"),
- "format_greeting",
- pytest.mark.rust,
- ),
- (
- Language.TYPESCRIPT,
- "Greeter/formatGreeting",
- "formatters.ts",
- "formatters.ts",
- "formatGreeting",
- pytest.mark.typescript,
- ),
+FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [
+ RegexDefiningSymbolErrorCase(
+ language=Language.PYTHON,
+ id="python_regex_multiple_matches",
+ relative_path=os.path.join("test_repo", "services.py"),
+ regex=r"(User)",
+ containing_symbol_name_path="",
+ error_fragment="Expected exactly one regex match",
+ ).to_pytest_param(),
+ RegexDefiningSymbolErrorCase(
+ language=Language.PYTHON,
+ id="python_regex_missing_group",
+ relative_path=os.path.join("test_repo", "services.py"),
+ regex=r"User",
+ containing_symbol_name_path="UserService/create_user",
+ error_fragment="must contain exactly one capturing group",
+ ).to_pytest_param(),
]
-IMPLEMENTATION_TOOL_TEST_CASES = [
- pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark)
- for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA
- if language_has_verified_implementation_support(language)
+FIND_SYMBOL_NAME_PATH_CASES = [
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="nested_class_exact",
+ name_path="OuterClass/NestedClass",
+ substring_matching=False,
+ expected_symbol_name="NestedClass",
+ expected_kind="Class",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="nested_method_exact",
+ name_path="OuterClass/NestedClass/find_me",
+ substring_matching=False,
+ expected_symbol_name="find_me",
+ expected_kind="Method",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="nested_class_substring",
+ name_path="OuterClass/NestedCl",
+ substring_matching=True,
+ expected_symbol_name="NestedClass",
+ expected_kind="Class",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="nested_method_substring",
+ name_path="OuterClass/NestedClass/find_m",
+ substring_matching=True,
+ expected_symbol_name="find_me",
+ expected_kind="Method",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="outer_class_absolute",
+ name_path="/OuterClass",
+ substring_matching=False,
+ expected_symbol_name="OuterClass",
+ expected_kind="Class",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+ FindSymbolNamePathCase(
+ language=Language.PYTHON,
+ id="nested_method_absolute_substring",
+ name_path="/OuterClass/NestedClass/find_m",
+ substring_matching=True,
+ expected_symbol_name="find_me",
+ expected_kind="Method",
+ expected_file=os.path.join("test_repo", "nested.py"),
+ ).to_pytest_param(),
+]
+
+FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES = [
+ FindSymbolNoMatchCase(language=Language.PYTHON, id="nested_class_not_top_level", name_path="/NestedClass").to_pytest_param(),
+ FindSymbolNoMatchCase(
+ language=Language.PYTHON, id="nested_class_missing_parent", name_path="/NoSuchParent/NestedClass"
+ ).to_pytest_param(),
+]
+
+FIND_SYMBOL_OVERLOADED_FUNCTION_CASES = [
+ FindSymbolOverloadedCase(
+ language=Language.JAVA, id="java_overloaded_get_name", name_path="Model/getName", num_expected=2
+ ).to_pytest_param(),
+]
+
+NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES = [
+ NonUniqueSymbolReferenceCase(
+ language=Language.JAVA,
+ id="java_overloaded_get_name",
+ name_path="Model/getName",
+ relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"),
+ ).to_pytest_param(),
+]
+
+SAFE_DELETE_BLOCKED_CASES = [
+ SafeDeleteCase(
+ language=Language.PYTHON,
+ id="python_user",
+ name_path="User",
+ relative_path=os.path.join("test_repo", "models.py"),
+ ).to_pytest_param(),
+ SafeDeleteCase(
+ language=Language.JAVA,
+ id="java_model",
+ name_path="Model",
+ relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"),
+ ).to_pytest_param(),
+ SafeDeleteCase(
+ language=Language.KOTLIN,
+ id="kotlin_model",
+ name_path="Model",
+ relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
+ ).to_pytest_param(
+ *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
+ ),
+ SafeDeleteCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_helper_function",
+ name_path="helperFunction",
+ relative_path="index.ts",
+ ).to_pytest_param(),
+]
+
+SAFE_DELETE_SUCCEEDS_CASES = [
+ SafeDeleteCase(
+ language=Language.PYTHON,
+ id="python_timer",
+ name_path="Timer",
+ relative_path=os.path.join("test_repo", "utils.py"),
+ ).to_pytest_param(),
+ SafeDeleteCase(
+ language=Language.JAVA,
+ id="java_model_user",
+ name_path="ModelUser",
+ relative_path=os.path.join("src", "main", "java", "test_repo", "ModelUser.java"),
+ ).to_pytest_param(),
+ SafeDeleteCase(
+ language=Language.KOTLIN,
+ id="kotlin_model_user",
+ name_path="ModelUser",
+ relative_path=os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"),
+ ).to_pytest_param(
+ *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
+ ),
+ SafeDeleteCase(
+ language=Language.TYPESCRIPT,
+ id="typescript_unused_standalone_function",
+ name_path="unusedStandaloneFunction",
+ relative_path="index.ts",
+ ).to_pytest_param(),
]
@@ -334,7 +854,11 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere
class TestSerenaAgent:
- @pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"])
+ @pytest.mark.parametrize(
+ "project",
+ [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"],
+ ids=["no_project", "python_project_path", "invalid_project_path"],
+ )
def test_agent_instantiation(self, project: str | None):
"""
Tests agent instantiation for cases where
@@ -346,113 +870,62 @@ class TestSerenaAgent:
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False)
SerenaAgent(project=project, serena_config=serena_config)
- def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
- agent = serena_agent
- find_symbol_tool = agent.get_tool(FindSymbolTool)
- result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True)
+ def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool:
+ return (
+ symbol.get("name") == expected_name
+ or symbol.get("name_path", "").split("/")[-1] == expected_name
+ or expected_name in symbol.get("info", "")
+ )
- symbols = json.loads(result)
- assert any(
- symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"]
- for s in symbols
- ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}"
- # testing retrieval of symbol info
+ def _assert_symbol_info_present(
+ self,
+ serena_agent: SerenaAgent,
+ symbol: dict,
+ expected_name: str | None = None,
+ ) -> None:
if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]:
# kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on
return
- for s in symbols:
- if s["kind"] in (SymbolKind.File.name, SymbolKind.Module.name):
- # we ignore file and module symbols for the info test
- continue
- symbol_info = s.get("info")
- assert symbol_info, f"Expected symbol info to be present for symbol: {s}"
- assert symbol_name in s["info"], (
- f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}"
+
+ if symbol["kind"] in (SymbolKind.File.name, SymbolKind.Module.name):
+ # we ignore file and module symbols for the info test
+ return
+
+ symbol_info = symbol.get("info")
+ assert symbol_info, f"Expected symbol info to be present for symbol: {symbol}"
+
+ if expected_name is not None:
+ assert expected_name in symbol_info, (
+ f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name "
+ f"{expected_name}. Info: {symbol_info}"
)
- # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right
- if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]:
- assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}"
- @pytest.mark.php
- @pytest.mark.parametrize("serena_agent", [Language.PHP], indirect=True)
- def test_find_symbol_within_php_file(self, serena_agent: SerenaAgent) -> None:
- """Verify find_symbol with a PHP file path routes to the PHP language server.
+ # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right
+ if symbol["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]:
+ assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {symbol}"
- This validates the fix in symbol.py (LanguageServerSymbolRetriever.find_symbols):
- when within_relative_path points to a PHP file, the retriever must use
- get_language_server() rather than iterating all language servers. Without this
- fix, non-PHP servers reject the PHP file and no symbols are returned.
- """
- find_symbol_tool = serena_agent.get_tool(FindSymbolTool)
- sample_php = "sample.php"
-
- result = find_symbol_tool.apply(name_path_pattern="Dog/greet", relative_path=sample_php)
- symbols = json.loads(result)
-
- assert len(symbols) > 0, (
- f"Expected to find Dog/greet in {sample_php} but got empty result. "
- "This may indicate that find_symbol is not routing to the PHP language server for PHP files."
- )
- assert any("greet" in s["name_path"] and sample_php in s["relative_path"] for s in symbols), (
- f"Dog/greet not found in {sample_php}. Symbols: {symbols}"
- )
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,expected_kind,expected_file",
- [
- pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python),
- pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go),
- pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java),
- pytest.param(
- Language.KOTLIN,
- "Model",
- "Struct",
- "Model.kt",
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript),
- pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php),
- pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure),
- pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
- pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell),
- pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp),
- pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe),
- pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4),
- pytest.param(Language.MSL, "greet", "Function", "main.mrc", marks=pytest.mark.msl),
- ],
- indirect=["serena_agent"],
- )
- def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
- self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,expected_kind,expected_file",
- [
- pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
- ],
- indirect=["serena_agent"],
- )
- @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040
- def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
- self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,expected_kind,expected_file",
- [
- pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust),
- ],
- indirect=["serena_agent"],
- )
- @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040
- def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
- self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
-
- def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
+ @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_REFERENCES_CASES, indirect=["serena_agent"])
+ def test_find_symbol(self, serena_agent: SerenaAgent, case: FindSymbolCase) -> None:
agent = serena_agent
-
- # Find the symbol location first
find_symbol_tool = agent.get_tool(FindSymbolTool)
- result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file)
+ result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, include_info=True)
+ symbols = json.loads(result)
+ assert any(
+ case.symbol_name in s["name_path"]
+ and case.expected_kind.lower() in s["kind"].lower()
+ and case.expected_file in s["relative_path"]
+ for s in symbols
+ ), (
+ f"Expected to find {case.symbol_name} ({case.expected_kind}) in {case.expected_file}. Found name paths: {[s['name_path'] for s in symbols]}"
+ )
+ for symbol in symbols:
+ self._assert_symbol_info_present(serena_agent, symbol, case.symbol_name)
+
+ @pytest.mark.parametrize("serena_agent,case", FIND_REFERENCE_CASES, indirect=["serena_agent"])
+ def test_find_symbol_references(self, serena_agent: SerenaAgent, case: FindReferenceCase) -> None:
+ # Find the symbol location first
+ find_symbol_tool = serena_agent.get_tool(FindSymbolTool)
+ result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file)
time.sleep(1)
symbols = json.loads(result)
@@ -460,12 +933,12 @@ class TestSerenaAgent:
def_symbol = symbols[0]
# Now find references
- find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
+ find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool)
result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"])
def contains_ref_with_relative_path(refs, relative_path):
"""
- Checks for reference to relative path, regardless of output format (grouped an ungrouped)
+ Checks for reference to relative path, regardless of output format (grouped or ungrouped)
"""
if isinstance(refs, list):
for ref in refs:
@@ -480,550 +953,156 @@ class TestSerenaAgent:
return False
refs = json.loads(result)
- assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
+ assert contains_ref_with_relative_path(refs, case.reference_file), (
+ f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}"
+ )
- def _assert_find_symbol_implementations(
+ @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"])
+ def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None:
+ project_root = get_repo_path(case.language)
+ position = find_identifier_pos(
+ project_root / case.relative_path,
+ case.identifier,
+ case.occurrence_index,
+ case.column_offset,
+ )
+ assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}"
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool)
+ result = find_defining_symbol_tool.apply(
+ relative_path=case.relative_path,
+ line=position[0],
+ column=position[1],
+ include_info=True,
+ )
+ defining_symbol = json.loads(result)
+ assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}"
+ assert defining_symbol.get("relative_path") is not None
+ assert case.expected_definition_file in defining_symbol["relative_path"], (
+ f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}"
+ )
+ assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), (
+ f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}"
+ )
+ self._assert_symbol_info_present(serena_agent, defining_symbol)
+
+ @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"])
+ def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None:
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
+ result = find_defining_symbol_tool.apply(
+ regex=case.regex,
+ relative_path=case.relative_path,
+ containing_symbol_name_path=case.containing_symbol_name_path,
+ include_info=True,
+ )
+ defining_symbol = json.loads(result)
+ assert defining_symbol is not None, f"Expected defining symbol for regex {case.regex!r} in {case.relative_path}"
+ assert defining_symbol.get("relative_path") is not None
+ assert case.expected_definition_file in defining_symbol["relative_path"], (
+ f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}"
+ )
+ assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), (
+ f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}"
+ )
+ self._assert_symbol_info_present(serena_agent, defining_symbol)
+
+ @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"])
+ def test_find_defining_symbol_by_regex_error(
self,
serena_agent: SerenaAgent,
- symbol_name: str,
- def_file: str,
- impl_file: str,
- expected_symbol_name: str,
+ case: RegexDefiningSymbolErrorCase,
) -> None:
+ find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
+ result = find_defining_symbol_tool.apply(
+ regex=case.regex,
+ relative_path=case.relative_path,
+ containing_symbol_name_path=case.containing_symbol_name_path,
+ )
+ assert result.startswith("Error: "), result
+ assert case.error_fragment in result, result
+
+ @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"])
+ def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
+ diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool)
+ result = diagnostics_tool.apply(
+ relative_path=diagnostic_case.relative_path,
+ min_severity=1,
+ )
+ full_file_diagnostics = json.loads(result)
+ diagnostic_case.assert_matches(full_file_diagnostics)
+
+ # testing diagnostics in range by removing second symbol
+ project_root = get_repo_path(diagnostic_case.language)
+ pos1 = find_identifier_pos(project_root / diagnostic_case.relative_path, diagnostic_case.symbol1_id_str)
+ pos2 = find_identifier_pos(project_root / diagnostic_case.relative_path, cast(str, diagnostic_case.symbol2_id_str))
+ assert pos1 is not None
+ assert pos2 is not None
+ result = diagnostics_tool.apply(
+ relative_path=diagnostic_case.relative_path,
+ min_severity=1,
+ start_line=pos1[0],
+ end_line=pos2[0] - 1,
+ )
+ diagnostics_in_range = json.loads(result)
+ diagnostic_case.without_second_symbol().assert_matches(diagnostics_in_range)
+
+ @pytest.mark.parametrize("serena_agent,case", FIND_IMPLEMENTATION_CASES, indirect=["serena_agent"])
+ def test_find_symbol_implementations(self, serena_agent: SerenaAgent, case: FindImplementationCase) -> None:
agent = serena_agent
-
find_symbol_tool = agent.get_tool(FindSymbolTool)
- result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file)
+ result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file)
symbols = json.loads(result)
- assert symbols, f"Expected to find symbol {symbol_name} in {def_file}"
-
+ assert symbols, f"Expected to find symbol {case.symbol_name} in {case.definition_file}"
def_symbol = symbols[0]
find_impl_tool = agent.get_tool(FindImplementationsTool)
result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True)
implementations = json.loads(result)
-
assert any(
- impl_file in implementation["relative_path"]
- and (
- implementation.get("name") == expected_symbol_name
- or implementation.get("name_path") == expected_symbol_name
- or expected_symbol_name in implementation.get("info", "")
- )
+ case.implementation_file in implementation["relative_path"]
+ and self._symbol_matches_expected_name(implementation, case.expected_symbol_name)
for implementation in implementations
- ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}"
-
+ ), f"Expected to find implementation of {case.symbol_name} in {case.implementation_file}. implementations={implementations}"
for implementation in implementations:
- if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name):
- continue
- symbol_info = implementation.get("info")
- assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}"
+ self._assert_symbol_info_present(serena_agent, implementation)
- def _assert_find_defining_symbol(
- self,
- serena_agent: SerenaAgent,
- relative_path: str,
- identifier: str,
- occurrence_index: int,
- column_offset: int,
- expected_name: str,
- expected_definition_file: str,
- ) -> None:
- project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0])
- position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset)
- assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}"
-
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool)
- result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True)
- defining_symbol = json.loads(result)
-
- assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}"
- assert defining_symbol.get("relative_path") is not None
- assert expected_definition_file in defining_symbol["relative_path"], (
- f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
- )
- assert (
- defining_symbol.get("name") == expected_name
- or defining_symbol.get("name_path") == expected_name
- or expected_name in defining_symbol.get("info", "")
- ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
-
- if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]:
- return
- if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name):
- assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}"
-
- def _assert_find_defining_symbol_by_regex(
- self,
- serena_agent: SerenaAgent,
- relative_path: str,
- regex: str,
- containing_symbol_name_path: str,
- expected_name: str,
- expected_definition_file: str,
- ) -> None:
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
- result = find_defining_symbol_tool.apply(
- regex=regex,
- relative_path=relative_path,
- containing_symbol_name_path=containing_symbol_name_path,
- include_info=True,
- )
- defining_symbol = json.loads(result)
-
- assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}"
- assert defining_symbol.get("relative_path") is not None
- assert expected_definition_file in defining_symbol["relative_path"], (
- f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
- )
- assert (
- defining_symbol.get("name") == expected_name
- or defining_symbol.get("name_path") == expected_name
- or expected_name in defining_symbol.get("info", "")
- ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
-
- if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]:
- return
- if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name):
- assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}"
-
- def _assert_diagnostics_for_file(
- self,
- serena_agent: SerenaAgent,
- diagnostic_case: DiagnosticCase,
- start_line: int = 0,
- end_line: int = -1,
- ) -> None:
- diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool)
- result = diagnostics_tool.apply(
- relative_path=diagnostic_case.relative_path,
- start_line=start_line,
- end_line=end_line,
- min_severity=1,
- )
- grouped_diagnostics = json.loads(result)
-
- assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics
- severity_group = grouped_diagnostics[diagnostic_case.relative_path]
- assert "Error" in severity_group, severity_group
- name_path_group = severity_group["Error"]
-
- for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]:
- assert expected_name_path in name_path_group, name_path_group
-
- diagnostic_messages = [
- diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path
- ]
- for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]:
- assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages
-
- def _assert_diagnostics_for_symbol(
- self,
- serena_agent: SerenaAgent,
- diagnostic_case: DiagnosticCase,
- check_symbol_references: bool,
- ) -> None:
- diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool)
- result = diagnostics_tool.apply(
- name_path=diagnostic_case.primary_symbol_name_path,
- reference_file=diagnostic_case.relative_path,
- check_symbol_references=check_symbol_references,
- min_severity=1,
- )
- grouped_diagnostics = json.loads(result)
- diagnostics_file = diagnostic_case.relative_path
-
- assert diagnostics_file in grouped_diagnostics, grouped_diagnostics
- severity_group = grouped_diagnostics[diagnostics_file]
- assert "Error" in severity_group, severity_group
- name_path_group = severity_group["Error"]
-
- expected_name_paths = [diagnostic_case.primary_symbol_name_path]
- expected_message_fragments = [diagnostic_case.primary_message_fragment]
- if check_symbol_references:
- expected_name_paths.append(diagnostic_case.reference_symbol_name_path)
- expected_message_fragments.append(diagnostic_case.reference_message_fragment)
-
- assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group
- diagnostic_messages = [
- diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path
- ]
- for expected_fragment in expected_message_fragments:
- assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,def_file,ref_file",
- [
- pytest.param(
- Language.PYTHON,
- "User",
- os.path.join("test_repo", "models.py"),
- os.path.join("test_repo", "services.py"),
- marks=pytest.mark.python,
- ),
- pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go),
- pytest.param(
- Language.JAVA,
- "Model",
- os.path.join("src", "main", "java", "test_repo", "Model.java"),
- os.path.join("src", "main", "java", "test_repo", "Main.java"),
- marks=pytest.mark.java,
- ),
- pytest.param(
- Language.KOTLIN,
- "Model",
- os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
- os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust),
- pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php),
- pytest.param(
- Language.CLOJURE,
- "multiply",
- clj.CORE_PATH,
- clj.UTILS_PATH,
- marks=pytest.mark.clojure,
- ),
- pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
- pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
- pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp),
- pytest.param(
- Language.HAXE,
- "addNumbers",
- os.path.join("src", "utils", "Helper.hx"),
- os.path.join("src", "Main.hx"),
- marks=pytest.mark.haxe,
- ),
- pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4),
- pytest.param(Language.MSL, "format.coins", "utils.mrc", "main.mrc", marks=pytest.mark.msl),
- ],
- indirect=["serena_agent"],
- )
- def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
- self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,def_file,ref_file",
- [
- pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript),
- ],
- indirect=["serena_agent"],
- )
- @pytest.mark.xfail(False, reason="TypeScript language server is unreliable") # NOTE: Testing; may be resolved by #1120; See issue #1040
- def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
- self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,def_file,ref_file",
- [
- pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
- ],
- indirect=["serena_agent"],
- )
- @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040
- def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
- self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
-
- @pytest.mark.parametrize(
- "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file",
- DEFINING_SYMBOL_TOOL_TEST_CASES,
- indirect=["serena_agent"],
- )
- def test_find_defining_symbol(
- self,
- serena_agent: SerenaAgent,
- relative_path: str,
- identifier: str,
- occurrence_index: int,
- column_offset: int,
- expected_name: str,
- expected_definition_file: str,
- ) -> None:
- self._assert_find_defining_symbol(
- serena_agent,
- relative_path,
- identifier,
- occurrence_index,
- column_offset,
- expected_name,
- expected_definition_file,
- )
-
- @pytest.mark.parametrize(
- "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file",
- REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES,
- indirect=["serena_agent"],
- )
- def test_find_defining_symbol_by_regex(
- self,
- serena_agent: SerenaAgent,
- relative_path: str,
- regex: str,
- containing_symbol_name_path: str,
- expected_name: str,
- expected_definition_file: str,
- ) -> None:
- self._assert_find_defining_symbol_by_regex(
- serena_agent,
- relative_path,
- regex,
- containing_symbol_name_path,
- expected_name,
- expected_definition_file,
- )
-
- @pytest.mark.parametrize(
- "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment",
- [
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- r"(User)",
- "",
- "Expected exactly one regex match",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- r"User",
- "UserService/create_user",
- "must contain exactly one capturing group",
- marks=pytest.mark.python,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_find_defining_symbol_by_regex_error(
- self,
- serena_agent: SerenaAgent,
- relative_path: str,
- regex: str,
- containing_symbol_name_path: str,
- error_fragment: str,
- ) -> None:
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
- result = find_defining_symbol_tool.apply(
- regex=regex,
- relative_path=relative_path,
- containing_symbol_name_path=containing_symbol_name_path,
- )
- assert result.startswith("Error: "), result
- assert error_fragment in result, result
-
- @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
- def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
- self._assert_diagnostics_for_file(
- serena_agent,
- diagnostic_case=diagnostic_case,
- )
-
- @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
- def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
- project_root = get_repo_path(diagnostic_case.language)
- primary_position = find_identifier_occurrence_position(
- project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier
- )
- reference_position = find_identifier_occurrence_position(
- project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier
- )
- assert primary_position is not None
- assert reference_position is not None
-
- self._assert_diagnostics_for_file(
- serena_agent,
- diagnostic_case=DiagnosticCase(
- language=diagnostic_case.language,
- relative_path=diagnostic_case.relative_path,
- primary_symbol_name_path=diagnostic_case.primary_symbol_name_path,
- primary_symbol_identifier=diagnostic_case.primary_symbol_identifier,
- reference_symbol_name_path=diagnostic_case.primary_symbol_name_path,
- reference_symbol_identifier=diagnostic_case.reference_symbol_identifier,
- primary_message_fragment=diagnostic_case.primary_message_fragment,
- reference_message_fragment=diagnostic_case.primary_message_fragment,
- ),
- start_line=primary_position[0],
- end_line=reference_position[0] - 1,
- )
-
- @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
- def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
- self._assert_diagnostics_for_symbol(
- serena_agent,
- diagnostic_case=diagnostic_case,
- check_symbol_references=False,
- )
-
- @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"])
- def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
- self._assert_diagnostics_for_symbol(
- serena_agent,
- diagnostic_case=diagnostic_case,
- check_symbol_references=True,
- )
-
- if IMPLEMENTATION_TOOL_TEST_CASES:
-
- @pytest.mark.parametrize(
- "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name",
- IMPLEMENTATION_TOOL_TEST_CASES,
- indirect=["serena_agent"],
- )
- def test_find_symbol_implementations(
- self,
- serena_agent: SerenaAgent,
- symbol_name: str,
- def_file: str,
- impl_file: str,
- expected_symbol_name: str,
- ) -> None:
- self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name)
-
- @pytest.mark.parametrize(
- "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
- [
- pytest.param(
- Language.PYTHON,
- "OuterClass/NestedClass",
- False,
- "NestedClass",
- "Class",
- os.path.join("test_repo", "nested.py"),
- id="exact_qualname_class",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "OuterClass/NestedClass/find_me",
- False,
- "find_me",
- "Method",
- os.path.join("test_repo", "nested.py"),
- id="exact_qualname_method",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "OuterClass/NestedCl", # Substring for NestedClass
- True,
- "NestedClass",
- "Class",
- os.path.join("test_repo", "nested.py"),
- id="substring_qualname_class",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "OuterClass/NestedClass/find_m", # Substring for find_me
- True,
- "find_me",
- "Method",
- os.path.join("test_repo", "nested.py"),
- id="substring_qualname_method",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "/OuterClass", # Absolute path
- False,
- "OuterClass",
- "Class",
- os.path.join("test_repo", "nested.py"),
- id="absolute_qualname_class",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "/OuterClass/NestedClass/find_m", # Absolute path with substring
- True,
- "find_me",
- "Method",
- os.path.join("test_repo", "nested.py"),
- id="absolute_substring_qualname_method",
- marks=pytest.mark.python,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_find_symbol_name_path(
- self,
- serena_agent,
- name_path: str,
- substring_matching: bool,
- expected_symbol_name: str,
- expected_kind: str,
- expected_file: str,
- ):
+ @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_CASES, indirect=["serena_agent"])
+ def test_find_symbol_name_path(self, serena_agent: SerenaAgent, case: FindSymbolNamePathCase) -> None:
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(
- name_path_pattern=name_path,
+ name_path_pattern=case.name_path,
depth=0,
relative_path=None,
include_body=False,
include_kinds=None,
exclude_kinds=None,
- substring_matching=substring_matching,
+ substring_matching=case.substring_matching,
)
symbols = json.loads(result)
assert any(
- expected_symbol_name == s["name_path"].split("/")[-1]
- and expected_kind.lower() in s["kind"].lower()
- and expected_file in s["relative_path"]
+ case.expected_symbol_name == s["name_path"].split("/")[-1]
+ and case.expected_kind.lower() in s["kind"].lower()
+ and case.expected_file in s["relative_path"]
for s in symbols
- ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}"
+ ), f"Expected to find {case.name_path} ({case.expected_kind}) in {case.expected_file}. Symbols: {symbols}"
- @pytest.mark.parametrize(
- "serena_agent,name_path",
- [
- pytest.param(
- Language.PYTHON,
- "/NestedClass", # Absolute path, NestedClass is not top-level
- id="absolute_path_non_top_level_no_match",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON,
- "/NoSuchParent/NestedClass", # Absolute path with non-existent parent
- id="absolute_path_non_existent_parent_no_match",
- marks=pytest.mark.python,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_find_symbol_name_path_no_match(
- self,
- serena_agent,
- name_path: str,
- ):
+ @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES, indirect=["serena_agent"])
+ def test_find_symbol_name_path_no_match(self, serena_agent: SerenaAgent, case: FindSymbolNoMatchCase) -> None:
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(
- name_path_pattern=name_path,
+ name_path_pattern=case.name_path,
depth=0,
substring_matching=True,
)
symbols = json.loads(result)
- assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}"
+ assert not symbols, f"Expected to find no symbols for {case.name_path}. Symbols found: {symbols}"
- @pytest.mark.parametrize(
- "serena_agent,name_path,num_expected",
- [
- pytest.param(
- Language.JAVA,
- "Model/getName",
- 2,
- id="overloaded_java_method",
- marks=pytest.mark.java,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int):
+ @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_OVERLOADED_FUNCTION_CASES, indirect=["serena_agent"])
+ def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, case: FindSymbolOverloadedCase) -> None:
"""
Tests whether the FindSymbolTool can find all overloads of a function/method
(provided that the overload id remains unspecified in the name path)
@@ -1032,51 +1111,38 @@ class TestSerenaAgent:
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(
- name_path_pattern=name_path,
+ name_path_pattern=case.name_path,
depth=0,
substring_matching=False,
)
symbols = json.loads(result)
- assert len(symbols) == num_expected, (
- f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}"
+ assert len(symbols) == case.num_expected, (
+ f"Expected to find {case.num_expected} symbols for overloaded function {case.name_path}. Symbols found: {symbols}"
)
- @pytest.mark.parametrize(
- "serena_agent,name_path,relative_path",
- [
- pytest.param(
- Language.JAVA,
- "Model/getName",
- os.path.join("src", "main", "java", "test_repo", "Model.java"),
- id="overloaded_java_method",
- marks=pytest.mark.java,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
+ @pytest.mark.parametrize("serena_agent,case", NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES, indirect=["serena_agent"])
+ def test_non_unique_symbol_reference_error(
+ self,
+ serena_agent: SerenaAgent,
+ case: NonUniqueSymbolReferenceCase,
+ ) -> None:
"""
Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique.
We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool).
"""
- match_text = "multiple"
-
find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool)
- with pytest.raises(ValueError, match=match_text):
- find_refs_tool.apply(name_path=name_path, relative_path=relative_path)
+ with pytest.raises(ValueError, match=case.expected_error_fragment):
+ find_refs_tool.apply(name_path=case.name_path, relative_path=case.relative_path)
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
- with pytest.raises(ValueError, match=match_text):
- replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="")
+ with pytest.raises(ValueError, match=case.expected_error_fragment):
+ replace_symbol_body_tool.apply(name_path=case.name_path, relative_path=case.relative_path, body="")
@pytest.mark.parametrize(
"serena_agent",
[
- pytest.param(
- Language.TYPESCRIPT,
- marks=pytest.mark.typescript,
- ),
+ pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_unique_regex"),
],
indirect=["serena_agent"],
)
@@ -1089,7 +1155,7 @@ class TestSerenaAgent:
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
result = replace_content_tool.apply(
needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}',
- repl='catch(error) { console.log("Never mind"); }',
+ repl='catch(error) {console.log("Never mind"); }',
relative_path=relative_path,
mode="regex",
)
@@ -1098,14 +1164,11 @@ class TestSerenaAgent:
@pytest.mark.parametrize(
"serena_agent",
[
- pytest.param(
- Language.TYPESCRIPT,
- marks=pytest.mark.typescript,
- ),
+ pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_backslashes"),
],
indirect=["serena_agent"],
)
- @pytest.mark.parametrize("mode", ["literal", "regex"])
+ @pytest.mark.parametrize("mode", ["literal", "regex"], ids=["literal_mode", "regex_mode"])
def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]):
"""
Tests a content replacement where the needle and replacement strings contain backslashes.
@@ -1129,8 +1192,8 @@ class TestSerenaAgent:
@pytest.mark.parametrize(
"serena_agent",
[
- pytest.param(Language.PYTHON, marks=pytest.mark.python),
- pytest.param(Language.PYTHON_TY, marks=pytest.mark.python),
+ pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_services"),
+ pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_services"),
],
indirect=["serena_agent"],
)
@@ -1138,6 +1201,7 @@ class TestSerenaAgent:
"""Tests that file-level edits report newly introduced diagnostics."""
relative_path = os.path.join("test_repo", "services.py")
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
+ replace_content_tool._ENABLE_DIAGNOSTICS = True
with project_file_modification_context(serena_agent, relative_path):
result = replace_content_tool.apply(
@@ -1156,8 +1220,8 @@ class TestSerenaAgent:
@pytest.mark.parametrize(
"serena_agent",
[
- pytest.param(Language.PYTHON, marks=pytest.mark.python),
- pytest.param(Language.PYTHON_TY, marks=pytest.mark.python),
+ pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_container_body"),
+ pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_container_body"),
],
indirect=["serena_agent"],
)
@@ -1165,6 +1229,7 @@ class TestSerenaAgent:
"""Tests that symbol-level edits report newly introduced diagnostics."""
relative_path = os.path.join("test_repo", "services.py")
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
+ replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True
with project_file_modification_context(serena_agent, relative_path):
result = replace_symbol_body_tool.apply(
@@ -1185,10 +1250,7 @@ def create_service_container() -> dict[str, Any]:
@pytest.mark.parametrize(
"serena_agent",
[
- pytest.param(
- Language.TYPESCRIPT,
- marks=pytest.mark.typescript,
- ),
+ pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"),
],
indirect=["serena_agent"],
)
@@ -1201,98 +1263,40 @@ def create_service_container() -> dict[str, Any]:
with pytest.raises(ValueError, match="ambiguous"):
replace_content_tool.apply(
needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}',
- repl='catch(error) { console.log("Never mind"); }',
+ repl='catch(error) {console.log("Never mind"); }',
relative_path="ws_manager.js",
mode="regex",
)
- @pytest.mark.parametrize(
- "serena_agent,name_path,relative_path",
- [
- pytest.param(
- Language.PYTHON,
- "User",
- os.path.join("test_repo", "models.py"),
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.JAVA,
- "Model",
- os.path.join("src", "main", "java", "test_repo", "Model.java"),
- marks=pytest.mark.java,
- ),
- pytest.param(
- Language.KOTLIN,
- "Model",
- os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(
- Language.TYPESCRIPT,
- "helperFunction",
- "index.ts",
- marks=pytest.mark.typescript,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
+ @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_BLOCKED_CASES, indirect=["serena_agent"])
+ def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase):
"""
Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere
and returns a message listing the referencing files.
"""
# wrap in modification context as a safety net: if the tool has a bug and deletes anyway,
# the file will be restored, preventing corruption of test resources
- with project_file_modification_context(serena_agent, relative_path):
+ with project_file_modification_context(serena_agent, case.relative_path):
safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol)
- result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path)
+ result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path)
assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}"
assert "referenced in" in result, f"Expected reference information in result, but got: {result}"
- @pytest.mark.parametrize(
- "serena_agent,name_path,relative_path",
- [
- pytest.param(
- Language.PYTHON,
- "Timer",
- os.path.join("test_repo", "utils.py"),
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.JAVA,
- "ModelUser",
- os.path.join("src", "main", "java", "test_repo", "ModelUser.java"),
- marks=pytest.mark.java,
- ),
- pytest.param(
- Language.KOTLIN,
- "ModelUser",
- os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"),
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(
- Language.TYPESCRIPT,
- "unusedStandaloneFunction",
- "index.ts",
- marks=pytest.mark.typescript,
- ),
- ],
- indirect=["serena_agent"],
- )
- def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
+ @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_SUCCEEDS_CASES, indirect=["serena_agent"])
+ def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase):
"""
Tests that SafeDeleteSymbol successfully deletes a symbol that has no references
and that the symbol is actually removed from the file.
"""
- with project_file_modification_context(serena_agent, relative_path):
+ with project_file_modification_context(serena_agent, case.relative_path):
safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol)
- result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path)
+ result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path)
assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}"
# verify the symbol was actually removed from the file
- file_content = read_project_file(serena_agent.get_active_project(), relative_path)
- assert name_path not in file_content, (
- f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content"
+ file_content = read_project_file(serena_agent.get_active_project(), case.relative_path)
+ assert case.name_path not in file_content, (
+ f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content"
)
diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py
deleted file mode 100644
index c0509a2f..00000000
--- a/test/solidlsp/test_defining_symbol_matrix.py
+++ /dev/null
@@ -1,139 +0,0 @@
-import os
-import shutil
-from pathlib import Path
-
-import pytest
-
-from solidlsp import SolidLanguageServer
-from solidlsp.ls_config import Language
-from test.conftest import find_identifier_occurrence_position, is_ci
-from test.solidlsp import clojure as clj
-
-
-@pytest.mark.parametrize(
- "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file",
- [
- pytest.param(
- Language.PYTHON,
- os.path.join("test_repo", "services.py"),
- "User",
- 1,
- 1,
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(
- Language.PYTHON_TY,
- os.path.join("test_repo", "services.py"),
- "User",
- 1,
- 1,
- "User",
- "models.py",
- marks=pytest.mark.python,
- ),
- pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go),
- pytest.param(
- Language.JAVA,
- os.path.join("src", "main", "java", "test_repo", "Main.java"),
- "Model",
- 0,
- 1,
- "Model",
- "Model.java",
- marks=pytest.mark.java,
- ),
- pytest.param(
- Language.KOTLIN,
- os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
- "Model",
- 0,
- 1,
- "Model",
- "Model.kt",
- marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
- ),
- pytest.param(
- Language.RUST,
- os.path.join("src", "main.rs"),
- "format_greeting",
- 0,
- 1,
- "format_greeting",
- "lib.rs",
- marks=pytest.mark.rust,
- ),
- pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php),
- pytest.param(
- Language.CLOJURE,
- clj.UTILS_PATH,
- "multiply",
- 0,
- 1,
- "multiply",
- clj.CORE_PATH,
- marks=[
- pytest.mark.clojure,
- pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"),
- ],
- ),
- pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp),
- pytest.param(
- Language.POWERSHELL,
- "main.ps1",
- "Convert-ToUpperCase",
- 0,
- 1,
- "function Convert-ToUpperCase ()",
- "utils.ps1",
- marks=pytest.mark.powershell,
- ),
- pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp),
- pytest.param(
- Language.LEAN4,
- "Main.lean",
- "add",
- 0,
- 1,
- "add",
- "Helper.lean",
- marks=[
- pytest.mark.lean4,
- pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"),
- pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"),
- ],
- ),
- pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript),
- pytest.param(
- Language.FSHARP,
- "Program.fs",
- "add",
- 0,
- 1,
- "add",
- "Calculator.fs",
- marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")],
- ),
- ],
- indirect=["language_server"],
-)
-def test_request_defining_symbol_matrix(
- language_server: SolidLanguageServer,
- relative_path: str,
- identifier: str,
- occurrence_index: int,
- column_offset: int,
- expected_name: str,
- expected_definition_file: str,
-) -> None:
- repo_root = Path(language_server.repository_root_path)
- position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset)
- assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}"
-
- defining_symbol = language_server.request_defining_symbol(relative_path, *position)
- assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}"
- assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}"
- assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), (
- f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}"
- )
diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py
deleted file mode 100644
index 2d1b50fe..00000000
--- a/test/solidlsp/test_diagnostics_matrix.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from pathlib import Path
-
-import pytest
-
-from serena.symbol import LanguageServerSymbolRetriever
-from solidlsp import SolidLanguageServer
-from test.conftest import find_identifier_position
-from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase
-
-
-@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"])
-def test_request_text_document_diagnostics_matrix(
- language_server: SolidLanguageServer,
- diagnostic_case: DiagnosticCase,
-) -> None:
- diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1)
- assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}"
-
- diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics]
- assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages
- assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages
-
- repo_root = language_server.repository_root_path
- primary_symbol_position = find_identifier_position(
- Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier
- )
- reference_symbol_position = find_identifier_position(
- Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier
- )
- assert primary_symbol_position is not None
- assert reference_symbol_position is not None
-
- primary_diagnostics = language_server.request_text_document_diagnostics(
- diagnostic_case.relative_path,
- start_line=primary_symbol_position[0],
- end_line=reference_symbol_position[0] - 1,
- min_severity=1,
- )
- primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics]
- assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}"
- assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages
- assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages
-
-
-@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"])
-def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None:
- symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
-
- diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics(
- diagnostic_case.primary_symbol_name_path,
- reference_file=diagnostic_case.relative_path,
- min_severity=1,
- )
- diagnostic_messages_by_symbol = {
- symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics]
- for symbol, diagnostics in diagnostics_by_symbol.items()
- }
- assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol
- assert any(
- diagnostic_case.primary_message_fragment in message
- for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path]
- ), diagnostic_messages_by_symbol
-
- diagnostics_with_references = symbol_retriever.get_symbol_diagnostics(
- diagnostic_case.primary_symbol_name_path,
- reference_file=diagnostic_case.relative_path,
- check_symbol_references=True,
- min_severity=1,
- )
- diagnostic_messages_with_references = {
- symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics]
- for symbol, diagnostics in diagnostics_with_references.items()
- }
- assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references
- assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references
- assert any(
- diagnostic_case.reference_message_fragment in message
- for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path]
- ), diagnostic_messages_with_references
From d198d67a9f6d0ae7898cdaddf42e057a7bc3187e Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Tue, 28 Apr 2026 21:07:01 +0200
Subject: [PATCH 19/32] Added JetBrains diagnostics tools, WIP
---
.../jetbrains/jetbrains_plugin_client.py | 48 ++++++++++++
src/serena/jetbrains/jetbrains_types.py | 27 +++++++
.../config/internal_modes/jetbrains.yml | 2 +
src/serena/tools/jetbrains_tools.py | 73 +++++++++++++++++++
4 files changed, 150 insertions(+)
diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py
index 263bc8a9..c662ec76 100644
--- a/src/serena/jetbrains/jetbrains_plugin_client.py
+++ b/src/serena/jetbrains/jetbrains_plugin_client.py
@@ -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", "/runInspections", 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()
diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py
index ead4eb55..ce488ea2 100644
--- a/src/serena/jetbrains/jetbrains_types.py
+++ b/src/serena/jetbrains/jetbrains_types.py
@@ -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]
diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml
index 34baf6ce..1932906c 100644
--- a/src/serena/resources/config/internal_modes/jetbrains.yml
+++ b/src/serena/resources/config/internal_modes/jetbrains.yml
@@ -25,3 +25,5 @@ included_optional_tools:
- jet_brains_safe_delete
- jet_brains_debug
- serena_info
+ - jet_brains_run_inspections
+ - jet_brains_list_inspections
diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py
index 0664fb03..74a05359 100644
--- a/src/serena/tools/jetbrains_tools.py
+++ b/src/serena/tools/jetbrains_tools.py
@@ -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)
From bd7e64e85a04751f629a2e4094575e0b6e67aa37 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Tue, 28 Apr 2026 22:06:41 +0200
Subject: [PATCH 20/32] Fixed diagnostics files uri handling, added low-level
tests
---
.gitignore | 4 +-
.serena/project.yml | 1 +
scripts/demo_run_tools.py | 6 +-
.../jetbrains/jetbrains_plugin_client.py | 2 +-
.../language_servers/elm_language_server.py | 28 +++++--
.../fortran_language_server.py | 1 +
.../language_servers/lean4_language_server.py | 1 +
.../language_servers/pascal_server.py | 9 +++
.../solidity_language_server.py | 1 +
.../typescript_language_server.py | 26 +++++-
.../language_servers/vue_language_server.py | 13 +++
src/solidlsp/language_servers/zls.py | 1 +
src/solidlsp/ls.py | 79 ++++++++++++++++---
.../bash/test_repo/diagnostics_sample.sh | 4 +
.../test_repo/src/diagnostics_sample.cr | 5 ++
.../test_repo/lib/diagnostics_sample.dart | 3 +
.../test_repo/lib/diagnostics_sample.ex | 10 +++
.../repos/elm/test_repo/DiagnosticsSample.elm | 5 ++
.../test_repo/src/diagnostics_sample.erl | 5 ++
.../fortran/test_repo/diagnostics_sample.f90 | 5 ++
.../com/example/DiagnosticsSample.groovy | 13 +++
.../test_repo/src/DiagnosticsSample.hs | 7 ++
.../haxe/test_repo/src/DiagnosticsSample.hx | 10 +++
.../hlsl/test_repo/diagnostics_sample.hlsl | 3 +
.../json/test_repo/diagnostics_sample.json | 4 +
.../julia/test_repo/diagnostics_sample.jl | 3 +
.../lean4/test_repo/DiagnosticsSample.lean | 5 --
.../matlab/test_repo/diagnostics_sample.m | 3 +
.../nix/test_repo/diagnostics_sample.nix | 4 +
.../ocaml/test_repo/lib/diagnostics_sample.ml | 1 +
test/resources/repos/ocaml/test_repo/lib/dune | 3 +-
.../pascal/test_repo/diagnostics_sample.pas | 4 +
.../perl/test_repo/diagnostics_sample.pl | 5 ++
.../test_repo/diagnostics_sample.ps1 | 9 +--
.../scala/com/example/DiagnosticsSample.scala | 9 +++
.../test_repo/contracts/DiagnosticsSample.sol | 7 ++
.../repos/swift/test_repo/Package.swift | 5 +-
.../test_repo/src/diagnostics_sample.swift | 3 +
.../test_repo/diagnostics_sample.sv | 5 ++
.../terraform/test_repo/diagnostics_sample.tf | 4 +
.../toml/test_repo/diagnostics_sample.toml | 2 +
.../vue/test_repo/src/DiagnosticsSample.vue | 3 +
.../yaml/test_repo/diagnostics_sample.yaml | 5 ++
test/resources/repos/zig/test_repo/build.zig | 2 +-
.../zig/test_repo/src/diagnostics_sample.zig | 3 +
.../repos/zig/test_repo/src/main.zig | 2 +-
.../__snapshots__/test_symbol_editing.ambr | 26 +++---
test/solidlsp/bash/test_bash_diagnostics.py | 17 ++++
test/solidlsp/clojure/__init__.py | 4 +-
.../clojure/test_clojure_diagnostics.py | 19 +++++
test/solidlsp/conftest.py | 9 +++
test/solidlsp/cpp/test_cpp_diagnostics.py | 17 ++++
.../crystal/test_crystal_diagnostics.py | 23 ++++++
.../csharp/test_csharp_diagnostics.py | 17 ++++
test/solidlsp/dart/test_dart_diagnostics.py | 17 ++++
test/solidlsp/elixir/test_elixir_basic.py | 10 +++
test/solidlsp/elm/test_elm_diagnostics.py | 20 +++++
test/solidlsp/erlang/test_erlang_basic.py | 10 +++
.../fortran/test_fortran_diagnostics.py | 17 ++++
test/solidlsp/fsharp/test_fsharp_basic.py | 23 ++----
test/solidlsp/go/test_go_diagnostics.py | 17 ++++
test/solidlsp/groovy/test_groovy_basic.py | 10 +++
test/solidlsp/haskell/test_haskell_basic.py | 10 +++
test/solidlsp/haxe/test_haxe_diagnostics.py | 17 ++++
test/solidlsp/hlsl/test_hlsl_diagnostics.py | 17 ++++
test/solidlsp/java/test_java_diagnostics.py | 17 ++++
.../solidlsp/json_ls/test_json_diagnostics.py | 17 ++++
test/solidlsp/julia/test_julia_basic.py | 10 +++
.../kotlin/test_kotlin_diagnostics.py | 17 ++++
test/solidlsp/lean4/test_lean4_diagnostics.py | 17 ++++
test/solidlsp/matlab/test_matlab_basic.py | 10 +++
test/solidlsp/nix/test_nix_basic.py | 10 +++
test/solidlsp/ocaml/test_ocaml_diagnostics.py | 17 ++++
.../pascal/test_pascal_diagnostics.py | 25 ++++++
test/solidlsp/perl/test_perl_basic.py | 10 +++
test/solidlsp/php/test_php_diagnostics.py | 17 ++++
.../powershell/test_powershell_diagnostics.py | 17 ++++
.../python/test_python_diagnostics.py | 19 +++++
test/solidlsp/rust/test_rust_basic.py | 2 +-
test/solidlsp/rust/test_rust_diagnostics.py | 17 ++++
.../scala/test_scala_language_server.py | 10 +++
.../solidity/test_solidity_diagnostics.py | 24 ++++++
test/solidlsp/swift/test_swift_basic.py | 10 +++
.../test_systemverilog_diagnostics.py | 17 ++++
.../terraform/test_terraform_diagnostics.py | 17 ++++
test/solidlsp/toml/test_toml_diagnostics.py | 17 ++++
.../typescript/test_typescript_diagnostics.py | 17 ++++
test/solidlsp/util/diagnostics.py | 19 +++++
test/solidlsp/vue/test_vue_diagnostics.py | 17 ++++
.../solidlsp/yaml_ls/test_yaml_diagnostics.py | 17 ++++
test/solidlsp/zig/test_zig_basic.py | 10 +++
91 files changed, 958 insertions(+), 72 deletions(-)
create mode 100644 test/resources/repos/bash/test_repo/diagnostics_sample.sh
create mode 100644 test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr
create mode 100644 test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart
create mode 100644 test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex
create mode 100644 test/resources/repos/elm/test_repo/DiagnosticsSample.elm
create mode 100644 test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl
create mode 100644 test/resources/repos/fortran/test_repo/diagnostics_sample.f90
create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy
create mode 100644 test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs
create mode 100644 test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx
create mode 100644 test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl
create mode 100644 test/resources/repos/json/test_repo/diagnostics_sample.json
create mode 100644 test/resources/repos/julia/test_repo/diagnostics_sample.jl
create mode 100644 test/resources/repos/matlab/test_repo/diagnostics_sample.m
create mode 100644 test/resources/repos/nix/test_repo/diagnostics_sample.nix
create mode 100644 test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml
create mode 100644 test/resources/repos/pascal/test_repo/diagnostics_sample.pas
create mode 100644 test/resources/repos/perl/test_repo/diagnostics_sample.pl
create mode 100644 test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala
create mode 100644 test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol
create mode 100644 test/resources/repos/swift/test_repo/src/diagnostics_sample.swift
create mode 100644 test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv
create mode 100644 test/resources/repos/terraform/test_repo/diagnostics_sample.tf
create mode 100644 test/resources/repos/toml/test_repo/diagnostics_sample.toml
create mode 100644 test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue
create mode 100644 test/resources/repos/yaml/test_repo/diagnostics_sample.yaml
create mode 100644 test/resources/repos/zig/test_repo/src/diagnostics_sample.zig
create mode 100644 test/solidlsp/bash/test_bash_diagnostics.py
create mode 100644 test/solidlsp/clojure/test_clojure_diagnostics.py
create mode 100644 test/solidlsp/cpp/test_cpp_diagnostics.py
create mode 100644 test/solidlsp/crystal/test_crystal_diagnostics.py
create mode 100644 test/solidlsp/csharp/test_csharp_diagnostics.py
create mode 100644 test/solidlsp/dart/test_dart_diagnostics.py
create mode 100644 test/solidlsp/elm/test_elm_diagnostics.py
create mode 100644 test/solidlsp/fortran/test_fortran_diagnostics.py
create mode 100644 test/solidlsp/go/test_go_diagnostics.py
create mode 100644 test/solidlsp/haxe/test_haxe_diagnostics.py
create mode 100644 test/solidlsp/hlsl/test_hlsl_diagnostics.py
create mode 100644 test/solidlsp/java/test_java_diagnostics.py
create mode 100644 test/solidlsp/json_ls/test_json_diagnostics.py
create mode 100644 test/solidlsp/kotlin/test_kotlin_diagnostics.py
create mode 100644 test/solidlsp/lean4/test_lean4_diagnostics.py
create mode 100644 test/solidlsp/ocaml/test_ocaml_diagnostics.py
create mode 100644 test/solidlsp/pascal/test_pascal_diagnostics.py
create mode 100644 test/solidlsp/php/test_php_diagnostics.py
create mode 100644 test/solidlsp/powershell/test_powershell_diagnostics.py
create mode 100644 test/solidlsp/python/test_python_diagnostics.py
create mode 100644 test/solidlsp/rust/test_rust_diagnostics.py
create mode 100644 test/solidlsp/solidity/test_solidity_diagnostics.py
create mode 100644 test/solidlsp/systemverilog/test_systemverilog_diagnostics.py
create mode 100644 test/solidlsp/terraform/test_terraform_diagnostics.py
create mode 100644 test/solidlsp/toml/test_toml_diagnostics.py
create mode 100644 test/solidlsp/typescript/test_typescript_diagnostics.py
create mode 100644 test/solidlsp/util/diagnostics.py
create mode 100644 test/solidlsp/vue/test_vue_diagnostics.py
create mode 100644 test/solidlsp/yaml_ls/test_yaml_diagnostics.py
diff --git a/.gitignore b/.gitignore
index dbc94920..37c1649c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
\ No newline at end of file
+news/news.json
diff --git a/.serena/project.yml b/.serena/project.yml
index 84b6fcf8..fbaead68 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -28,6 +28,7 @@ project_name: "serena"
languages:
- python
- typescript
+- clojure
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py
index b0ed063b..4914201c 100644
--- a/scripts/demo_run_tools.py
+++ b/scripts/demo_run_tools.py
@@ -16,6 +16,7 @@ from serena.tools import (
JetBrainsFindSymbolTool,
JetBrainsGetSymbolsOverviewTool,
JetBrainsInlineSymbol,
+ JetBrainsRunInspectionsTool,
JetBrainsSafeDeleteTool,
SearchForPatternTool,
)
@@ -35,12 +36,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(
# 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,
)
)
diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py
index c662ec76..c7b440ce 100644
--- a/src/serena/jetbrains/jetbrains_plugin_client.py
+++ b/src/serena/jetbrains/jetbrains_plugin_client.py
@@ -689,7 +689,7 @@ class JetBrainsPluginClient(ToStringMixin):
request_data["startLine"] = start_line
if end_line is not None:
request_data["endLine"] = end_line
- return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspections", request_data))
+ return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data))
def list_inspections(
self,
diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py
index e1960c32..0c6139c1 100644
--- a/src/solidlsp/language_servers/elm_language_server.py
+++ b/src/solidlsp/language_servers/elm_language_server.py
@@ -32,9 +32,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 +66,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", "2.8.0")
+ elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6")
+ 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 +83,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,6 +91,12 @@ 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",
+ ),
]
)
@@ -98,6 +107,10 @@ class ElmLanguageServer(SolidLanguageServer):
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 +141,7 @@ class ElmLanguageServer(SolidLanguageServer):
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py
index 2cd6b099..a1d5d56d 100644
--- a/src/solidlsp/language_servers/fortran_language_server.py
+++ b/src/solidlsp/language_servers/fortran_language_server.py
@@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer):
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py
index 463e4992..961a65b6 100644
--- a/src/solidlsp/language_servers/lean4_language_server.py
+++ b/src/solidlsp/language_servers/lean4_language_server.py
@@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer):
"documentationFormat": ["markdown", "plaintext"],
},
},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py
index 9de2a926..1f2b7de7 100644
--- a/src/solidlsp/language_servers/pascal_server.py
+++ b/src/solidlsp/language_servers/pascal_server.py
@@ -777,6 +777,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 +839,7 @@ class PascalLanguageServer(SolidLanguageServer):
},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py
index 223a6683..1e7ccd2e 100644
--- a/src/solidlsp/language_servers/solidity_language_server.py
+++ b/src/solidlsp/language_servers/solidity_language_server.py
@@ -148,6 +148,7 @@ class SolidityLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"], # type: ignore[list-item]
},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py
index 32e9ac22..8b1c04e8 100644
--- a/src/solidlsp/language_servers/typescript_language_server.py
+++ b/src/solidlsp/language_servers/typescript_language_server.py
@@ -89,7 +89,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()
@@ -243,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},
},
@@ -293,6 +298,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
@@ -352,6 +361,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)
@@ -397,6 +407,20 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self.INDEXING_PROGRESS_TIMEOUT,
)
+ @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
diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py
index 06123623..f99056ae 100644
--- a/src/solidlsp/language_servers/vue_language_server.py
+++ b/src/solidlsp/language_servers/vue_language_server.py
@@ -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,
diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py
index 368f57eb..cb5652fc 100644
--- a/src/solidlsp/language_servers/zls.py
+++ b/src/solidlsp/language_servers/zls.py
@@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
+ "publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py
index 546c6a9f..4ea04694 100644
--- a/src/solidlsp/ls.py
+++ b/src/solidlsp/ls.py
@@ -13,7 +13,7 @@ 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 time import monotonic, perf_counter, sleep
from typing import Any, Self, Union, cast
import pathspec
@@ -754,21 +754,77 @@ class SolidLanguageServer(ABC):
: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_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout)
-
- # falling back to cached diagnostics
- if diagnostics is None and allow_cached:
- diagnostics = self._get_cached_published_diagnostics(uri)
+ 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 _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,
@@ -789,7 +845,8 @@ class SolidLanguageServer(ABC):
:return: A list of diagnostics for the file
"""
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)
+ 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
@@ -825,13 +882,11 @@ class SolidLanguageServer(ABC):
ret.append(ls_types.Diagnostic(**new_item))
if not ret:
- published_diagnostics = self._wait_for_published_diagnostics(
- uri=uri,
+ published_diagnostics = self._wait_for_relevant_published_diagnostics(
+ uri=published_uri,
after_generation=diagnostics_before_request,
- timeout=2.5 if pull_diagnostics_failed else 0.5,
+ timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed),
)
- if published_diagnostics is None:
- published_diagnostics = self._get_cached_published_diagnostics(uri)
if published_diagnostics is not None:
ret = published_diagnostics
diff --git a/test/resources/repos/bash/test_repo/diagnostics_sample.sh b/test/resources/repos/bash/test_repo/diagnostics_sample.sh
new file mode 100644
index 00000000..050a8a99
--- /dev/null
+++ b/test/resources/repos/bash/test_repo/diagnostics_sample.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+if then
+ echo "$missingGreeting"
+fi
diff --git a/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr
new file mode 100644
index 00000000..b408a202
--- /dev/null
+++ b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr
@@ -0,0 +1,5 @@
+def broken_factory : String
+ missingGreeting
+end
+
+broken_consumer = missingConsumerValue
diff --git a/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart
new file mode 100644
index 00000000..d309d9fc
--- /dev/null
+++ b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart
@@ -0,0 +1,3 @@
+String brokenFactory() {
+ return 'broken'
+}
diff --git a/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex
new file mode 100644
index 00000000..af510437
--- /dev/null
+++ b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex
@@ -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
diff --git a/test/resources/repos/elm/test_repo/DiagnosticsSample.elm b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm
new file mode 100644
index 00000000..2880fa7b
--- /dev/null
+++ b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm
@@ -0,0 +1,5 @@
+module DiagnosticsSample exposing (brokenFactory)
+
+brokenFactory : String
+brokenFactory =
+ "unterminated
diff --git a/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl
new file mode 100644
index 00000000..edb18776
--- /dev/null
+++ b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl
@@ -0,0 +1,5 @@
+-module(diagnostics_sample).
+-export([broken_factory/0, broken_consumer/0]).
+
+broken_factory() -> MissingGreeting.
+broken_consumer() -> MissingConsumerValue.
diff --git a/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90
new file mode 100644
index 00000000..2c23e5df
--- /dev/null
+++ b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90
@@ -0,0 +1,5 @@
+program diagnostics_sample
+ implicit none
+ integer :: duplicated_value
+ real :: duplicated_value
+end program diagnostics_sample
diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy
new file mode 100644
index 00000000..a176203e
--- /dev/null
+++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy
@@ -0,0 +1,13 @@
+package com.example
+
+class DiagnosticsSample {
+ static String brokenFactory() {
+ missingGreeting
+ }
+
+ static void brokenConsumer() {
+ def value = brokenFactory()
+ println(value)
+ println(missingConsumerValue)
+ }
+}
diff --git a/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs
new file mode 100644
index 00000000..e5777b1f
--- /dev/null
+++ b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs
@@ -0,0 +1,7 @@
+module DiagnosticsSample where
+
+brokenFactory :: String
+brokenFactory = missingGreeting
+
+brokenConsumer :: String
+brokenConsumer = missingConsumerValue
diff --git a/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx
new file mode 100644
index 00000000..3b2e2c29
--- /dev/null
+++ b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx
@@ -0,0 +1,10 @@
+class DiagnosticsSample {
+ static function brokenFactory():String {
+ return missingGreeting;
+ }
+
+ static function brokenConsumer():Void {
+ trace(brokenFactory());
+ trace(missingConsumerValue);
+ }
+}
diff --git a/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl
new file mode 100644
index 00000000..ffc8075c
--- /dev/null
+++ b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl
@@ -0,0 +1,3 @@
+float4 main() : SV_Target {
+ return missingGreeting;
+}
diff --git a/test/resources/repos/json/test_repo/diagnostics_sample.json b/test/resources/repos/json/test_repo/diagnostics_sample.json
new file mode 100644
index 00000000..0cc75efd
--- /dev/null
+++ b/test/resources/repos/json/test_repo/diagnostics_sample.json
@@ -0,0 +1,4 @@
+{
+ "name": "diagnostics",
+ "value":
+}
diff --git a/test/resources/repos/julia/test_repo/diagnostics_sample.jl b/test/resources/repos/julia/test_repo/diagnostics_sample.jl
new file mode 100644
index 00000000..ef7e4a51
--- /dev/null
+++ b/test/resources/repos/julia/test_repo/diagnostics_sample.jl
@@ -0,0 +1,3 @@
+function broken_factory(
+ value = 1
+end
diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
index a2c570ec..169754f7 100644
--- a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
+++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean
@@ -1,6 +1 @@
def brokenFactory : Nat :=
- missingGreeting
-
-def brokenConsumer : Nat :=
- let value := brokenFactory
- value + missingConsumerValue
diff --git a/test/resources/repos/matlab/test_repo/diagnostics_sample.m b/test/resources/repos/matlab/test_repo/diagnostics_sample.m
new file mode 100644
index 00000000..b0f87a62
--- /dev/null
+++ b/test/resources/repos/matlab/test_repo/diagnostics_sample.m
@@ -0,0 +1,3 @@
+function value = diagnostics_sample()
+value = missingGreeting;
+end
diff --git a/test/resources/repos/nix/test_repo/diagnostics_sample.nix b/test/resources/repos/nix/test_repo/diagnostics_sample.nix
new file mode 100644
index 00000000..320a6ab8
--- /dev/null
+++ b/test/resources/repos/nix/test_repo/diagnostics_sample.nix
@@ -0,0 +1,4 @@
+{
+ greeting = missingGreeting;
+ consumer = missingConsumerValue;
+}
diff --git a/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml
new file mode 100644
index 00000000..c23ccefe
--- /dev/null
+++ b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml
@@ -0,0 +1 @@
+let broken_factory (
diff --git a/test/resources/repos/ocaml/test_repo/lib/dune b/test/resources/repos/ocaml/test_repo/lib/dune
index fe41978f..aba011b1 100644
--- a/test/resources/repos/ocaml/test_repo/lib/dune
+++ b/test/resources/repos/ocaml/test_repo/lib/dune
@@ -1,3 +1,4 @@
(library
(public_name test_repo)
- (name test_repo))
\ No newline at end of file
+ (name test_repo)
+ (modules test_repo))
diff --git a/test/resources/repos/pascal/test_repo/diagnostics_sample.pas b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas
new file mode 100644
index 00000000..7a3bf84b
--- /dev/null
+++ b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas
@@ -0,0 +1,4 @@
+program DiagnosticsSample;
+begin
+ writeln(
+end.
diff --git a/test/resources/repos/perl/test_repo/diagnostics_sample.pl b/test/resources/repos/perl/test_repo/diagnostics_sample.pl
new file mode 100644
index 00000000..4d78b686
--- /dev/null
+++ b/test/resources/repos/perl/test_repo/diagnostics_sample.pl
@@ -0,0 +1,5 @@
+use strict;
+use warnings;
+
+print $missingGreeting;
+print $missingConsumerValue;
diff --git a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1
index d06165f2..c5ce7600 100644
--- a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1
+++ b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1
@@ -1,9 +1,4 @@
function Invoke-BrokenFactory {
- Invoke-MissingGreeting
-}
-
-function Invoke-BrokenConsumer {
- $value = Invoke-BrokenFactory
- Write-Output $value
- Invoke-MissingConsumerValue
+ if ($true) {
+ Write-Output "broken"
}
diff --git a/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala
new file mode 100644
index 00000000..ca9d929b
--- /dev/null
+++ b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala
@@ -0,0 +1,9 @@
+package com.example
+
+object DiagnosticsSample {
+ def brokenFactory(): String = missingGreeting
+ def brokenConsumer(): Unit = {
+ println(brokenFactory())
+ println(missingConsumerValue)
+ }
+}
diff --git a/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol
new file mode 100644
index 00000000..7b2a0892
--- /dev/null
+++ b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol
@@ -0,0 +1,7 @@
+pragma solidity ^0.8.0;
+
+contract DiagnosticsSample {
+ function brokenFactory() pure returns (string memory) {
+ return "broken";
+ }
+}
diff --git a/test/resources/repos/swift/test_repo/Package.swift b/test/resources/repos/swift/test_repo/Package.swift
index 13a02704..5e9b7cff 100644
--- a/test/resources/repos/swift/test_repo/Package.swift
+++ b/test/resources/repos/swift/test_repo/Package.swift
@@ -11,6 +11,7 @@ let package = Package(
targets: [
.target(
name: "test_repo",
- dependencies: []),
+ dependencies: [],
+ path: "src"),
]
-)
\ No newline at end of file
+)
diff --git a/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift
new file mode 100644
index 00000000..b70b2c68
--- /dev/null
+++ b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift
@@ -0,0 +1,3 @@
+func brokenFactory() -> String {
+ return
+}
diff --git a/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv
new file mode 100644
index 00000000..b5364262
--- /dev/null
+++ b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv
@@ -0,0 +1,5 @@
+module diagnostics_sample;
+ initial begin
+ if (
+ end
+endmodule
diff --git a/test/resources/repos/terraform/test_repo/diagnostics_sample.tf b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf
new file mode 100644
index 00000000..15de22c0
--- /dev/null
+++ b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf
@@ -0,0 +1,4 @@
+resource "aws_instance" "broken" {
+ ami =
+ instance_type = "t2.micro"
+}
diff --git a/test/resources/repos/toml/test_repo/diagnostics_sample.toml b/test/resources/repos/toml/test_repo/diagnostics_sample.toml
new file mode 100644
index 00000000..ed4ae4c1
--- /dev/null
+++ b/test/resources/repos/toml/test_repo/diagnostics_sample.toml
@@ -0,0 +1,2 @@
+[diagnostics]
+value =
diff --git a/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue
new file mode 100644
index 00000000..17bd62e3
--- /dev/null
+++ b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue
@@ -0,0 +1,3 @@
+
diff --git a/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml
new file mode 100644
index 00000000..c07e469d
--- /dev/null
+++ b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml
@@ -0,0 +1,5 @@
+name: diagnostics
+value:
+ - good
+ -
+ broken: [
diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig
index da7317d0..c9c1ff5a 100644
--- a/test/resources/repos/zig/test_repo/build.zig
+++ b/test/resources/repos/zig/test_repo/build.zig
@@ -28,4 +28,4 @@ pub fn build(b: *std.Build) void {
const run_lib_tests = b.addRunArtifact(lib_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_lib_tests.step);
-}
\ No newline at end of file
+}
diff --git a/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig
new file mode 100644
index 00000000..844d6f14
--- /dev/null
+++ b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig
@@ -0,0 +1,3 @@
+pub fn brokenFactory() void {
+ const value =
+}
diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig
index 326b2cd0..156adabd 100644
--- a/test/resources/repos/zig/test_repo/src/main.zig
+++ b/test/resources/repos/zig/test_repo/src/main.zig
@@ -29,4 +29,4 @@ pub fn main() !void {
pub fn greeting(name: []const u8) []const u8 {
return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!";
-}
\ No newline at end of file
+}
diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr
index 8fd3a73b..ec23cf33 100644
--- a/test/serena/__snapshots__/test_symbol_editing.ambr
+++ b/test/serena/__snapshots__/test_symbol_editing.ambr
@@ -68,13 +68,13 @@
# name: test_delete_symbol[test_case1]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
-
+
+
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
@@ -521,7 +521,7 @@
# name: test_insert_in_rel_to_symbol[test_case1-after]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -539,7 +539,7 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
@@ -555,7 +555,7 @@
# name: test_insert_in_rel_to_symbol[test_case1-before]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
+
function newFunctionAfterClass(): void {
console.log("This function is after DemoClass.");
}
@@ -573,7 +573,7 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
@@ -589,7 +589,7 @@
# name: test_insert_in_rel_to_symbol[test_case2-after]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -603,7 +603,7 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
@@ -623,7 +623,7 @@
# name: test_insert_in_rel_to_symbol[test_case2-before]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -641,7 +641,7 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
@@ -1712,7 +1712,7 @@
# name: test_replace_body[test_case1]
'''
import { ConsoleGreeter, Greeter } from "./formatters";
-
+
export class DemoClass {
value: number;
constructor(value: number) {
@@ -1727,7 +1727,7 @@
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
-
+
const greeter: Greeter = new ConsoleGreeter();
console.log(greeter.formatGreeting("World"));
}
diff --git a/test/solidlsp/bash/test_bash_diagnostics.py b/test/solidlsp/bash/test_bash_diagnostics.py
new file mode 100644
index 00000000..bd1cc494
--- /dev/null
+++ b/test/solidlsp/bash/test_bash_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.bash
+class TestBashDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.sh",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py
index 5e8e1c93..f7d9dad6 100644
--- a/test/solidlsp/clojure/__init__.py
+++ b/test/solidlsp/clojure/__init__.py
@@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool:
CLI_FAIL = _test_clojure_cli()
TEST_APP_PATH = Path("src") / "test_app"
-CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/")
-UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/")
+CORE_PATH = str(TEST_APP_PATH / "core.clj")
+UTILS_PATH = str(TEST_APP_PATH / "utils.clj")
def is_clojure_cli_available() -> bool:
diff --git a/test/solidlsp/clojure/test_clojure_diagnostics.py b/test/solidlsp/clojure/test_clojure_diagnostics.py
new file mode 100644
index 00000000..a7bff96b
--- /dev/null
+++ b/test/solidlsp/clojure/test_clojure_diagnostics.py
@@ -0,0 +1,19 @@
+import os
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.clojure
+class TestClojureDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ os.path.join("src", "test_app", "diagnostics_sample.clj"),
+ ("missing-greeting", "missing-consumer-value"),
+ min_count=2,
+ )
diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py
index 08bb6a01..ee563757 100644
--- a/test/solidlsp/conftest.py
+++ b/test/solidlsp/conftest.py
@@ -5,6 +5,12 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY]
+def is_diagnostics_test_file(relative_path: str) -> bool:
+ normalized_path = relative_path.replace("\\", "/")
+ filename = normalized_path.rsplit("/", 1)[-1].lower()
+ return filename.startswith(("diagnosticssample.", "diagnostics_sample."))
+
+
def has_malformed_name(
symbol: UnifiedSymbolInformation,
whitespace_allowed: bool = False,
@@ -36,6 +42,9 @@ def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSym
result: list[UnifiedSymbolInformation] = []
def visit(symbol: UnifiedSymbolInformation) -> None:
+ relative_path = symbol.get("location", {}).get("relativePath", "")
+ if relative_path and is_diagnostics_test_file(relative_path):
+ return
result.append(symbol)
for child in symbol.get("children", []):
visit(child)
diff --git a/test/solidlsp/cpp/test_cpp_diagnostics.py b/test/solidlsp/cpp/test_cpp_diagnostics.py
new file mode 100644
index 00000000..20a58368
--- /dev/null
+++ b/test/solidlsp/cpp/test_cpp_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.cpp
+class TestCppDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.CPP], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.cpp",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py
new file mode 100644
index 00000000..e650adc1
--- /dev/null
+++ b/test/solidlsp/crystal/test_crystal_diagnostics.py
@@ -0,0 +1,23 @@
+import shutil
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+pytestmark = [
+ pytest.mark.crystal,
+ pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"),
+]
+
+
+class TestCrystalDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/diagnostics_sample.cr",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/csharp/test_csharp_diagnostics.py b/test/solidlsp/csharp/test_csharp_diagnostics.py
new file mode 100644
index 00000000..de32fd1d
--- /dev/null
+++ b/test/solidlsp/csharp/test_csharp_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.csharp
+class TestCsharpDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "DiagnosticsSample.cs",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/dart/test_dart_diagnostics.py b/test/solidlsp/dart/test_dart_diagnostics.py
new file mode 100644
index 00000000..ddc5e398
--- /dev/null
+++ b/test/solidlsp/dart/test_dart_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.dart
+class TestDartDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.DART], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "lib/diagnostics_sample.dart",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py
index 11b983ca..7c44abc4 100644
--- a/test/solidlsp/elixir/test_elixir_basic.py
+++ b/test/solidlsp/elixir/test_elixir_basic.py
@@ -13,6 +13,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
@@ -138,3 +139,12 @@ class TestElixirBasic:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "lib/diagnostics_sample.ex",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/elm/test_elm_diagnostics.py b/test/solidlsp/elm/test_elm_diagnostics.py
new file mode 100644
index 00000000..744201a2
--- /dev/null
+++ b/test/solidlsp/elm/test_elm_diagnostics.py
@@ -0,0 +1,20 @@
+import shutil
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.elm
+@pytest.mark.skipif(shutil.which("node") is None or shutil.which("npm") is None, reason="Elm diagnostics require Node.js and npm")
+class TestElmDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "DiagnosticsSample.elm",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py
index a1dba06a..dc39535b 100644
--- a/test/solidlsp/erlang/test_erlang_basic.py
+++ b/test/solidlsp/erlang/test_erlang_basic.py
@@ -10,6 +10,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON
@@ -55,3 +56,12 @@ class TestErlangLanguageServerBasics:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/diagnostics_sample.erl",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/fortran/test_fortran_diagnostics.py b/test/solidlsp/fortran/test_fortran_diagnostics.py
new file mode 100644
index 00000000..f607cd0b
--- /dev/null
+++ b/test/solidlsp/fortran/test_fortran_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.fortran
+class TestFortranDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.f90",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py
index f58ec0b1..f4f49ebc 100644
--- a/test/solidlsp/fsharp/test_fsharp_basic.py
+++ b/test/solidlsp/fsharp/test_fsharp_basic.py
@@ -9,6 +9,7 @@ from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
# Currently, most F# tests fail, there seems to be a regression or instability.
@@ -196,21 +197,13 @@ class TestFSharpLanguageServer:
assert isinstance(result["value"], list), "Completions should be a list"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
- def test_diagnostics(self, language_server: SolidLanguageServer) -> None:
- """Test getting diagnostics (errors, warnings) from F# files."""
- file_path = os.path.join("Program.fs")
-
- # FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests
- # So we'll test that the language server can handle files without crashing
- # In real usage, diagnostics would come through the publishDiagnostics notification handler
-
- # Test that we can at least work with the file (open/close cycle)
- with language_server.open_file(file_path) as _:
- # If we can open and close the file without errors, basic diagnostics support is working
- pass
-
- # This is a successful test - FsAutoComplete is working with F# files
- assert True, "F# language server can handle files successfully"
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "DiagnosticsSample.fs",
+ (),
+ min_count=1,
+ )
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_bare_symbol_names(self, language_server) -> None:
diff --git a/test/solidlsp/go/test_go_diagnostics.py b/test/solidlsp/go/test_go_diagnostics.py
new file mode 100644
index 00000000..00b4cc48
--- /dev/null
+++ b/test/solidlsp/go/test_go_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.go
+class TestGoDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.GO], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.go",
+ ("missingGreeting", "missingConsumerValue"),
+ min_count=2,
+ )
diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py
index 47f22ed8..0637e3de 100644
--- a/test/solidlsp/groovy/test_groovy_basic.py
+++ b/test/solidlsp/groovy/test_groovy_basic.py
@@ -9,6 +9,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_utils import SymbolUtils
from solidlsp.settings import SolidLSPSettings
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.groovy
@@ -117,3 +118,12 @@ class TestGroovyLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ def test_file_diagnostics(self) -> None:
+ assert self.language_server is not None
+ assert_file_diagnostics(
+ self.language_server,
+ "src/main/groovy/com/example/DiagnosticsSample.groovy",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py
index 67452151..5b23ad3d 100644
--- a/test/solidlsp/haskell/test_haskell_basic.py
+++ b/test/solidlsp/haskell/test_haskell_basic.py
@@ -21,6 +21,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.haskell
@@ -240,3 +241,12 @@ class TestHaskellLanguageServer:
for sym in malformed_symbols
]
pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False)
+
+ @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/DiagnosticsSample.hs",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/haxe/test_haxe_diagnostics.py b/test/solidlsp/haxe/test_haxe_diagnostics.py
new file mode 100644
index 00000000..2f789b60
--- /dev/null
+++ b/test/solidlsp/haxe/test_haxe_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.haxe
+class TestHaxeDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/DiagnosticsSample.hx",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/hlsl/test_hlsl_diagnostics.py b/test/solidlsp/hlsl/test_hlsl_diagnostics.py
new file mode 100644
index 00000000..e356d2da
--- /dev/null
+++ b/test/solidlsp/hlsl/test_hlsl_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.hlsl
+class TestHlslDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.hlsl",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/java/test_java_diagnostics.py b/test/solidlsp/java/test_java_diagnostics.py
new file mode 100644
index 00000000..ebd59457
--- /dev/null
+++ b/test/solidlsp/java/test_java_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.java
+class TestJavaDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/main/java/test_repo/DiagnosticsSample.java",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/json_ls/test_json_diagnostics.py b/test/solidlsp/json_ls/test_json_diagnostics.py
new file mode 100644
index 00000000..7850083a
--- /dev/null
+++ b/test/solidlsp/json_ls/test_json_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.json
+class TestJsonDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.json",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py
index 44adb43d..176587f7 100644
--- a/test/solidlsp/julia/test_julia_basic.py
+++ b/test/solidlsp/julia/test_julia_basic.py
@@ -6,6 +6,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import is_ci
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.skipif(shutil.which("julia") is None and not is_ci, reason="Julia is not available")
@@ -66,3 +67,12 @@ class TestJuliaLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.jl",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py
new file mode 100644
index 00000000..c189825f
--- /dev/null
+++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.kotlin
+class TestKotlinDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/main/kotlin/test_repo/DiagnosticsSample.kt",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py
new file mode 100644
index 00000000..964bc2c6
--- /dev/null
+++ b/test/solidlsp/lean4/test_lean4_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.lean4
+class TestLean4Diagnostics:
+ @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "DiagnosticsSample.lean",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py
index 71724ff7..a1582887 100644
--- a/test/solidlsp/matlab/test_matlab_basic.py
+++ b/test/solidlsp/matlab/test_matlab_basic.py
@@ -17,6 +17,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
# Skip all tests if MATLAB is not available
pytestmark = pytest.mark.matlab
@@ -129,3 +130,12 @@ class TestMatlabLanguageServerReferences:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.m",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py
index 23f4311a..e66ddfa5 100644
--- a/test/solidlsp/nix/test_nix_basic.py
+++ b/test/solidlsp/nix/test_nix_basic.py
@@ -13,6 +13,7 @@ from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.conftest import is_ci
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
# Skip all Nix tests on Windows as Nix doesn't support Windows
pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows")
@@ -250,3 +251,12 @@ class TestNixLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.nix",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py
new file mode 100644
index 00000000..a76f95cb
--- /dev/null
+++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.ocaml
+class TestOcamlDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "lib/diagnostics_sample.ml",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/pascal/test_pascal_diagnostics.py b/test/solidlsp/pascal/test_pascal_diagnostics.py
new file mode 100644
index 00000000..ad96a1ea
--- /dev/null
+++ b/test/solidlsp/pascal/test_pascal_diagnostics.py
@@ -0,0 +1,25 @@
+import shutil
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.conftest import language_tests_enabled
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+pytestmark = [
+ pytest.mark.pascal,
+ pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled"),
+ pytest.mark.skipif(shutil.which("fpc") is None, reason="Pascal diagnostics require the Free Pascal compiler"),
+]
+
+
+class TestPascalDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.pas",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py
index 41682e3f..efb01d01 100644
--- a/test/solidlsp/perl/test_perl_basic.py
+++ b/test/solidlsp/perl/test_perl_basic.py
@@ -6,6 +6,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.perl
@@ -88,3 +89,12 @@ class TestPerlLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.pl",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py
new file mode 100644
index 00000000..1cfc67de
--- /dev/null
+++ b/test/solidlsp/php/test_php_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.php
+class TestPhpDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.php",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/powershell/test_powershell_diagnostics.py b/test/solidlsp/powershell/test_powershell_diagnostics.py
new file mode 100644
index 00000000..ac8706cf
--- /dev/null
+++ b/test/solidlsp/powershell/test_powershell_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.powershell
+class TestPowershellDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.ps1",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/python/test_python_diagnostics.py b/test/solidlsp/python/test_python_diagnostics.py
new file mode 100644
index 00000000..386d2830
--- /dev/null
+++ b/test/solidlsp/python/test_python_diagnostics.py
@@ -0,0 +1,19 @@
+import os
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from test.conftest import PYTHON_LANGUAGE_BACKENDS
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.python
+class TestPythonDiagnostics:
+ @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ os.path.join("test_repo", "diagnostics_sample.py"),
+ ("missing_user", "undefined_name"),
+ min_count=2,
+ )
diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py
index 29fce938..4c7ec2c0 100644
--- a/test/solidlsp/rust/test_rust_basic.py
+++ b/test/solidlsp/rust/test_rust_basic.py
@@ -90,7 +90,7 @@ class TestRustLanguageServer:
all_symbols = request_all_symbols(language_server)
malformed_symbols = []
for s in all_symbols:
- if has_malformed_name(s):
+ if has_malformed_name(s, whitespace_allowed=True):
malformed_symbols.append(s)
if malformed_symbols:
pytest.fail(
diff --git a/test/solidlsp/rust/test_rust_diagnostics.py b/test/solidlsp/rust/test_rust_diagnostics.py
new file mode 100644
index 00000000..b08f7cf7
--- /dev/null
+++ b/test/solidlsp/rust/test_rust_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.rust
+class TestRustDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/diagnostics_sample.rs",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py
index bfc6d8f1..4e60209d 100644
--- a/test/solidlsp/scala/test_scala_language_server.py
+++ b/test/solidlsp/scala/test_scala_language_server.py
@@ -6,6 +6,7 @@ import pytest
from solidlsp.language_servers.scala_language_server import ScalaLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True)
@@ -62,3 +63,12 @@ def test_scala_find_definition_and_references_across_files(scala_ls):
assert first_def["range"]["start"]["character"] == 6
assert first_def["range"]["end"]["line"] == 7
assert first_def["range"]["end"]["character"] == 14
+
+
+def test_file_diagnostics(scala_ls) -> None:
+ assert_file_diagnostics(
+ scala_ls,
+ "src/main/scala/com/example/DiagnosticsSample.scala",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py
new file mode 100644
index 00000000..fd3cd6ba
--- /dev/null
+++ b/test/solidlsp/solidity/test_solidity_diagnostics.py
@@ -0,0 +1,24 @@
+import sys
+
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.solidity
+class TestSolidityDiagnostics:
+ @pytest.mark.xfail(
+ sys.platform == "darwin",
+ reason="Unknown — passes on Ubuntu but consistently fails on macOS CI; root cause not yet identified.",
+ strict=False,
+ )
+ @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "contracts/DiagnosticsSample.sol",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py
index 5a0d8bd0..30e9759c 100644
--- a/test/solidlsp/swift/test_swift_basic.py
+++ b/test/solidlsp/swift/test_swift_basic.py
@@ -16,6 +16,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import is_ci
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
# Skip Swift tests on Windows due to complex GitHub Actions configuration
WINDOWS_SKIP = platform.system() == "Windows"
@@ -238,3 +239,12 @@ class TestSwiftProjectBasics:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/diagnostics_sample.swift",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py
new file mode 100644
index 00000000..a783b828
--- /dev/null
+++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.systemverilog
+class TestSystemverilogDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.sv",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/terraform/test_terraform_diagnostics.py b/test/solidlsp/terraform/test_terraform_diagnostics.py
new file mode 100644
index 00000000..318cc7f9
--- /dev/null
+++ b/test/solidlsp/terraform/test_terraform_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.terraform
+class TestTerraformDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.tf",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/toml/test_toml_diagnostics.py b/test/solidlsp/toml/test_toml_diagnostics.py
new file mode 100644
index 00000000..dde2fc83
--- /dev/null
+++ b/test/solidlsp/toml/test_toml_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.toml
+class TestTomlDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.toml",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/typescript/test_typescript_diagnostics.py b/test/solidlsp/typescript/test_typescript_diagnostics.py
new file mode 100644
index 00000000..5e879e16
--- /dev/null
+++ b/test/solidlsp/typescript/test_typescript_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.typescript
+class TestTypeScriptDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.ts",
+ ("missingGreeting", "missingConsumerValue"),
+ min_count=2,
+ )
diff --git a/test/solidlsp/util/diagnostics.py b/test/solidlsp/util/diagnostics.py
new file mode 100644
index 00000000..dee66086
--- /dev/null
+++ b/test/solidlsp/util/diagnostics.py
@@ -0,0 +1,19 @@
+from collections.abc import Sequence
+
+from solidlsp import SolidLanguageServer
+
+
+def assert_file_diagnostics(
+ language_server: SolidLanguageServer,
+ relative_file_path: str,
+ expected_message_fragments: Sequence[str],
+ min_count: int = 1,
+) -> None:
+ diagnostics = language_server.request_text_document_diagnostics(relative_file_path, min_severity=1)
+
+ assert isinstance(diagnostics, list), diagnostics
+ assert len(diagnostics) >= min_count, diagnostics
+
+ diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics]
+ for fragment in expected_message_fragments:
+ assert any(fragment in message for message in diagnostic_messages), diagnostic_messages
diff --git a/test/solidlsp/vue/test_vue_diagnostics.py b/test/solidlsp/vue/test_vue_diagnostics.py
new file mode 100644
index 00000000..08ac2fed
--- /dev/null
+++ b/test/solidlsp/vue/test_vue_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.vue
+class TestVueDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/DiagnosticsSample.vue",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/yaml_ls/test_yaml_diagnostics.py b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py
new file mode 100644
index 00000000..4ba774c7
--- /dev/null
+++ b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py
@@ -0,0 +1,17 @@
+import pytest
+
+from solidlsp import SolidLanguageServer
+from solidlsp.ls_config import Language
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
+
+
+@pytest.mark.yaml
+class TestYamlDiagnostics:
+ @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "diagnostics_sample.yaml",
+ (),
+ min_count=1,
+ )
diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py
index 51b23650..e7413482 100644
--- a/test/solidlsp/zig/test_zig_basic.py
+++ b/test/solidlsp/zig/test_zig_basic.py
@@ -14,6 +14,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
+from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.zig
@@ -355,3 +356,12 @@ class TestZigLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
+
+ @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True)
+ def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
+ assert_file_diagnostics(
+ language_server,
+ "src/diagnostics_sample.zig",
+ (),
+ min_count=1,
+ )
From da59a19c5060ba896c73c06a5886e054f9f8ddc7 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 29 Apr 2026 09:25:58 +0200
Subject: [PATCH 21/32] Fixes in various LS and in tests to accommodate
diagnostics edge behavior
Also minor fix in go test expectations of symbol name
---
.github/workflows/pytest.yml | 4 +-
scripts/demo_run_tools.py | 6 +-
.../language_servers/bash_language_server.py | 133 +++++++++++++++---
.../language_servers/elm_language_server.py | 24 ++++
.../fortran_language_server.py | 33 ++++-
.../language_servers/haxe_language_server.py | 9 +-
src/solidlsp/language_servers/julia_server.py | 49 ++++++-
.../language_servers/ocaml_lsp_server.py | 10 +-
.../powershell_language_server.py | 62 +++++++-
.../language_servers/pyright_server.py | 49 +++++++
.../solidity_language_server.py | 132 ++++++++++++++++-
src/solidlsp/ls.py | 99 ++++++++++---
test/serena/test_serena_agent.py | 2 +-
test/solidlsp/go/test_go_basic.py | 4 +-
14 files changed, 560 insertions(+), 56 deletions(-)
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 86246a2e..1d2b79a6 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -235,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
diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py
index 4914201c..1c95365c 100644
--- a/scripts/demo_run_tools.py
+++ b/scripts/demo_run_tools.py
@@ -8,11 +8,12 @@ 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,
@@ -24,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)
@@ -40,7 +42,7 @@ if __name__ == "__main__":
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="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj",
# keep_definition=True,
diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py
index c618884b..9dfc9356 100644
--- a/src/solidlsp/language_servers/bash_language_server.py
+++ b/src/solidlsp/language_servers/bash_language_server.py
@@ -18,12 +18,64 @@ 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).
+_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/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/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,7 +105,7 @@ 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
@@ -61,29 +113,29 @@ class BashLanguageServer(SolidLanguageServer):
bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0")
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
+ 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 +144,49 @@ 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_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
+ 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 _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "start"]
diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py
index 0c6139c1..a86a1d2d 100644
--- a/src/solidlsp/language_servers/elm_language_server.py
+++ b/src/solidlsp/language_servers/elm_language_server.py
@@ -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
@@ -217,3 +218,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)
diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py
index a1d5d56d..a80efc68 100644
--- a/src/solidlsp/language_servers/fortran_language_server.py
+++ b/src/solidlsp/language_servers/fortran_language_server.py
@@ -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
@@ -285,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)
diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py
index 41c63a2e..cf48c78a 100644
--- a/src/solidlsp/language_servers/haxe_language_server.py
+++ b/src/solidlsp/language_servers/haxe_language_server.py
@@ -215,7 +215,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": {},
diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py
index 41d23c02..ff3acd66 100644
--- a/src/solidlsp/language_servers/julia_server.py
+++ b/src/solidlsp/language_servers/julia_server.py
@@ -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.")
diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py
index 8c9bc356..b2e9511a 100644
--- a/src/solidlsp/language_servers/ocaml_lsp_server.py
+++ b/src/solidlsp/language_servers/ocaml_lsp_server.py
@@ -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,
diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py
index 72f9ac27..e737b09a 100644
--- a/src/solidlsp/language_servers/powershell_language_server.py
+++ b/src/solidlsp/language_servers/powershell_language_server.py
@@ -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
@@ -33,6 +37,7 @@ log = logging.getLogger(__name__)
PSES_VERSION = "4.4.0"
PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
+PSSCRIPTANALYZER_VERSION = "1.25.0"
class PowerShellLanguageServer(SolidLanguageServer):
@@ -157,6 +162,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 +373,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)
diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py
index efeefdaa..7f774836 100644
--- a/src/solidlsp/language_servers/pyright_server.py
+++ b/src/solidlsp/language_servers/pyright_server.py
@@ -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)
diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py
index 1e7ccd2e..a41deb89 100644
--- a/src/solidlsp/language_servers/solidity_language_server.py
+++ b/src/solidlsp/language_servers/solidity_language_server.py
@@ -7,13 +7,17 @@ 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
@@ -70,6 +74,7 @@ class SolidityLanguageServer(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."
solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4")
+ forge_version = self._custom_settings.get("forge_version", "1.5.1")
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -84,17 +89,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")
+ 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 +123,33 @@ 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_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
+ managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
+ return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")}
+
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@@ -225,3 +269,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('/')}"
diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py
index 4ea04694..57a5cdfd 100644
--- a/src/solidlsp/ls.py
+++ b/src/solidlsp/ls.py
@@ -618,15 +618,49 @@ class SolidLanguageServer(ABC):
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[uri] = normalized_diagnostics
- self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation
+ 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(uri, -1)
+ return self._published_diagnostics_generation_by_uri.get(key, -1)
def _wait_for_published_diagnostics(
self,
@@ -634,12 +668,13 @@ class SolidLanguageServer(ABC):
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(uri, -1)
+ current_generation = self._published_diagnostics_generation_by_uri.get(key, -1)
if current_generation > after_generation:
- return list(self._published_diagnostics.get(uri, []))
+ return list(self._published_diagnostics.get(key, []))
remaining_timeout = deadline - perf_counter()
if remaining_timeout <= 0:
@@ -647,8 +682,9 @@ class SolidLanguageServer(ABC):
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(uri)
+ diagnostics = self._published_diagnostics.get(key)
if diagnostics is None:
return None
return list(diagnostics)
@@ -657,8 +693,17 @@ class SolidLanguageServer(ABC):
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"]
- effective_end_line = end_line if end_line >= 0 else diagnostic_end_line
- return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= start_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:
@@ -777,6 +822,16 @@ class SolidLanguageServer(ABC):
"""
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.
@@ -851,18 +906,22 @@ class SolidLanguageServer(ABC):
pull_diagnostics_failed = False
with self.open_file(relative_file_path):
- try:
- response = self.server.send.text_document_diagnostic(
- {
- LSPConstants.TEXT_DOCUMENT: { # type: ignore
- LSPConstants.URI: uri,
+ 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
+ )
+ 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), (
@@ -875,7 +934,7 @@ class SolidLanguageServer(ABC):
"severity": item["severity"],
"message": item["message"],
"range": item["range"],
- "code": item["code"], # type: ignore
+ "code": item.get("code"), # type: ignore
}
if "source" in item:
new_item["source"] = item["source"]
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index a577b849..974cdc4b 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -513,7 +513,7 @@ FIND_SYMBOL_REFERENCES_CASES = [
symbol_name="Calculator",
expected_kind="Module",
expected_file="Calculator.fs",
- ).to_pytest_param(),
+ ).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")),
FindSymbolCase(
language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs"
).to_pytest_param(),
diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py
index 02aa3b9b..347f6022 100644
--- a/test/solidlsp/go/test_go_basic.py
+++ b/test/solidlsp/go/test_go_basic.py
@@ -71,9 +71,9 @@ class TestGoLanguageServer:
implementing_symbols = language_server.request_implementing_symbols("main.go", *pos)
assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting"
assert any(
- symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "")
+ symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "")
for symbol in implementing_symbols
- ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}"
+ ), f"Expected FormatGreeting symbol, got: {implementing_symbols}"
def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list:
From 15858e6cd99c30ae26cc5bc038d1be9b58e9b8d4 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 21:58:10 +0200
Subject: [PATCH 22/32] Refactoring: Introduce base class
EditingToolWithDiagnostics
---
src/serena/tools/file_tools.py | 14 +-
src/serena/tools/symbol_tools.py | 7 +-
src/serena/tools/tools_base.py | 212 ++++++++++++++++---------------
3 files changed, 122 insertions(+), 111 deletions(-)
diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py
index 42923a03..4572f2fc 100644
--- a/src/serena/tools/file_tools.py
+++ b/src/serena/tools/file_tools.py
@@ -13,7 +13,7 @@ from pathlib import Path
from typing import Literal
from serena.code_editor import EditedFilePath
-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
@@ -49,7 +49,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.
"""
@@ -164,13 +164,11 @@ class FindFileTool(Tool):
return result
-class ReplaceContentTool(Tool, ToolMarkerCanEdit):
+class ReplaceContentTool(EditingToolWithDiagnostics):
"""
Replaces content in a file (optionally using regular expressions).
"""
- _CAPTURE_DIAGNOSTICS: bool = False
-
def apply(
self,
relative_path: str,
@@ -237,7 +235,7 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
-class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
+class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Deletes a range of lines within a file.
"""
@@ -268,7 +266,7 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
-class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
+class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Replaces a range of lines within a file with new content.
"""
@@ -306,7 +304,7 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
-class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
+class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional):
"""
Inserts content at a given line in a file.
"""
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index 2a44a14e..036c1d71 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -13,6 +13,7 @@ from serena.code_editor import EditedFilePath
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper
from serena.tools import (
SUCCESS_RESULT,
+ EditingToolWithDiagnostics,
Tool,
ToolMarkerSymbolicEdit,
ToolMarkerSymbolicRead,
@@ -771,7 +772,7 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
return self._limit_length(result, max_answer_chars)
-class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
+class ReplaceSymbolBodyTool(EditingToolWithDiagnostics):
"""
Replaces the full definition of a symbol using the language server backend.
"""
@@ -810,7 +811,7 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
-class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
+class InsertAfterSymbolTool(EditingToolWithDiagnostics):
"""
Inserts content after the end of the definition of a given symbol.
"""
@@ -841,7 +842,7 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit):
return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
-class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit):
+class InsertBeforeSymbolTool(EditingToolWithDiagnostics):
"""
Inserts content before the beginning of the definition of a given symbol.
"""
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index 7ef28c3c..ca8e5a49 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -165,8 +165,6 @@ class Tool(Component):
_last_tool_call_client_str: str | None = None
"""We can only get the client info from within a tool call. Each tool call will update this variable."""
- _ENABLE_DIAGNOSTICS: bool = False
-
def __init__(self, agent: "SerenaAgent"):
super().__init__(agent)
@@ -326,6 +324,118 @@ class Tool(Component):
def is_symbolic(self) -> bool:
return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit)
+ def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
+ """
+ Applies the tool with logging and exception handling, using the given keyword arguments
+ """
+ session_id = "global"
+ if mcp_ctx is not None:
+ try:
+ session_id = "%x" % id(mcp_ctx.session)
+ client_params = mcp_ctx.session.client_params
+ if client_params is not None:
+ client_info = cast(Implementation, client_params.clientInfo)
+ client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version
+ 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 Exception as e:
+ log.info(f"Failed to get client info: {e}.")
+
+ def task() -> str:
+ apply_fn = self.get_apply_fn()
+
+ try:
+ if not self.is_active():
+ return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}"
+ except Exception as e:
+ return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}"
+
+ if log_call:
+ self._log_tool_application(inspect.currentframe(), session_id)
+ try:
+ # check whether the tool requires an active project and language server
+ if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
+ if self.agent.get_active_project() is None:
+ return (
+ "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: "
+ + f"{self.agent.serena_config.project_names}"
+ )
+
+ # construct apply kwargs, adding session_id if the tool is session-aware
+ apply_kwargs = dict(kwargs)
+ if self._is_session_aware:
+ apply_kwargs["session_id"] = session_id
+
+ # apply the actual tool
+ try:
+ result = apply_fn(**apply_kwargs)
+ except SolidLSPException as e:
+ if e.is_language_server_terminated():
+ affected_language = e.get_affected_language()
+ if affected_language is not None:
+ log.error(
+ f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..."
+ )
+ self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language)
+ result = apply_fn(**apply_kwargs)
+ else:
+ log.error(
+ f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying."
+ )
+ raise
+ else:
+ raise
+
+ # record tool usage
+ self.agent.record_tool_usage(apply_kwargs, result, self)
+
+ except Exception as e:
+ if not catch_exceptions:
+ raise
+ msg = f"Error executing tool: {e.__class__.__name__} - {e}"
+ log.error(f"Error executing tool: {e}", exc_info=e)
+ result = msg
+
+ if log_call:
+ log.info(f"Result: {result}")
+
+ try:
+ ls_manager = self.agent.get_language_server_manager()
+ if ls_manager is not None:
+ ls_manager.save_all_caches()
+ except Exception as e:
+ log.error(f"Error saving language server cache: {e}")
+
+ return result
+
+ # execute the tool in the agent's task executor, with timeout
+ try:
+ task_exec = self.agent.issue_task(task, name=self.__class__.__name__)
+ return task_exec.result(timeout=self.agent.serena_config.tool_timeout)
+ except Exception as e: # typically TimeoutError (other exceptions caught in task)
+ msg = f"Error: {e.__class__.__name__} - {e}"
+ log.error(msg)
+ return msg
+
+ @staticmethod
+ def _to_json(x: Any) -> str:
+ 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.
+ """
+
@staticmethod
def _diagnostic_code_repr(code: Any) -> str | None:
if code is None:
@@ -500,104 +610,6 @@ class Tool(Component):
return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}"
- def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
- """
- Applies the tool with logging and exception handling, using the given keyword arguments
- """
- session_id = "global"
- if mcp_ctx is not None:
- try:
- session_id = "%x" % id(mcp_ctx.session)
- client_params = mcp_ctx.session.client_params
- if client_params is not None:
- client_info = cast(Implementation, client_params.clientInfo)
- client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version
- 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 Exception as e:
- log.info(f"Failed to get client info: {e}.")
-
- def task() -> str:
- apply_fn = self.get_apply_fn()
-
- try:
- if not self.is_active():
- return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}"
- except Exception as e:
- return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}"
-
- if log_call:
- self._log_tool_application(inspect.currentframe(), session_id)
- try:
- # check whether the tool requires an active project and language server
- if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
- if self.agent.get_active_project() is None:
- return (
- "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: "
- + f"{self.agent.serena_config.project_names}"
- )
-
- # construct apply kwargs, adding session_id if the tool is session-aware
- apply_kwargs = dict(kwargs)
- if self._is_session_aware:
- apply_kwargs["session_id"] = session_id
-
- # apply the actual tool
- try:
- result = apply_fn(**apply_kwargs)
- except SolidLSPException as e:
- if e.is_language_server_terminated():
- affected_language = e.get_affected_language()
- if affected_language is not None:
- log.error(
- f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..."
- )
- self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language)
- result = apply_fn(**apply_kwargs)
- else:
- log.error(
- f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying."
- )
- raise
- else:
- raise
-
- # record tool usage
- self.agent.record_tool_usage(apply_kwargs, result, self)
-
- except Exception as e:
- if not catch_exceptions:
- raise
- msg = f"Error executing tool: {e.__class__.__name__} - {e}"
- log.error(f"Error executing tool: {e}", exc_info=e)
- result = msg
-
- if log_call:
- log.info(f"Result: {result}")
-
- try:
- ls_manager = self.agent.get_language_server_manager()
- if ls_manager is not None:
- ls_manager.save_all_caches()
- except Exception as e:
- log.error(f"Error saving language server cache: {e}")
-
- return result
-
- # execute the tool in the agent's task executor, with timeout
- try:
- task_exec = self.agent.issue_task(task, name=self.__class__.__name__)
- return task_exec.result(timeout=self.agent.serena_config.tool_timeout)
- except Exception as e: # typically TimeoutError (other exceptions caught in task)
- msg = f"Error: {e.__class__.__name__} - {e}"
- log.error(msg)
- return msg
-
- @staticmethod
- def _to_json(x: Any) -> str:
- return json.dumps(x, ensure_ascii=False)
-
class EditedFileContext:
"""
From 061020ce226e7f0790ee3baffe97c6f2b4bdef82 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 23:18:47 +0200
Subject: [PATCH 23/32] Refactor EditingToolWithDiagnostics
* Add context manager to simplify usage, improving output handling
* Add util module ls_diagnostics, reifying DiagnosticsDiff
---
scripts/demo_diagnostics.py | 12 +-
src/serena/code_editor.py | 8 +-
src/serena/tools/file_tools.py | 100 +++++--------
src/serena/tools/symbol_tools.py | 48 +++----
src/serena/tools/tools_base.py | 227 +++++-------------------------
src/serena/util/ls_diagnostics.py | 202 ++++++++++++++++++++++++++
test/serena/test_serena_agent.py | 69 +++++----
7 files changed, 343 insertions(+), 323 deletions(-)
create mode 100644 src/serena/util/ls_diagnostics.py
diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py
index 7aa85361..2e5cd30e 100644
--- a/scripts/demo_diagnostics.py
+++ b/scripts/demo_diagnostics.py
@@ -16,7 +16,13 @@ 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, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool
+from serena.tools import (
+ CreateTextFileTool,
+ EditingToolWithDiagnostics,
+ GetDiagnosticsForFileTool,
+ GetDiagnosticsForSymbolTool,
+ ReplaceContentTool,
+)
from solidlsp.ls_config import Language
SEPARATOR = "=" * 80
@@ -69,7 +75,7 @@ def parse_edit_diagnostics_result(result: str) -> dict:
if __name__ == "__main__":
- Tool._ENABLE_DIAGNOSTICS = True
+ EditingToolWithDiagnostics.ENABLE_DIAGNOSTICS = True
temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH))
temp_file = temp_dir / "demo_temp_diagnostics.py"
@@ -166,5 +172,5 @@ if __name__ == "__main__":
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:
- agent.shutdown()
shutil.rmtree(temp_dir, ignore_errors=True)
+ agent.shutdown()
diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py
index 7d52f2b8..ec834273 100644
--- a/src/serena/code_editor.py
+++ b/src/serena/code_editor.py
@@ -4,7 +4,6 @@ import os
from abc import ABC, abstractmethod
from collections.abc import Iterable, Iterator, Reversible
from contextlib import contextmanager
-from dataclasses import dataclass
from typing import Generic, TypeVar, cast
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
@@ -14,17 +13,12 @@ from solidlsp.ls import LSPFileBuffer
from solidlsp.ls_utils import PathUtils, TextUtils
from .project import Project
+from .util.ls_diagnostics import EditedFilePath
log = logging.getLogger(__name__)
TSymbol = TypeVar("TSymbol", bound=Symbol)
-@dataclass(frozen=True)
-class EditedFilePath:
- before_relative_path: str
- after_relative_path: str
-
-
class CodeEditor(Generic[TSymbol], ABC):
def __init__(self, project: Project) -> None:
self.project_root = project.project_root
diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py
index 4572f2fc..4b4313eb 100644
--- a/src/serena/tools/file_tools.py
+++ b/src/serena/tools/file_tools.py
@@ -12,7 +12,6 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
-from serena.code_editor import EditedFilePath
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
@@ -62,30 +61,27 @@ class CreateTextFileTool(EditingToolWithDiagnostics):
:param content: the (appropriately encoded) content to write to the file
:return: a message indicating success or failure
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+ 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()
- # 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=}"
- )
+ # 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."
- # 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 self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot)
+ return diagnostics_context.format_result(answer)
class ListDirTool(Tool):
@@ -220,19 +216,14 @@ class ReplaceContentTool(EditingToolWithDiagnostics):
Performs the replacement, with additional options not exposed in the tool.
This function can be used internally by other tools.
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
-
- # applying the content replacement
- 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 self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ 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(EditingToolWithDiagnostics, ToolMarkerOptional):
@@ -255,15 +246,10 @@ class DeleteLinesTool(EditingToolWithDiagnostics, 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
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
-
- # applying the edit
- code_editor = self.create_code_editor()
- code_editor.delete_lines(relative_path, start_line, end_line)
-
- return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ 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(EditingToolWithDiagnostics, ToolMarkerOptional):
@@ -292,16 +278,12 @@ class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional):
if not content.endswith("\n"):
content += "\n"
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+ 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)
- # applying the replacement
- 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 self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ return diagnostics_context.format_result(SUCCESS_RESULT)
class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional):
@@ -329,15 +311,11 @@ class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional):
if not content.endswith("\n"):
content += "\n"
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
+ with self.DiagnosticsContext(self, relative_path) as diagnostics_context:
+ code_editor = self.create_code_editor()
+ code_editor.insert_at_line(relative_path, line, content)
- # applying the insertion
- code_editor = self.create_code_editor()
- code_editor.insert_at_line(relative_path, line, content)
-
- return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ return diagnostics_context.format_result(SUCCESS_RESULT)
class SearchForPatternTool(Tool):
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index 036c1d71..7f9c17f5 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -9,7 +9,6 @@ from collections import Counter, defaultdict
from collections.abc import Sequence
from typing import Any
-from serena.code_editor import EditedFilePath
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper
from serena.tools import (
SUCCESS_RESULT,
@@ -796,19 +795,14 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics):
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.
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
-
- # applying the symbol replacement
- code_editor = self.create_code_editor()
- code_editor.replace_body(
- name_path,
- relative_file_path=relative_path,
- body=body,
- )
-
- return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ 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(EditingToolWithDiagnostics):
@@ -831,15 +825,10 @@ class InsertAfterSymbolTool(EditingToolWithDiagnostics):
:param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol.
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
-
- # applying the insertion
- code_editor = self.create_code_editor()
- code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
-
- return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ 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(EditingToolWithDiagnostics):
@@ -862,15 +851,10 @@ class InsertBeforeSymbolTool(EditingToolWithDiagnostics):
: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
"""
- # capturing diagnostics before the edit
- edited_file_paths = [EditedFilePath(relative_path, relative_path)]
- diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths)
-
- # applying the insertion
- code_editor = self.create_code_editor()
- code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
-
- return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot)
+ 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):
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index ca8e5a49..11d92dce 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -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,13 +18,12 @@ 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 solidlsp import ls_types
+from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot
from solidlsp.ls_exceptions import SolidLSPException
-from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
if TYPE_CHECKING:
from serena.agent import SerenaAgent
- from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor
+ from serena.code_editor import CodeEditor, LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
log = logging.getLogger(__name__)
@@ -32,24 +31,6 @@ T = TypeVar("T")
SUCCESS_RESULT = "OK"
-@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
-
-
-@dataclass
-class PublishedDiagnosticsSnapshot:
- generation_by_after_path: dict[str, int]
- warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]]
-
-
class Component(ABC):
def __init__(self, agent: "SerenaAgent"):
self.agent = agent
@@ -428,7 +409,7 @@ 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
+ 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
@@ -436,179 +417,47 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit):
are then resolved in subsequent edits.
"""
- @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)
+ DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]"
- @classmethod
- def _diagnostic_identity(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"),
- )
+ 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:
+ symbol_retriever = tool.create_language_server_symbol_retriever()
+ self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever)
- @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}"
+ def __enter__(self) -> Self:
+ return self
- @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
+ def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore
+ pass
- @classmethod
- def _add_grouped_diagnostic(
- cls,
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
- relative_path: str,
- name_path: str,
- diagnostic: ls_types.Diagnostic,
- ) -> None:
- severity_name = cls._diagnostic_severity_name(diagnostic.get("severity"))
- grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
- cls._diagnostic_output_dict(diagnostic)
- )
+ def format_result(
+ self,
+ base_result: str,
+ ) -> str:
+ if not self._is_diagnostics_enabled:
+ return base_result
- def _capture_published_lsp_diagnostics_snapshot(
- self,
- edited_file_paths: Iterable["EditedFilePath"],
- ) -> PublishedDiagnosticsSnapshot | None:
- if not self._ENABLE_DIAGNOSTICS:
- return None
- if self.agent.get_language_backend() != LanguageBackend.LSP:
- return None
+ if self._before_edit_diagnostics_snapshot is None:
+ return base_result
- # collecting diagnostics state before the edit
- symbol_retriever = self.create_language_server_symbol_retriever()
- generation_by_after_path: dict[str, int] = {}
- warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {}
+ 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()
- for edited_file_path in edited_file_paths:
- try:
- language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path)
- except Exception:
- return None
-
- 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] = {
- self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or []
- }
-
- return PublishedDiagnosticsSnapshot(
- generation_by_after_path=generation_by_after_path,
- warning_identities_by_before_path=warning_identities_by_before_path,
- )
-
- def _format_lsp_edit_result_with_new_diagnostics(
- self,
- default_result: str,
- edited_file_paths: Iterable["EditedFilePath"],
- before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None,
- ) -> str:
- if not self._ENABLE_DIAGNOSTICS:
- return default_result
-
- # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics
- # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code
- if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP:
- return default_result
-
- # collecting diagnostics state after the edit
- symbol_retriever = self.create_language_server_symbol_retriever()
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
- saw_diagnostics_result = False
-
- for edited_file_path in edited_file_paths:
- try:
- language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path)
- except Exception:
- return default_result
-
- published_diagnostics = language_server.request_published_text_document_diagnostics(
- relative_file_path=edited_file_path.after_relative_path,
- after_generation=before_edit_diagnostics_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 Exception:
- published_diagnostics = None
- if published_diagnostics is None:
- continue
-
- saw_diagnostics_result = True
- existing_warning_identities = before_edit_diagnostics_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 = self._diagnostic_identity(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 ""
- self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic)
-
- # preserving the normal success result when no new diagnostics were introduced
- if not saw_diagnostics_result or not grouped_result:
- return default_result
-
- return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}"
+ 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:
diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py
new file mode 100644
index 00000000..6df5ce34
--- /dev/null
+++ b/src/serena/util/ls_diagnostics.py
@@ -0,0 +1,202 @@
+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 DiagnosticsDiff:
+ def __init__(
+ self,
+ before_snapshot: PublishedDiagnosticsSnapshot,
+ edited_files: Iterable[EditedFilePath],
+ symbol_retriever: "LanguageServerSymbolRetriever",
+ ):
+ grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+
+ 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 ""
+ self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic)
+
+ self._grouped_result = grouped_result
+
+ def get_grouped_diagnostics(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 "" 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_result
+
+ @classmethod
+ def _add_grouped_diagnostic(
+ cls,
+ grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
+ relative_path: str,
+ name_path: str,
+ diagnostic: ls_types.Diagnostic,
+ ) -> None:
+ severity_name = cls._diagnostic_severity_name(diagnostic.get("severity"))
+ grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
+ cls._diagnostic_output_dict(diagnostic)
+ )
+
+ @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
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index 974cdc4b..b3e2427b 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -18,6 +18,7 @@ from serena.project import Project
from serena.tools import (
SUCCESS_RESULT,
ActivateProjectTool,
+ EditingToolWithDiagnostics,
FindDefiningSymbolAtLocationTool,
FindDefiningSymbolTool,
FindImplementationsTool,
@@ -812,9 +813,9 @@ def read_project_file(project: Project, relative_path: str) -> str:
def parse_edit_diagnostics_result(result: str) -> dict:
"""Utility function to parse the diagnostic payload returned by edit tools."""
- prefix = "Edit introduced new warning-or-higher diagnostics: "
- assert result.startswith(prefix), result
- return json.loads(result[len(prefix) :])
+ assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result
+ d = json.loads(result)
+ return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY]
@contextmanager
@@ -1201,21 +1202,24 @@ class TestSerenaAgent:
"""Tests that file-level edits report newly introduced diagnostics."""
relative_path = os.path.join("test_repo", "services.py")
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
- replace_content_tool._ENABLE_DIAGNOSTICS = True
+ try:
+ replace_content_tool.ENABLE_DIAGNOSTICS = True
- with project_file_modification_context(serena_agent, relative_path):
- result = replace_content_tool.apply(
- relative_path=relative_path,
- needle="return container",
- repl="return missing_container",
- mode="literal",
- )
+ with project_file_modification_context(serena_agent, relative_path):
+ result = replace_content_tool.apply(
+ relative_path=relative_path,
+ needle="return container",
+ repl="return missing_container",
+ mode="literal",
+ )
- diagnostics = parse_edit_diagnostics_result(result)
- relative_path_result = diagnostics[relative_path]
- diagnostic_messages = json.dumps(relative_path_result)
- assert "missing_container" in diagnostic_messages
- assert "create_service_container" in diagnostic_messages
+ diagnostics = parse_edit_diagnostics_result(result)
+ relative_path_result = diagnostics[relative_path]
+ diagnostic_messages = json.dumps(relative_path_result)
+ assert "missing_container" in diagnostic_messages
+ assert "create_service_container" in diagnostic_messages
+ finally:
+ replace_content_tool.ENABLE_DIAGNOSTICS = False
@pytest.mark.parametrize(
"serena_agent",
@@ -1229,23 +1233,26 @@ class TestSerenaAgent:
"""Tests that symbol-level edits report newly introduced diagnostics."""
relative_path = os.path.join("test_repo", "services.py")
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
- replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True
+ try:
+ replace_symbol_body_tool.ENABLE_DIAGNOSTICS = True
- with project_file_modification_context(serena_agent, relative_path):
- result = replace_symbol_body_tool.apply(
- name_path="create_service_container",
- relative_path=relative_path,
- body="""
-def create_service_container() -> dict[str, Any]:
- return missing_container
-""",
- )
+ with project_file_modification_context(serena_agent, relative_path):
+ result = replace_symbol_body_tool.apply(
+ name_path="create_service_container",
+ relative_path=relative_path,
+ body="""
+ def create_service_container() -> dict[str, Any]:
+ return missing_container
+ """,
+ )
- diagnostics = parse_edit_diagnostics_result(result)
- relative_path_result = diagnostics[relative_path]
- diagnostic_messages = json.dumps(relative_path_result)
- assert "missing_container" in diagnostic_messages
- assert "create_service_container" in diagnostic_messages
+ diagnostics = parse_edit_diagnostics_result(result)
+ relative_path_result = diagnostics[relative_path]
+ diagnostic_messages = json.dumps(relative_path_result)
+ assert "missing_container" in diagnostic_messages
+ assert "create_service_container" in diagnostic_messages
+ finally:
+ replace_symbol_body_tool.ENABLE_DIAGNOSTICS = False
@pytest.mark.parametrize(
"serena_agent",
From 5ab912ca0bd66a1edf7a5479fd7e6e584643981a Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Wed, 29 Apr 2026 23:29:14 +0200
Subject: [PATCH 24/32] CodeEditor: Remove unused management of edited files
Management was unnecessarily introduced by AI in 133e1be2a780ee875a9b7d739e076b85d81b29fd
---
src/serena/code_editor.py | 47 ----------------------------------
src/serena/tools/tools_base.py | 4 +--
2 files changed, 2 insertions(+), 49 deletions(-)
diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py
index ec834273..2392d2ba 100644
--- a/src/serena/code_editor.py
+++ b/src/serena/code_editor.py
@@ -13,7 +13,6 @@ from solidlsp.ls import LSPFileBuffer
from solidlsp.ls_utils import PathUtils, TextUtils
from .project import Project
-from .util.ls_diagnostics import EditedFilePath
log = logging.getLogger(__name__)
TSymbol = TypeVar("TSymbol", bound=Symbol)
@@ -24,7 +23,6 @@ class CodeEditor(Generic[TSymbol], ABC):
self.project_root = project.project_root
self.encoding = project.project_config.encoding
self.newline = project.line_ending.newline_str
- self._last_edited_file_paths: list[EditedFilePath] = []
class EditedFile(ABC):
def __init__(self, relative_path: str) -> None:
@@ -85,20 +83,6 @@ class CodeEditor(Generic[TSymbol], ABC):
with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f:
f.write(new_contents)
- def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None:
- unique_paths: list[EditedFilePath] = []
- seen_keys: set[tuple[str, str]] = set()
- for edited_file_path in edited_file_paths:
- key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path)
- if key in seen_keys:
- continue
- seen_keys.add(key)
- unique_paths.append(edited_file_path)
- self._last_edited_file_paths = unique_paths
-
- def get_last_edited_file_paths(self) -> list[EditedFilePath]:
- return list(self._last_edited_file_paths)
-
@abstractmethod
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol:
"""
@@ -130,8 +114,6 @@ class CodeEditor(Generic[TSymbol], ABC):
edited_file.delete_text_between_positions(start_pos, end_pos)
edited_file.insert_text_at_position(start_pos, body)
- self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
-
@staticmethod
def _count_leading_newlines(text: Iterable) -> int:
cnt = 0
@@ -189,8 +171,6 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line, col), body)
- self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
-
def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None:
"""
Inserts content before the symbol with the given name in the given file.
@@ -219,8 +199,6 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body)
- self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
-
def insert_at_line(self, relative_path: str, line: int, content: str) -> None:
"""
Inserts content at the given line in the given file.
@@ -232,8 +210,6 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_path) as edited_file:
edited_file.insert_text_at_position(PositionInFile(line, 0), content)
- self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)])
-
def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None:
"""
Deletes lines in the given file.
@@ -250,8 +226,6 @@ class CodeEditor(Generic[TSymbol], ABC):
end_pos = PositionInFile(line=end_line_for_delete, col=end_col)
edited_file.delete_text_between_positions(start_pos, end_pos)
- self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)])
-
def delete_symbol(self, name_path: str, relative_file_path: str) -> None:
"""
Deletes the symbol with the given name in the given file.
@@ -262,8 +236,6 @@ class CodeEditor(Generic[TSymbol], ABC):
with self.edited_file_context(relative_file_path) as edited_file:
edited_file.delete_text_between_positions(start_pos, end_pos)
- self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)])
-
@abstractmethod
def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str:
pass
@@ -320,10 +292,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
def apply(self) -> None:
pass
- @abstractmethod
- def get_edited_file_paths(self) -> list[EditedFilePath]:
- pass
-
class EditOperationFileTextEdits(EditOperation):
def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]):
self._code_editor = code_editor
@@ -335,9 +303,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file)
edited_file.apply_text_edits(self._text_edits)
- def get_edited_file_paths(self) -> list[EditedFilePath]:
- return [EditedFilePath(self._relative_path, self._relative_path)]
-
class EditOperationRenameFile(EditOperation):
def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str):
self._code_editor = code_editor
@@ -349,9 +314,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path)
os.rename(old_abs_path, new_abs_path)
- def get_edited_file_paths(self) -> list[EditedFilePath]:
- return [EditedFilePath(self._old_relative_path, self._new_relative_path)]
-
def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]:
operations: list[LanguageServerCodeEditor.EditOperation] = []
@@ -381,15 +343,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
:return: number of edit operations applied
"""
operations = self._workspace_edit_to_edit_operations(workspace_edit)
-
- # recording the affected files
- edited_file_paths: list[EditedFilePath] = []
- for operation in operations:
- edited_file_paths.extend(operation.get_edited_file_paths())
-
- self._set_last_edited_file_paths(edited_file_paths)
-
- # applying the edit operations
for operation in operations:
operation.apply()
return len(operations)
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index 11d92dce..4348deb9 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -427,8 +427,8 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit):
self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None
self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None
if self._is_diagnostics_enabled:
- symbol_retriever = tool.create_language_server_symbol_retriever()
- self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever)
+ 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
From 371d70f87ee0280d58ff7693ee4ad20c9b644293 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Thu, 30 Apr 2026 11:43:23 +0200
Subject: [PATCH 25/32] Refactoring: Introduce abstraction GroupedDiagnostics,
removing duplication
---
src/serena/tools/symbol_tools.py | 68 +++--------------
src/serena/tools/tools_base.py | 2 +-
src/serena/util/ls_diagnostics.py | 121 +++++++++++++++---------------
3 files changed, 71 insertions(+), 120 deletions(-)
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index 7f9c17f5..d2626ae0 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -18,44 +18,8 @@ from serena.tools import (
ToolMarkerSymbolicRead,
)
from serena.tools.tools_base import ToolMarkerOptional
-from solidlsp import ls_types
+from serena.util.ls_diagnostics import GroupedDiagnostics
from solidlsp.ls_types import SymbolKind
-from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
-
-FILE_LEVEL_DIAGNOSTIC_BUCKET = ""
-
-
-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}"
-
-
-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
-
-
-def _add_grouped_diagnostic(
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
- relative_path: str,
- severity_name: str,
- name_path: str,
- diagnostic: ls_types.Diagnostic,
-) -> None:
- grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
- _diagnostic_output_dict(diagnostic)
- )
def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]:
@@ -663,7 +627,7 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol.
"""
- _ENABLE_DIAGNOSTICS: bool = True
+ FILE_LEVEL_DIAGNOSTIC_BUCKET = ""
def apply(
self,
@@ -693,10 +657,10 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
min_severity=min_severity,
)
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ grouped_diagnostics = GroupedDiagnostics()
for diagnostic in diagnostics:
diag_range = diagnostic["range"]["start"]
- name_path = FILE_LEVEL_DIAGNOSTIC_BUCKET
+ name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET
owner_symbol = symbol_retriever.find_diagnostic_owner_symbol(
relative_file_path=relative_path,
line=diag_range["line"],
@@ -704,15 +668,9 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
)
if owner_symbol is not None:
name_path = owner_symbol.get_name_path()
- _add_grouped_diagnostic(
- grouped_result,
- relative_path=relative_path,
- severity_name=_diagnostic_severity_name(diagnostic.get("severity")),
- name_path=name_path,
- diagnostic=diagnostic,
- )
+ grouped_diagnostics.add(relative_path, name_path, diagnostic)
- result = self._to_json(grouped_result)
+ result = self._to_json(grouped_diagnostics.get_dict())
return self._limit_length(result, max_answer_chars)
@@ -721,8 +679,6 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
Gets diagnostics for a symbol and, optionally, for symbols that reference it.
"""
- _ENABLE_DIAGNOSTICS: bool = True
-
def apply(
self,
name_path: str,
@@ -752,22 +708,16 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
min_severity=min_severity,
)
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ 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:
- _add_grouped_diagnostic(
- grouped_result,
- relative_path=relative_path,
- severity_name=_diagnostic_severity_name(diagnostic.get("severity")),
- name_path=symbol_name_path,
- diagnostic=diagnostic,
- )
+ grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic)
- result = self._to_json(grouped_result)
+ result = self._to_json(grouped_diagnostics.get_dict())
return self._limit_length(result, max_answer_chars)
diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py
index 4348deb9..565a4055 100644
--- a/src/serena/tools/tools_base.py
+++ b/src/serena/tools/tools_base.py
@@ -448,7 +448,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit):
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()
+ grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict()
if not grouped_diagnostics:
return base_result
diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py
index 6df5ce34..13b505ea 100644
--- a/src/serena/util/ls_diagnostics.py
+++ b/src/serena/util/ls_diagnostics.py
@@ -88,6 +88,62 @@ class PublishedDiagnosticsSnapshot:
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 "" 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,
@@ -95,7 +151,7 @@ class DiagnosticsDiff:
edited_files: Iterable[EditedFilePath],
symbol_retriever: "LanguageServerSymbolRetriever",
):
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {}
+ grouped_diagnostics = GroupedDiagnostics()
for edited_file_path in edited_files:
try:
@@ -139,64 +195,9 @@ class DiagnosticsDiff:
column=diagnostic_start["character"],
)
name_path = owner_symbol.get_name_path() if owner_symbol is not None else ""
- self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic)
+ grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic)
- self._grouped_result = grouped_result
+ self._grouped_diagnostics = grouped_diagnostics
- def get_grouped_diagnostics(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 "" 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_result
-
- @classmethod
- def _add_grouped_diagnostic(
- cls,
- grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]],
- relative_path: str,
- name_path: str,
- diagnostic: ls_types.Diagnostic,
- ) -> None:
- severity_name = cls._diagnostic_severity_name(diagnostic.get("severity"))
- grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append(
- cls._diagnostic_output_dict(diagnostic)
- )
-
- @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
+ def get_grouped_diagnostics(self) -> GroupedDiagnostics:
+ return self._grouped_diagnostics
From 2e0167f8180c2725489cbfd407bbfc3d3b164447 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Thu, 30 Apr 2026 15:08:56 +0200
Subject: [PATCH 26/32] Refactor LSP tools for symbol declaration retrieval
* Apply common functionality already used in JetBrainsFindDeclarationTool
* Remove location-based tool (counteracts Serena's principle to eschew line numbers)
* Apply renaming to use "find declaration" instead of "find defining symbol"
---
scripts/demo_find_defining_symbol.py | 22 +--
src/serena/code_editor.py | 10 +-
src/serena/symbol.py | 10 +-
src/serena/tools/symbol_tools.py | 272 ++++++---------------------
src/solidlsp/ls_utils.py | 8 +
test/serena/test_serena_agent.py | 43 +----
6 files changed, 91 insertions(+), 274 deletions(-)
diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py
index f65b820c..63bde56c 100644
--- a/scripts/demo_find_defining_symbol.py
+++ b/scripts/demo_find_defining_symbol.py
@@ -11,7 +11,7 @@ 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 FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool
+from serena.tools import FindDeclarationTool
from solidlsp.ls_config import Language
SEPARATOR = "=" * 80
@@ -73,24 +73,8 @@ if __name__ == "__main__":
relative_path = SERVICES_FILE.as_posix()
services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE
- # resolving via exact location
- line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1)
- find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool)
- location_result = agent.execute_task(
- lambda: find_by_location_tool.apply(
- relative_path=relative_path,
- line=line,
- column=column,
- include_info=True,
- )
- )
-
- print_section("FindDefiningSymbolAtLocationTool")
- location_symbol = json.loads(location_result)
- pprint(location_symbol, width=200)
-
# resolving via regex over the full file
- find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool)
+ 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)",
@@ -118,7 +102,7 @@ if __name__ == "__main__":
pprint(contained_regex_symbol, width=200)
# validating the demonstrated result
- for symbol in [location_symbol, regex_symbol, contained_regex_symbol]:
+ 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
diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py
index 2392d2ba..fed0297f 100644
--- a/src/serena/code_editor.py
+++ b/src/serena/code_editor.py
@@ -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"]:
diff --git a/src/serena/symbol.py b/src/serena/symbol.py
index b44339e1..8bae7d14 100644
--- a/src/serena/symbol.py
+++ b/src/serena/symbol.py
@@ -310,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:
"""
@@ -936,7 +942,7 @@ class LanguageServerSymbolRetriever:
return [LanguageServerSymbol(s) for s in implementing_symbols]
- def find_defining_symbol(
+ def find_declaration(
self,
relative_file_path: str,
line: int,
@@ -944,7 +950,7 @@ class LanguageServerSymbolRetriever:
include_body: bool = False,
) -> LanguageServerSymbol | None:
"""
- Find the symbol that defines the symbol at the given file position.
+ 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.
diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py
index d2626ae0..e146c5ea 100644
--- a/src/serena/tools/symbol_tools.py
+++ b/src/serena/tools/symbol_tools.py
@@ -4,7 +4,6 @@ Language server-related tools
import copy
import os
-import re
from collections import Counter, defaultdict
from collections.abc import Sequence
from typing import Any
@@ -19,37 +18,10 @@ from serena.tools import (
)
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
-def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]:
- if offset < 0 or offset > len(text):
- raise ValueError(f"Offset out of range: {offset}")
- prefix = text[:offset]
- line = prefix.count("\n")
- previous_newline_offset = prefix.rfind("\n")
- column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1
- return line, column
-
-
-def _line_and_column_to_offset(text: str, line: int, column: int) -> int:
- if line < 0 or column < 0:
- raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}")
-
- line_start_offsets = [0]
- for match in re.finditer("\n", text):
- line_start_offsets.append(match.end())
-
- if line >= len(line_start_offsets):
- raise ValueError(f"Line out of range: {line}")
-
- line_start_offset = line_start_offsets[line]
- line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text)
- if column > line_end_offset - line_start_offset:
- raise ValueError(f"Column out of range for line {line}: {column}")
- return line_start_offset + column
-
-
class RestartLanguageServerTool(Tool, ToolMarkerOptional):
"""Restarts the language server, may be necessary when edits not through Serena happen."""
@@ -411,215 +383,85 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead):
return self._limit_length(result, max_answer_chars)
-class FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
+class FindDeclarationTool(Tool, ToolMarkerSymbolicRead):
"""
- Finds the symbol that defines the symbol at the given file position using the language server backend.
+ Finds the declaration/definition of a symbol
"""
- @staticmethod
- def _defining_symbol_to_result_dict(
- symbol_retriever: Any,
- defining_symbol: LanguageServerSymbol | None,
- include_body: bool,
- include_info: bool,
- ) -> dict[str, Any] | None:
- if defining_symbol is None:
- return None
-
- 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
-
- # noinspection PyDefaultArgument
def apply(
self,
relative_path: str,
- line: int,
- column: int,
+ regex: str,
+ containing_symbol_name_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
- max_answer_chars: int = -1,
- ) -> str:
- """
- Finds the defining symbol for the symbol at the given position.
-
- :param relative_path: the relative path to the file containing the symbol usage.
- :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 source code of the defining symbol.
- :param include_info: whether to include additional info (hover-like, typically including docstring and signature)
- about the defining symbol. Ignored if no defining symbol is found.
- :param max_answer_chars: same as in the `find_symbol` tool.
- :return: a JSON object representing the defining symbol, or `null` if no definition was found.
- """
- symbol_retriever = self.create_language_server_symbol_retriever()
- defining_symbol = symbol_retriever.find_defining_symbol(
- relative_file_path=relative_path,
- line=line,
- column=column,
- include_body=include_body,
- )
-
- if defining_symbol is None:
- result = self._to_json(None)
- return self._limit_length(result, max_answer_chars)
-
- symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info)
- assert symbol_dict is not None
- result = self._to_json(symbol_dict)
- return self._limit_length(result, max_answer_chars)
-
-
-class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead):
- """
- Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body.
- """
-
- @staticmethod
- def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str:
- if containing_symbol_name_path:
- return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'"
- return f"file '{relative_path}'"
-
- @classmethod
- def _format_match_preview(cls, match: re.Match[str]) -> str:
- matched_text = match.group(0).replace("\n", "\\n")
- return matched_text[:120]
-
- @classmethod
- def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str:
- if match.re.groups == 0:
- return (
- f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in "
- f"{search_scope_description}."
- )
-
- matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)]
- if len(matched_capture_spans) != 1:
- return (
- f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, "
- f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'."
- )
-
- capture_start_offset, capture_end_offset = matched_capture_spans[0]
- if capture_start_offset == capture_end_offset:
- return (
- f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; "
- "the capture must select the referenced symbol text."
- )
- return capture_start_offset, capture_end_offset
-
- def _find_unique_captured_location(
- self,
- relative_path: str,
- regex: str,
- containing_symbol_name_path: str | None,
- ) -> tuple[int, int] | str:
- # retrieving the search region
- file_content = self.project.read_file(relative_path)
- symbol_retriever = self.create_language_server_symbol_retriever()
- search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path)
- search_start_offset = 0
- search_text = file_content
-
- if containing_symbol_name_path:
- try:
- containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path)
- except Exception as e:
- return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}"
-
- body_start_position = containing_symbol.get_body_start_position_or_raise()
- body_end_position = containing_symbol.get_body_end_position_or_raise()
- search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col)
- search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col)
- search_text = file_content[search_start_offset:search_end_offset]
-
- # finding regex matches
- try:
- compiled_regex = re.compile(regex, re.MULTILINE)
- except re.error as e:
- return f"Error: Invalid regex '{regex}': {e}"
-
- if compiled_regex.groups == 0:
- return (
- f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in "
- f"{search_scope_description}."
- )
-
- matches = list(compiled_regex.finditer(search_text))
- if len(matches) != 1:
- match_previews = [self._format_match_preview(match) for match in matches[:3]]
- preview_suffix = f" Matches: {match_previews}" if match_previews else ""
- return (
- f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, "
- f"but found {len(matches)}.{preview_suffix}"
- )
-
- capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description)
- if isinstance(capture_span_or_error, str):
- return capture_span_or_error
-
- capture_start_offset, _ = capture_span_or_error
- absolute_capture_start_offset = search_start_offset + capture_start_offset
- return _offset_to_line_and_column(file_content, absolute_capture_start_offset)
-
- # noinspection PyDefaultArgument
- def apply(
- self,
- regex: str,
- relative_path: str,
- containing_symbol_name_path: str = "",
- include_body: bool = False,
- include_info: bool = False,
- max_answer_chars: int = -1,
) -> str:
r"""
- Finds the defining symbol for a uniquely captured regex match in a file.
+ Finds the declaration of a symbol.
- The regex must contain exactly one capturing group, and exactly one overall match must be found.
- The capture identifies the symbol usage whose definition should be resolved.
-
- The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common
- single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)``
- or ``[\\s\\S]*?``.
-
- :param regex: a Python regular expression containing exactly one capturing group.
- :param relative_path: the relative path to the file containing the symbol usage.
+ :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 source code of the defining symbol.
- :param include_info: whether to include additional info (hover-like, typically including docstring and signature)
- about the defining symbol. Ignored if no defining symbol is found.
- :param max_answer_chars: same as in the `find_symbol` tool.
- :return: a JSON object representing the defining symbol, or ``null`` if no definition was found.
- If the regex does not identify a unique captured match, an informative error string is returned.
+ :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.
"""
- captured_location_or_error = self._find_unique_captured_location(
- relative_path=relative_path,
- regex=regex,
- containing_symbol_name_path=containing_symbol_name_path or None,
- )
- if isinstance(captured_location_or_error, str):
- return captured_location_or_error
-
- line, column = captured_location_or_error
symbol_retriever = self.create_language_server_symbol_retriever()
- defining_symbol = symbol_retriever.find_defining_symbol(
+ 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=line,
- column=column,
+ line=coords.line,
+ column=coords.col,
include_body=include_body,
)
- symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict(
+ 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 self._limit_length(result, max_answer_chars)
+ 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):
diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py
index b54654b8..73066e12 100644
--- a/src/solidlsp/ls_utils.py
+++ b/src/solidlsp/ls_utils.py
@@ -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:
"""
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index b3e2427b..4a30550a 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -19,8 +19,7 @@ from serena.tools import (
SUCCESS_RESULT,
ActivateProjectTool,
EditingToolWithDiagnostics,
- FindDefiningSymbolAtLocationTool,
- FindDefiningSymbolTool,
+ FindDeclarationTool,
FindImplementationsTool,
FindReferencingSymbolsTool,
FindSymbolTool,
@@ -958,38 +957,10 @@ class TestSerenaAgent:
f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}"
)
- @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"])
- def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None:
- project_root = get_repo_path(case.language)
- position = find_identifier_pos(
- project_root / case.relative_path,
- case.identifier,
- case.occurrence_index,
- case.column_offset,
- )
- assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}"
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool)
- result = find_defining_symbol_tool.apply(
- relative_path=case.relative_path,
- line=position[0],
- column=position[1],
- include_info=True,
- )
- defining_symbol = json.loads(result)
- assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}"
- assert defining_symbol.get("relative_path") is not None
- assert case.expected_definition_file in defining_symbol["relative_path"], (
- f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}"
- )
- assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), (
- f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}"
- )
- self._assert_symbol_info_present(serena_agent, defining_symbol)
-
@pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"])
- def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None:
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
- result = find_defining_symbol_tool.apply(
+ def test_find_declaration(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None:
+ tool = serena_agent.get_tool(FindDeclarationTool)
+ result = tool.apply(
regex=case.regex,
relative_path=case.relative_path,
containing_symbol_name_path=case.containing_symbol_name_path,
@@ -1007,13 +978,13 @@ class TestSerenaAgent:
self._assert_symbol_info_present(serena_agent, defining_symbol)
@pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"])
- def test_find_defining_symbol_by_regex_error(
+ def test_find_declaration_error(
self,
serena_agent: SerenaAgent,
case: RegexDefiningSymbolErrorCase,
) -> None:
- find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool)
- result = find_defining_symbol_tool.apply(
+ tool = serena_agent.get_tool(FindDeclarationTool)
+ result = tool.apply(
regex=case.regex,
relative_path=case.relative_path,
containing_symbol_name_path=case.containing_symbol_name_path,
From 4f0c3f83e3f6262a9b9828d6171e5e69b0bc3edd Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Thu, 30 Apr 2026 15:22:37 +0200
Subject: [PATCH 27/32] Update change log and README to consider new
diagnostics/declaration/implementations tools
---
CHANGELOG.md | 13 ++++++++++++-
README.md | 5 +++--
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96330eaa..e65d8ceb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,21 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
+* 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:
diff --git a/README.md b/README.md
index 0c819fb4..a89d9267 100644
--- a/README.md
+++ b/README.md
@@ -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
From 30ee9235faf1fd4ad333a3005e09b22f030db3b5 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Thu, 30 Apr 2026 21:10:53 +0200
Subject: [PATCH 28/32] Update TestSerenaAgent.test_find_declaration_error
---
test/serena/test_serena_agent.py | 21 ++++++++++-----------
1 file changed, 10 insertions(+), 11 deletions(-)
diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py
index 4a30550a..3e1829f3 100644
--- a/test/serena/test_serena_agent.py
+++ b/test/serena/test_serena_agent.py
@@ -606,15 +606,15 @@ FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [
relative_path=os.path.join("test_repo", "services.py"),
regex=r"(User)",
containing_symbol_name_path="",
- error_fragment="Expected exactly one regex match",
+ error_fragment="Match must be unique",
).to_pytest_param(),
RegexDefiningSymbolErrorCase(
language=Language.PYTHON,
id="python_regex_missing_group",
relative_path=os.path.join("test_repo", "services.py"),
- regex=r"User",
- containing_symbol_name_path="UserService/create_user",
- error_fragment="must contain exactly one capturing group",
+ regex=r"self.users.get\(id\)",
+ containing_symbol_name_path="UserService/get_user",
+ error_fragment="Regex must contain exactly one group",
).to_pytest_param(),
]
@@ -984,13 +984,12 @@ class TestSerenaAgent:
case: RegexDefiningSymbolErrorCase,
) -> None:
tool = serena_agent.get_tool(FindDeclarationTool)
- result = tool.apply(
- regex=case.regex,
- relative_path=case.relative_path,
- containing_symbol_name_path=case.containing_symbol_name_path,
- )
- assert result.startswith("Error: "), result
- assert case.error_fragment in result, result
+ with pytest.raises(ValueError, match=case.error_fragment):
+ tool.apply(
+ regex=case.regex,
+ relative_path=case.relative_path,
+ containing_symbol_name_path=case.containing_symbol_name_path,
+ )
@pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"])
def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None:
From 7aeb7bc48224a4d6b46e0fa078b86a950fe35713 Mon Sep 17 00:00:00 2001
From: kurushimee
Date: Fri, 1 May 2026 20:51:51 +0700
Subject: [PATCH 29/32] fix(lsp): omit optional Content-Type header from
outgoing messages
Per the LSP spec, Content-Type is optional and defaults to
application/vscode-jsonrpc; charset=utf-8 (the exact value we were
sending). Spec-compliant servers behave identically with or without it.
In practice some servers have stricter parsers. Notably, Godot's
GDScript LSP (LSPeer::handle_data in
modules/gdscript/language_server/gdscript_language_protocol.cpp)
extracts the body length via header.substr(16).to_int() and silently
drops messages that contain any header beyond Content-Length.
Sending only Content-Length is interoperable across the spec-compliant
servers we use today and unblocks Godot's LSP. Also tightens the type
signature of create_message from tuple[bytes, bytes, bytes] to
tuple[bytes, bytes] now that there is no Content-Type element.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/solidlsp/lsp_protocol_handler/server.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py
index 36e24b15..da94e1eb 100644
--- a/src/solidlsp/lsp_protocol_handler/server.py
+++ b/src/solidlsp/lsp_protocol_handler/server.py
@@ -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,
)
From 90c4e78e545efccc2ea9f1bfcae4fc51205095f8 Mon Sep 17 00:00:00 2001
From: Michael Panchenko
Date: Wed, 29 Apr 2026 20:18:20 +0200
Subject: [PATCH 30/32] LS: fix unversioned runtime dependency paths, keeping
backwards compatibility
---
.../language_servers/al_language_server.py | 51 ++++----
.../ansible_language_server.py | 19 ++-
.../language_servers/bash_language_server.py | 24 +++-
src/solidlsp/language_servers/clojure_lsp.py | 56 +++++++--
.../language_servers/dart_language_server.py | 47 ++++++--
.../language_servers/eclipse_jdtls.py | 111 ++++++++++++++----
.../language_servers/elm_language_server.py | 20 +++-
.../fsharp_language_server.py | 13 +-
.../groovy_language_server.py | 39 ++++--
.../language_servers/haxe_language_server.py | 32 +++--
.../language_servers/hlsl_language_server.py | 39 ++++--
src/solidlsp/language_servers/intelephense.py | 13 +-
.../language_servers/json_language_server.py | 16 ++-
.../kotlin_language_server.py | 41 +++++--
src/solidlsp/language_servers/lua_ls.py | 41 ++++++-
src/solidlsp/language_servers/luau_lsp.py | 36 +++++-
src/solidlsp/language_servers/marksman.py | 50 ++++++--
.../matlab_language_server.py | 47 +++++---
src/solidlsp/language_servers/omnisharp.py | 18 ++-
.../language_servers/pascal_server.py | 46 ++++++--
src/solidlsp/language_servers/phpactor.py | 32 +++--
.../powershell_language_server.py | 38 ++++--
.../solidity_language_server.py | 30 ++++-
src/solidlsp/language_servers/taplo_server.py | 48 +++++---
src/solidlsp/language_servers/terraform_ls.py | 50 ++++++--
.../typescript_language_server.py | 49 ++++----
.../language_servers/vts_language_server.py | 12 +-
.../language_servers/yaml_language_server.py | 17 ++-
28 files changed, 789 insertions(+), 246 deletions(-)
diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py
index ed3cf9c7..438b1d3d 100644
--- a/src/solidlsp/language_servers/al_language_server.py
+++ b/src/solidlsp/language_servers/al_language_server.py
@@ -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")
diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py
index 2b3da993..3fb1a3b6 100644
--- a/src/solidlsp/language_servers/ansible_language_server.py
+++ b/src/solidlsp/language_servers/ansible_language_server.py
@@ -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
diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py
index 9dfc9356..0338a5ef 100644
--- a/src/solidlsp/language_servers/bash_language_server.py
+++ b/src/solidlsp/language_servers/bash_language_server.py
@@ -28,6 +28,14 @@ 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 = (
@@ -110,10 +118,10 @@ class BashLanguageServer(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."
- 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")
- bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
+ 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":
@@ -180,13 +188,23 @@ class BashLanguageServer(SolidLanguageServer):
os.chmod(binary_path, current | 0o111)
def create_launch_command_env(self) -> dict[str, str]:
- bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
+ 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"]
diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py
index 4fdc3ce9..dc238256 100644
--- a/src/solidlsp/language_servers/clojure_lsp.py
+++ b/src/solidlsp/language_servers/clojure_lsp.py
@@ -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)
diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py
index f82627c6..800f69eb 100644
--- a/src/solidlsp/language_servers/dart_language_server.py
+++ b/src/solidlsp/language_servers/dart_language_server.py
@@ -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):
diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py
index 50deb1b0..646174a5 100644
--- a/src/solidlsp/language_servers/eclipse_jdtls.py
+++ b/src/solidlsp/language_servers/eclipse_jdtls.py
@@ -32,19 +32,57 @@ 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_ directory names
JDTLS_CONFIG_DIR_BY_PLATFORM = {
@@ -237,12 +275,35 @@ class EclipseJDTLS(SolidLanguageServer):
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": {
@@ -258,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",
@@ -277,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",
@@ -289,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",
@@ -301,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",
@@ -313,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",
@@ -328,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",
diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py
index a86a1d2d..e5823811 100644
--- a/src/solidlsp/language_servers/elm_language_server.py
+++ b/src/solidlsp/language_servers/elm_language_server.py
@@ -22,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):
"""
@@ -68,8 +76,8 @@ class ElmLanguageServer(SolidLanguageServer):
Setup runtime dependencies for Elm Language Server and return the command to start the server.
"""
elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM)
- elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0")
- elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6")
+ 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
@@ -101,8 +109,12 @@ class ElmLanguageServer(SolidLanguageServer):
]
)
- # 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...")
diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py
index 79970bae..a8cd6847 100644
--- a/src/solidlsp/language_servers/fsharp_language_server.py
+++ b/src/solidlsp/language_servers/fsharp_language_server.py
@@ -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
diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py
index 66bcacc7..2370e128 100644
--- a/src/solidlsp/language_servers/groovy_language_server.py
+++ b/src/solidlsp/language_servers/groovy_language_server.py
@@ -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)
diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py
index cf48c78a..24ca6fe7 100644
--- a/src/solidlsp/language_servers/haxe_language_server.py
+++ b/src/solidlsp/language_servers/haxe_language_server.py
@@ -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")
diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py
index b6eb7e05..cab201dd 100644
--- a/src/solidlsp/language_servers/hlsl_language_server.py
+++ b/src/solidlsp/language_servers/hlsl_language_server.py
@@ -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):
diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py
index 0c77af6d..47212695 100644
--- a/src/solidlsp/language_servers/intelephense.py
+++ b/src/solidlsp/language_servers/intelephense.py
@@ -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):
diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py
index bf46992a..f764418e 100644
--- a/src/solidlsp/language_servers/json_language_server.py
+++ b/src/solidlsp/language_servers/json_language_server.py
@@ -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":
diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py
index bd2d3035..3998dafa 100644
--- a/src/solidlsp/language_servers/kotlin_language_server.py
+++ b/src/solidlsp/language_servers/kotlin_language_server.py
@@ -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,
diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py
index ef5e8674..3176e41f 100644
--- a/src/solidlsp/language_servers/lua_ls.py
+++ b/src/solidlsp/language_servers/lua_ls.py
@@ -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,
)
diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py
index 4150374d..f0cb06bf 100644
--- a/src/solidlsp/language_servers/luau_lsp.py
+++ b/src/solidlsp/language_servers/luau_lsp.py
@@ -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,
)
diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py
index affe2dcb..13c32147 100644
--- a/src/solidlsp/language_servers/marksman.py
+++ b/src/solidlsp/language_servers/marksman.py
@@ -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(
diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py
index d31c2224..5a83a75f 100644
--- a/src/solidlsp/language_servers/matlab_language_server.py
+++ b/src/solidlsp/language_servers/matlab_language_server.py
@@ -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")
diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py
index ddd21698..f4ece965 100644
--- a/src/solidlsp/language_servers/omnisharp.py
+++ b/src/solidlsp/language_servers/omnisharp.py
@@ -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(
diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py
index 1f2b7de7..34c6ff73 100644
--- a/src/solidlsp/language_servers/pascal_server.py
+++ b/src/solidlsp/language_servers/pascal_server.py
@@ -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"),
),
]
)
diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py
index 80805c0d..181cb3ee 100644
--- a/src/solidlsp/language_servers/phpactor.py
+++ b/src/solidlsp/language_servers/phpactor.py
@@ -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,
)
diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py
index e737b09a..70a06d1a 100644
--- a/src/solidlsp/language_servers/powershell_language_server.py
+++ b/src/solidlsp/language_servers/powershell_language_server.py
@@ -34,8 +34,30 @@ 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"
@@ -99,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():
@@ -111,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}...")
@@ -125,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,
)
diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py
index a41deb89..d0c34053 100644
--- a/src/solidlsp/language_servers/solidity_language_server.py
+++ b/src/solidlsp/language_servers/solidity_language_server.py
@@ -24,6 +24,14 @@ 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):
"""
@@ -73,8 +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")
- forge_version = self._custom_settings.get("forge_version", "1.5.1")
+ 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(
@@ -101,7 +111,7 @@ class SolidityLanguageServer(SolidLanguageServer):
]
)
- solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
+ 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")
@@ -146,10 +156,22 @@ class SolidityLanguageServer(SolidLanguageServer):
return f"@foundry-rs/forge-linux-{arch}"
def create_launch_command_env(self) -> dict[str, str]:
- solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
+ 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"]
diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py
index 410edde1..ee9be952 100644
--- a/src/solidlsp/language_servers/taplo_server.py
+++ b/src/solidlsp/language_servers/taplo_server.py
@@ -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
-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 , 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:
diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py
index ec26126d..c654fd95 100644
--- a/src/solidlsp/language_servers/terraform_ls.py
+++ b/src/solidlsp/language_servers/terraform_ls.py
@@ -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}"
diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py
index 6f3a443b..77f919d8 100644
--- a/src/solidlsp/language_servers/typescript_language_server.py
+++ b/src/solidlsp/language_servers/typescript_language_server.py
@@ -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
@@ -154,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(
@@ -181,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):
diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py
index dc6db249..2464338a 100644
--- a/src/solidlsp/language_servers/vts_language_server.py
+++ b/src/solidlsp/language_servers/vts_language_server.py
@@ -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
diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py
index 8a471921..b52c760e 100644
--- a/src/solidlsp/language_servers/yaml_language_server.py
+++ b/src/solidlsp/language_servers/yaml_language_server.py
@@ -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
From 3a0afadf478d4b53f2e943811066fc33a37d6580 Mon Sep 17 00:00:00 2001
From: Dominik Jain
Date: Sat, 2 May 2026 20:53:20 +0200
Subject: [PATCH 31/32] Improve project creation/config docs
---
docs/02-usage/040_workflow.md | 21 ++++++++++++---------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md
index ce8fbb77..219d33c2 100644
--- a/docs/02-usage/040_workflow.md
+++ b/docs/02-usage/040_workflow.md
@@ -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)
@@ -49,8 +52,8 @@ The file allows you to configure ...
* ignore rules
* write access
* 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).
From 16d1a303971d28ea23a7c4c5cfcc3d2be74db4af Mon Sep 17 00:00:00 2001
From: Sam Carson
Date: Mon, 4 May 2026 04:27:09 -0500
Subject: [PATCH 32/32] hooks: extend auto-approve to Claude Code's `auto`
permission mode (#1445)
* hooks: extend auto-approve to Claude Code's auto permission mode
Closes #1386
---------
Co-authored-by: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com>
---
CHANGELOG.md | 4 +++
docs/02-usage/030_clients.md | 6 ++--
src/serena/hooks.py | 39 +++++++++++++++--------
test/serena/test_hooks.py | 61 +++++++++++++++++++++++++++++++++++-
4 files changed, 93 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5d8f79c..abdc5f62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
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.
diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md
index 646873b1..cbfe0d4e 100644
--- a/docs/02-usage/030_clients.md
+++ b/docs/02-usage/030_clients.md
@@ -218,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).
diff --git a/src/serena/hooks.py b/src/serena/hooks.py
index a9322193..b1becc04 100644
--- a/src/serena/hooks.py
+++ b/src/serena/hooks.py
@@ -359,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())
@@ -426,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:
diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py
index 24af5523..bf7ca82c 100644
--- a/test/serena/test_hooks.py
+++ b/test/serena/test_hooks.py
@@ -409,7 +409,7 @@ class TestToolUseCounter:
class TestPreToolUseAutoApproveSerenaHook:
- """Tests for the auto-approve hook that allows Serena tools while the client is in ``acceptEdits`` mode."""
+ """Tests for the auto-approve hook that allows Serena tools while the client is in a permissive permission mode (``acceptEdits`` or ``auto``)."""
@staticmethod
def _approve_input(
@@ -440,6 +440,27 @@ class TestPreToolUseAutoApproveSerenaHook:
assert hook_output["permissionDecision"] == "allow"
assert "acceptedits" in hook_output["permissionDecisionReason"].lower()
+ def test_approves_serena_tool_in_auto_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
+ """When the tool is a Serena tool and the mode is ``auto``, an allow decision is emitted.
+
+ Claude Code's ``auto`` mode is the hands-off-execution mode users adopt for autonomous
+ runs; Serena tool calls should be auto-approved there for the same reason as in
+ ``acceptEdits`` (Serena's destructive tools would otherwise still prompt per call).
+ """
+ stdin_data = self._approve_input(permission_mode="auto")
+ with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
+ PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
+
+ output = capsys.readouterr().out.strip()
+ result = json.loads(output)
+ hook_output = result["hookSpecificOutput"]
+ assert hook_output["hookEventName"] == "PreToolUse"
+ assert hook_output["permissionDecision"] == "allow"
+ # the reason must identify the actual mode that triggered the approval, not just say
+ # "auto-approved" — the substring " auto " (with surrounding spaces) discriminates the
+ # mode name from the unrelated "Auto-approved" prefix.
+ assert " auto " in hook_output["permissionDecisionReason"]
+
def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""The hook also reads the ``permissionMode`` (camelCase) variant of the field."""
stdin_data = self._approve_input(permission_mode_key="permissionMode")
@@ -470,6 +491,27 @@ class TestPreToolUseAutoApproveSerenaHook:
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
+ def test_stays_silent_in_bypass_permissions_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
+ """``bypassPermissions`` already approves everything upstream in Claude Code, so the
+ hook deliberately does not emit an explicit allow there — it stays silent.
+
+ This test pins that boundary: only ``acceptEdits`` and ``auto`` are active modes for the
+ hook; expanding it further requires a deliberate change here.
+ """
+ stdin_data = self._approve_input(permission_mode="bypassPermissions")
+ with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
+ PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
+ assert capsys.readouterr().out == ""
+
+ def test_stays_silent_in_dont_ask_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
+ """``dontAsk`` is the user's deny-by-default posture (auto-deny unless allow-listed);
+ the hook honors that choice and stays silent rather than overriding it.
+ """
+ stdin_data = self._approve_input(permission_mode="dontAsk")
+ with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
+ PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
+ assert capsys.readouterr().out == ""
+
def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring."""
stdin_data = self._approve_input(permission_mode=None)
@@ -542,6 +584,23 @@ class TestHookCli:
output = json.loads(result.output)
assert output["hookSpecificOutput"]["permissionDecision"] == "allow"
+ def test_auto_approve_command_in_auto_mode(self, tmp_path: Path):
+ """The ``auto-approve`` CLI command emits an allow for a Serena tool in ``auto`` mode."""
+ stdin_json = json.dumps(
+ {
+ "session_id": "cli-auto-approve-auto-mode",
+ "tool_name": "mcp__serena__find_symbol",
+ "tool_input": {},
+ "permission_mode": "auto",
+ }
+ )
+ runner = CliRunner()
+ with patch("serena.hooks.serena_home_dir", str(tmp_path)):
+ result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json)
+ assert result.exit_code == 0
+ output = json.loads(result.output)
+ assert output["hookSpecificOutput"]["permissionDecision"] == "allow"
+
def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path):
"""The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``."""
stdin_json = json.dumps(