Merge branch 'main' into features/move_and_safe_delete

# Conflicts:
#	src/serena/util/text_utils.py
This commit is contained in:
Michael Panchenko committed 2026-03-31 11:03:49 +02:00
commit 94b118dcdc
25 files changed
+1616 -959

No files matched your search

+33 -17
View File
@@ -8,10 +8,10 @@ project_name: "serena"
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# powershell python python_jedi r rego
# ruby ruby_solargraph rust scala swift
# terraform toml typescript typescript_vts vue
# yaml zig
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
@@ -20,8 +20,8 @@ project_name: "serena"
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# 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.
@@ -32,17 +32,19 @@ languages:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in all projects
# same syntax as gitignore, so you can use * and **
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# 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 tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# 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`.
@@ -83,11 +85,10 @@ read_only: false
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
# 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.
# 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: |
@@ -106,11 +107,11 @@ initial_prompt: |
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
# 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 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.
@@ -147,6 +148,21 @@ language_backend:
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# line ending convention for file writes: unset (use global setting, "lf", "crlf", or "native" (platform default)
# If not undefined, overrides the global setting in serena_config.yml
# 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 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: {}
+3
View File
@@ -147,6 +147,9 @@ you would run
and then configure your client to connect to `http://localhost:9121/mcp`.
By default, only connections from localhost are allowed; pass the `--host <listen_address>` option to configure
the listen address and allow remote connections if needed (but be aware of the security implications of doing so).
**When to use.** Note that Serena is a stateful MCP server, and only one coding project can be active at a time.
Therefore, starting a single Serena instance and connecting it to multiple clients is only
appropriate if all clients will be working on the same project.
+4
View File
@@ -498,6 +498,10 @@ The following settings are supported for the Java language server:
| `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. |
| `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. |
Note:
- When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal
+60 -41
View File
@@ -1,29 +1,77 @@
# Security Considerations
Security and protection against supply chain attacks are important to us, and we take this topic seriously.
Security is important to us, and we take this topic seriously.
Serena comes in two main variants with different security characteristics:
## Serena's Assumptions
- the **JetBrains-based variant**, which integrates with a running JetBrains IDE, and
- the **language-server-based variant** (the free variant), which can automatically acquire language-server dependencies on demand.
The current security model for Serena assumes:
## JetBrains Variant
- the local machine is trusted,
- the MCP client (i.e. the LLM) is trusted,
- the code repository being worked on is trusted,
- user configuration is trusted,
- package manager configuration (e.g. npm) for downloading additional dependencies (i.e. language servers when using Serena with the LSP backend) is trusted.
The JetBrains variant of Serena is safe by default.
Serena contains tools for executing shell commands and modifying files.
As such tools are, however, an essential part of coding agent workflows, they typically need to be made available.
Therefore, the only way to *fully* protect against unintended consequences is to use a [sandboxed environment](sandboxing) for running Serena.
At runtime, Serena does **not** download additional components and does **not** start extra helper processes beyond the Serena MCP server itself. It talks to the already running JetBrains IDE and relies on the IDE's own indexing and language intelligence.
## General Recommendations for Risk Reduction
This means that, from a supply-chain perspective, the JetBrains variant has a much smaller runtime attack surface than the language-server-based variant.
To reduce the risk of unintended consequences, we recommend that you:
- back up your work regularly (keep the project being worked on under version control),
- restrict the set of allowed tools via the [configuration](050_configuration),
- do not expose [Serena's network services](network-security) to untrusted networks.
## Language-Server Variant
If you do not fully the trust the client/the LLM, we additionally recommend to monitor tool executions carefully
(provided that your MCP client supports this).
The language-server-based variant supports many languages, including languages whose language servers are not typically preinstalled on a machine. For convenience, Serena can therefore download or install certain language-server dependencies on demand.
(sandboxing)=
## Sandboxing
Sandboxing is the most effective way to mitigate risks when using coding agents.
[Running Serena inside a docker container](docker) which only exposes the necessary files and tools to the agent is a good way to achieve this.
While setting up a sandboxed environment may require some initial effort, we highly recommend it for all security-conscious users.
(network-security)=
## Network Security
Serena includes several network services:
- the Serena MCP server itself (when run in [HTTP or SSE mode](streamable-http) instead of stdio mode)
- the Serena Dashboard web server
- the Serena JetBrains Plugin server, which runs within the JetBrains IDE (when using the JetBrains language backend)
- the Serena Project Server (only started explicitly for [project querying](query-projects))
By default, these services accept connections from localhost only, which is a secure default for most users
(given our assumption that the local machine is trusted; see above).
These services can be reconfigured to listen on other addresses, but doing so may have security implications.
If you need to allow connections from other machines, we recommend that you set up a secure networking environment
(e.g. using a VPN or SSH tunnels) and ensure that only trusted machines can connect to these services.
## Supply Chain Security
Serena has two language backends with different security characteristics:
- the JetBrains-based variant, which integrates with a running JetBrains IDE, and
- the language-server-based variant (the free variant), which can automatically acquire language server dependencies on demand.
While we can assume that JetBrains IDEs installed by the user do not pose a security risk,
language server dependencies (if not handled with care) could.
For convenience, Serena downloads or installs certain language server dependencies on demand.
We treat this path as security-sensitive and have hardened it accordingly.
### How Serena Secures Downloaded Language-Server Dependencies
The most important supply chain protections are:
For language servers that download archives, binaries, VSIX packages, NuGet packages, or other release artifacts, Serena uses a hardened shared download path with the following protections:
- exact version pinning,
- hash verification,
- host restriction,
- and isolated Serena-managed installation directories.
### Auto-Downloaded Language Server Dependencies
For language servers that are auto-installed by downloading archives, binaries, VSIX packages, NuGet packages, or other release artifacts, Serena uses a hardened shared download path with the following protections:
- **Pinned versions by default**: default downloads use exact versions instead of floating `latest` or nightly channels.
- **Integrity verification**: downloaded artifacts are checked against pinned SHA256 hashes stored in Serena's source code.
@@ -63,35 +111,6 @@ One important detail is that `uvx` ignores the lockfile when installing directly
For the `ty` Python language server, Serena also uses an exact pinned version when invoking it through `uvx`.
### Our Assumptions
The current security model for Serena's language-server variant assumes:
- the local machine is trusted,
- the checked-out repository is trusted,
- user configuration is trusted,
- package-manager configuration such as npm config is trusted unless explicitly overridden,
- and the main risk to defend against is compromised or unexpectedly changing upstream artifacts.
Under these assumptions, the most important supply-chain protections are:
- exact version pinning,
- hash verification,
- host restriction,
- and isolated Serena-managed installation directories.
## Operational Recommendations
As fundamental abilities for a coding agent, Serena contains tools for executing shell commands and modifying files. Therefore, if the respective tool calls are not monitored or restricted, and execution takes place in a sensitive environment, there is a risk of unintended consequences.
To reduce that risk, we recommend that you:
- back up your work regularly, for example by using Git,
- monitor tool executions carefully, if your MCP client supports this,
- consider enabling read-only mode for analysis-only sessions by setting `read_only: True` in `project.yml`,
- restrict the set of allowed tools via the [configuration](050_configuration),
- and use a sandboxed environment for running Serena, for example by [using Docker](docker).
```{dropdown} What Serena Downloads by Default for Language Servers
:open:
+3 -2
View File
@@ -20,7 +20,7 @@ dependencies = [
"overrides==7.7.0",
"python-dotenv==1.2.1",
"mcp==1.26.0",
"flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert)
"flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert)
"sensai-utils==1.5.0",
"pydantic==2.12.5",
"types-pyyaml==6.0.12.20250516",
@@ -43,7 +43,8 @@ dependencies = [
"starlette==1.0.0",
"python-multipart==0.0.22",
"filelock==3.25.2",
"cryptography==46.0.5",
"cryptography==46.0.6",
"regex==2026.2.28",
]
[[tool.uv.index]]
+6 -2
View File
@@ -5,6 +5,7 @@ import os
import shutil
import subprocess
import sys
import time
from collections.abc import Iterator, Sequence
from logging import Logger
from pathlib import Path
@@ -187,7 +188,7 @@ class TopLevelCommands(AutoRegisteringGroup):
@click.option(
"--host",
type=str,
default="0.0.0.0",
default="127.0.0.1",
show_default=True,
help="Listen address for the MCP server (when using corresponding transport).",
)
@@ -705,6 +706,7 @@ class ProjectCommands(AutoRegisteringGroup):
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts: dict[Language, int] = collections.defaultdict(lambda: 0)
last_save_time = time.monotonic()
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls = ls_mgr.get_language_server(f)
@@ -714,8 +716,10 @@ class ProjectCommands(AutoRegisteringGroup):
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
now = time.monotonic()
if now - last_save_time >= 30:
ls_mgr.save_all_caches()
last_save_time = now
reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()}
click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}")
ls_mgr.save_all_caches()
+2 -2
View File
@@ -565,7 +565,7 @@ class ProjectConfig(SharedConfig):
# transfer missing comments from the template file
template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION)
transfer_missing_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION)
transfer_missing_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION, force_update_all=True)
# save project.yml
save_yaml(config_path, config_with_comments)
@@ -1047,7 +1047,7 @@ class SerenaConfig(SharedConfig):
# For some keys, we force updates, because old comments are problematic/misleading.
normalise_yaml_comments(commented_yaml, YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING)
template_yaml = load_yaml(SERENA_CONFIG_TEMPLATE_FILE, comment_normalisation=YamlCommentNormalisation.LEADING)
transfer_missing_yaml_comments(template_yaml, commented_yaml, YamlCommentNormalisation.LEADING, forced_update_keys=["projects"])
transfer_missing_yaml_comments(template_yaml, commented_yaml, YamlCommentNormalisation.LEADING, force_update_all=True)
save_yaml(self.config_file_path, commented_yaml)
+3 -1
View File
@@ -368,6 +368,8 @@ class SerenaDashboardAPI:
return post_installation_news_ids
with open(news_snippet_id_file, encoding="utf-8") as f:
last_read_news_id = int(f.read().strip())
if last_read_news_id == 20262103:
last_read_news_id = 20260321 # fix originally misnamed file
return [news_id for news_id in post_installation_news_ids if news_id > last_read_news_id]
try:
@@ -547,7 +549,7 @@ class SerenaDashboardAPI:
from solidlsp.ls_config import Language
def run() -> ResponseAvailableLanguages:
all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)]
all_languages = [lang.value for lang in Language.iter_all(include_experimental=True)]
# Filter out already added languages for the active project
project = self._agent.get_active_project()
+1 -1
View File
@@ -270,7 +270,7 @@ class SerenaMCPFactory:
def create_mcp_server(
self,
host: str = "0.0.0.0",
host: str = "127.0.0.1",
port: int = 8000,
modes: Sequence[str] = (),
language_backend: LanguageBackend | None = None,
@@ -0,0 +1,13 @@
<div class="news-item">
<h3>Querying of External Projects</h3>
<p class="date">March 30, 2026</p>
<p>
We added a new tool for querying external projects that are
known to Serena: From the current project, use Serena's tools on another project to retrieve information.
This is useful in situations where the current project builds on or is otherwise related
to other projects.
</p>
<p>
Find out more about this feature in our <a href="https://oraios.github.io/serena/02-usage/040_workflow.html#reading-from-external-projects">documentation</a>.
</p>
</div>
@@ -29,15 +29,13 @@ line_ending: native
# we have to live with this limitation for now.
gui_log_window: False
# whether to open the Serena web dashboard (which will be accessible through your web browser) that
# provides access to Serena's configuration and state as well as the current session logs.
# whether to start the Serena web dashboard, which provides access to Serena's configuration
# and state as well as the current session logs.
# The web dashboard is supported on all platforms.
# We strongly recommend to always enable this option, since the dashboard provides important information
# about the current state of Serena, including the configuration, the logs and tool usage statistics.
# If you don't want the browser window to pop up automatically, set the `web_dashboard_open_on_launch` to False
# (either here or through the corresponding flag in the `start-mcp-server` CLI command).
# You can then open the dashboard by asking your agent to do so (e.g., by saying "open the dashboard"), Serena provides
# a tool for this.
# about the current state of Serena.
# If you don't want the browser window to pop up automatically, set `web_dashboard_open_on_launch` to False
# (see option below).
# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html
web_dashboard: True
+12 -4
View File
@@ -205,13 +205,14 @@ def yaml_comment_entry_is_empty(comment_entry: Any) -> bool:
def transfer_missing_yaml_comments_by_index(
source: CommentedMap, target: CommentedMap, indices: list[int], forced_update_keys: Sequence[str] = ()
source: CommentedMap, target: CommentedMap, indices: list[int], forced_update_keys: Sequence[str] = (), force_update_all: bool = False
) -> None:
"""
:param source: the source, from which to transfer missing comments
:param target: the target map, whose comments will be updated
:param indices: list of comment indices to transfer
:param forced_update_keys: keys for which comments are always transferred, even if present in target
:param force_update_all: if True, comments are transferred for all keys, even if present in target
"""
for key in target.keys():
if key in source:
@@ -224,14 +225,18 @@ def transfer_missing_yaml_comments_by_index(
target_comment = [None] * 4
target.ca.items[key] = target_comment
# transfer comments at specified indices
is_forced_update = force_update_all or key in forced_update_keys
for index in indices:
is_forced_update = key in forced_update_keys
if is_forced_update or yaml_comment_entry_is_empty(target_comment[index]):
target_comment[index] = source_comment[index]
def transfer_missing_yaml_comments(
source: CommentedMap, target: CommentedMap, comment_normalisation: YamlCommentNormalisation, forced_update_keys: Sequence[str] = ()
source: CommentedMap,
target: CommentedMap,
comment_normalisation: YamlCommentNormalisation,
forced_update_keys: Sequence[str] = (),
force_update_all: bool = False,
) -> None:
"""
Transfers missing comments from source to target YAML.
@@ -240,11 +245,14 @@ def transfer_missing_yaml_comments(
:param target: the target map, whose comments will be updated.
:param comment_normalisation: the comment normalisation to assume; if NONE, no comments are transferred
:param forced_update_keys: keys for which comments are always transferred, even if present in target
:param force_update_all: if True, comments are transferred for all keys, even if present in target
"""
match comment_normalisation:
case YamlCommentNormalisation.NONE:
pass
case YamlCommentNormalisation.LEADING | YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING:
transfer_missing_yaml_comments_by_index(source, target, [ITEM_COMMENT_INDEX_BEFORE], forced_update_keys=forced_update_keys)
transfer_missing_yaml_comments_by_index(
source, target, [ITEM_COMMENT_INDEX_BEFORE], forced_update_keys=forced_update_keys, force_update_all=force_update_all
)
case _:
raise ValueError(f"Unhandled comment normalisation: {comment_normalisation}")
@@ -18,7 +18,7 @@ from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import quote_windows_path
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
from solidlsp.ls_utils import FileUtils
@@ -1007,35 +1007,25 @@ class ALLanguageServer(SolidLanguageServer):
# Get symbols from parent implementation
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
# Normalize names by stripping AL object metadata, storing originals for hover
def normalize_name(symbol: UnifiedSymbolInformation) -> None:
original_name = symbol["name"]
normalized_name = self._extract_al_display_name(original_name)
# Store original name if it was normalized (for hover injection)
# Only store if we have valid position data to avoid false matches at (0, 0)
if original_name != normalized_name:
sel_range = symbol.get("selectionRange")
if sel_range:
start = sel_range.get("start")
if start and "line" in start and "character" in start:
line = start["line"]
char = start["character"]
self._al_original_names[(relative_file_path, line, char)] = original_name
symbol["name"] = normalized_name
# Process children recursively
if symbol.get("children"):
for child in symbol["children"]:
normalize_name(child)
# Apply to all root symbols
for sym in document_symbols.root_symbols:
normalize_name(sym)
return document_symbols
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
original_name = symbol["name"]
normalized_name = self._extract_al_display_name(original_name)
# Store original name if it was normalized (for hover injection)
# Only store if we have valid position data to avoid false matches at (0, 0)
if original_name != normalized_name:
sel_range = symbol.get("selectionRange")
if sel_range:
start = sel_range.get("start") # type: ignore
if start and "line" in start and "character" in start:
line = start["line"]
char = start["character"]
self._al_original_names[(relative_file_path, line, char)] = original_name
return normalized_name
@override
def request_hover(
self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None
+3 -5
View File
@@ -117,7 +117,7 @@ class RuntimeDependencyCollection:
completed_process = subprocess.run(
command,
shell=True,
check=True,
check=False,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -126,10 +126,8 @@ class RuntimeDependencyCollection:
if completed_process.returncode != 0:
log.warning("Command '%s' failed with return code %d", command, completed_process.returncode)
log.warning("Command output:\n%s", completed_process.stdout)
else:
log.info(
"Command completed successfully",
)
raise subprocess.CalledProcessError(completed_process.returncode, command, completed_process.stdout)
log.info("Command completed successfully")
@staticmethod
def _install_from_url(dep: RuntimeDependency, target_dir: str) -> None:
@@ -7,7 +7,7 @@ import os
import platform
import shutil
import threading
from collections.abc import Iterable
from collections.abc import Hashable, Iterable
from dataclasses import replace
from pathlib import Path
from typing import Any, cast
@@ -15,10 +15,15 @@ from typing import Any, cast
from overrides import override
from serena.util.dotnet import DotNETUtil
from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls import (
LanguageServerDependencyProvider,
LSPFileBuffer,
RawDocumentSymbol,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Hover, UnifiedSymbolInformation
from solidlsp.ls_types import Hover
from solidlsp.ls_utils import FileUtils, PathUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult
from solidlsp.settings import SolidLSPSettings
@@ -218,28 +223,6 @@ class CSharpLanguageServer(SolidLanguageServer):
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"]
@override
def request_document_symbols(self, relative_file_path: str, file_buffer: Any = None) -> DocumentSymbols:
"""
Override to normalize Roslyn symbol names and cache originals.
Roslyn 5.5.0+ returns symbol names with type annotations:
- Properties: "Name : string"
- Methods: "Add(int, int) : int"
This method:
1. Normalizes names to base form ("Name", "Add")
2. Caches original names for rich information display
3. Populates LSP spec's 'detail' field with type/signature info
"""
symbols = super().request_document_symbols(relative_file_path, file_buffer)
# Normalize all symbols recursively
for symbol in symbols.iter_symbols():
self._normalize_symbol_name(symbol, relative_file_path)
return symbols
@override
def request_hover(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> Hover | None:
"""
@@ -265,12 +248,20 @@ class CSharpLanguageServer(SolidLanguageServer):
return hover
def _normalize_symbol_name(self, symbol: UnifiedSymbolInformation, relative_file_path: str) -> None:
"""
Normalize a single symbol's name and cache the original.
Processes children recursively.
"""
original_name = symbol.get("name", "")
def _document_symbols_cache_fingerprint(self) -> Hashable | None:
normalize_symbol_name_version = 1
return normalize_symbol_name_version
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
# Roslyn 5.5.0+ returns symbol names with type annotations:
# - Properties: "Name : string"
# - Methods: "Add(int, int) : int"
#
# This method:
# 1. Normalizes names to base form ("Name", "Add")
# 2. Caches original names for rich information display
# 3. Populates LSP spec's 'detail' field with type/signature info
original_name = symbol["name"]
# Extract base name and type/signature info
normalized_name, type_info = self._extract_base_name_and_type(original_name)
@@ -279,24 +270,18 @@ class CSharpLanguageServer(SolidLanguageServer):
if original_name != normalized_name:
sel_range = symbol.get("selectionRange")
if sel_range:
start = sel_range.get("start")
start = sel_range.get("start") # type: ignore
if start and "line" in start and "character" in start:
line = start["line"]
char = start["character"]
cache_key = (relative_file_path, line, char)
self._original_symbol_names[cache_key] = original_name
# Populate LSP spec's 'detail' field with type/signature information
# Populate 'detail' field with type/signature information (for UnifiedSymbolInformation)
if type_info and "detail" not in symbol:
symbol["detail"] = type_info
symbol["detail"] = type_info # type: ignore
# Update the symbol name
symbol["name"] = normalized_name
# Process children recursively
children = symbol.get("children", [])
for child in children:
self._normalize_symbol_name(child, relative_file_path)
return normalized_name
@staticmethod
def _extract_base_name_and_type(roslyn_name: str) -> tuple[str, str]:
+17 -3
View File
@@ -69,6 +69,10 @@ class EclipseJDTLS(SolidLanguageServer):
- gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false)
- gradle_java_home: Path to JDK for Gradle (default: null, uses bundled JRE)
- use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself (default: false)
- jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G")
- jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m")
- intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G")
- intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m")
- gradle_version: Override the pinned Gradle distribution version downloaded by Serena
- 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
@@ -84,6 +88,10 @@ class EclipseJDTLS(SolidLanguageServer):
gradle_wrapper_enabled: true # set to true for projects with custom plugins/repositories
gradle_java_home: "/path/to/jdk" # set to override Gradle's JDK
use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS
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
gradle_version: "8.14.2"
vscode_java_version: "1.42.0-561"
intellicode_version: "1.2.30"
@@ -361,6 +369,8 @@ class EclipseJDTLS(SolidLanguageServer):
lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path
jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path
jdtls_xmx = self._custom_settings.get("jdtls_xmx", "3G")
jdtls_xms = self._custom_settings.get("jdtls_xms", "100m")
data_dir = str(PurePath(ws_dir, "data_dir"))
jdtls_config_path = str(PurePath(ws_dir, "config_path"))
@@ -399,8 +409,8 @@ class EclipseJDTLS(SolidLanguageServer):
"-XX:AdaptiveSizePolicyWeight=90",
"-Dsun.zip.disableMemoryMapping=true",
"-Djava.lsp.joinOnCompletion=true",
"-Xmx3G",
"-Xms100m",
f"-Xmx{jdtls_xmx}",
f"-Xms{jdtls_xms}",
"-Xlog:disable",
"-Dlog.level=ALL",
f"-javaagent:{lombok_jar_path}",
@@ -484,6 +494,10 @@ class EclipseJDTLS(SolidLanguageServer):
gradle_user_home = None
log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults")
# IntelliCode JVM settings (used in vmargs for the embedded JVM)
intellicode_xmx = self._custom_settings.get("intellicode_xmx", "1G")
intellicode_xms = self._custom_settings.get("intellicode_xms", "100m")
# Gradle wrapper: default to False to preserve existing behaviour
gradle_wrapper_enabled = self._custom_settings.get("gradle_wrapper_enabled", False)
log.info(
@@ -680,7 +694,7 @@ class EclipseJDTLS(SolidLanguageServer):
"jdt": {
"ls": {
"java": {"home": None},
"vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable",
"vmargs": f"-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx{intellicode_xmx} -Xms{intellicode_xms} -Xlog:disable",
"lombokSupport": {"enabled": True},
"protobufSupport": {"enabled": True},
"androidSupport": {"enabled": True},
@@ -21,7 +21,7 @@ from solidlsp.language_servers.typescript_language_server import (
prefer_non_node_modules_definition,
)
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Location
from solidlsp.ls_utils import PathUtils
@@ -47,6 +47,11 @@ class VueTypeScriptServer(TypeScriptLanguageServer):
"""
return Language.TYPESCRIPT
def get_source_fn_matcher(self) -> FilenameMatcher:
# must override with Vue-specific matcher to ensure .vue files are included (as they can be discovered via references,
# for instance; otherwise, we may find references in .vue files but then filter the results out, because .vue files are ignored.)
return Language.VUE.get_source_fn_matcher()
class DependencyProvider(TypeScriptLanguageServer.DependencyProvider):
override_ts_ls_executable: str | None = None
+516 -174
View File
@@ -23,7 +23,7 @@ from sensai.util.string import ToStringMixin
from serena.util.file_system import match_path
from serena.util.text_utils import MatchedConsecutiveLines
from solidlsp import ls_types
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_process import LanguageServerProcess
from solidlsp.ls_types import UnifiedSymbolInformation
@@ -35,6 +35,7 @@ from solidlsp.lsp_protocol_handler.lsp_types import (
Definition,
DefinitionParams,
DocumentSymbol,
ImplementationParams,
LocationLink,
RenameParams,
SymbolInformation,
@@ -47,7 +48,13 @@ from solidlsp.lsp_protocol_handler.server import (
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.cache import load_cache, save_cache
GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation]
RawDocumentSymbol = Union[DocumentSymbol, SymbolInformation]
"""
Type alias for the raw symbol information returned by a language server in response to a
`textDocument/documentSymbol` request.
The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformation` is also still used.
"""
log = logging.getLogger(__name__)
_debug_enabled = log.isEnabledFor(logging.DEBUG)
@@ -210,7 +217,7 @@ class SymbolBodyFactory:
def __init__(self, file_buffer: LSPFileBuffer):
self._lines = file_buffer.split_lines()
def create_symbol_body(self, symbol: GenericDocumentSymbol) -> SymbolBody:
def create_symbol_body(self, symbol: UnifiedSymbolInformation) -> SymbolBody:
existing_body = symbol.get("body", None)
if existing_body and isinstance(existing_body, SymbolBody):
return existing_body
@@ -378,6 +385,13 @@ class SolidLanguageServer(ABC):
def get_language_enum_instance(cls) -> Language:
return Language.from_ls_class(cls)
@classmethod
def supports_implementation_request(cls) -> bool:
"""
Return whether this language server supports ``textDocument/implementation``.
"""
return False
@classmethod
def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str:
"""
@@ -575,6 +589,14 @@ class SolidLanguageServer(ABC):
"""
return self._ignore_spec
def get_source_fn_matcher(self) -> FilenameMatcher:
"""
:return: the source filename matcher for this language server, which must positively match all files that
are understood by this language server or are discovered as containing sources indirectly, e.g. via references
"""
# By default, use the matcher of the language
return self.language.get_source_fn_matcher()
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
"""
Determine if a path should be ignored based on file type
@@ -592,7 +614,7 @@ class SolidLanguageServer(ABC):
# Check file extension if it's a file
is_file = os.path.isfile(abs_path)
if is_file and ignore_unsupported_files:
fn_matcher = self.language.get_source_fn_matcher()
fn_matcher = self.get_source_fn_matcher()
if not fn_matcher.is_relevant_filename(abs_path):
return True
@@ -846,6 +868,149 @@ class SolidLanguageServer(ABC):
def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None:
return self.server.send.definition(definition_params)
class SymbolLocationRequest(ABC):
def __init__(
self,
language_server: "SolidLanguageServer",
relative_file_path: str,
line: int,
column: int,
*,
request_name: str,
) -> None:
self.language_server = language_server
self.relative_file_path = relative_file_path
self.line = line
self.column = column
self.request_name = request_name
self.skip_ignored_paths = True
def execute(self) -> list[ls_types.Location]:
self._ensure_server_started()
t0 = perf_counter() if _debug_enabled else None
with self.language_server.open_file(self.relative_file_path):
self.language_server._wait_for_cross_file_references_if_needed()
try:
response = self.send_request()
except Exception as e:
mapped_exception = self.map_exception(e)
if mapped_exception is not None:
raise mapped_exception from e
raise
result = self.normalize_response(response)
if t0 is not None:
self.log_perf_result(t0, result)
return result
def _ensure_server_started(self) -> None:
if not self.language_server.server_started:
log.error("%s called before language server started", self.request_name)
raise SolidLSPException("Language Server not started")
@abstractmethod
def send_request(self) -> object | None:
pass
def map_exception(self, error: Exception) -> Exception | None:
if isinstance(error, LSPError) and getattr(error, "code", None) == -32603:
return RuntimeError(
f"LSP internal error (-32603) when requesting {self.request_name} for "
f"{self.relative_file_path}:{self.line}:{self.column}. "
"This often occurs when requesting a symbol in a way the language server cannot resolve."
)
return None
@abstractmethod
def normalize_response(self, response: object | None) -> list[ls_types.Location]:
pass
def convert_location_item(self, item: dict[str, object], *, allow_location_links: bool = False) -> ls_types.Location | None:
if LSPConstants.URI in item and LSPConstants.RANGE in item:
uri = cast(str, item[LSPConstants.URI])
range_d = cast(ls_types.Range, item[LSPConstants.RANGE])
elif (
allow_location_links
and LSPConstants.TARGET_URI in item
and LSPConstants.TARGET_RANGE in item
and LSPConstants.TARGET_SELECTION_RANGE in item
):
uri = cast(str, item[LSPConstants.TARGET_URI])
range_d = cast(ls_types.Range, item[LSPConstants.TARGET_SELECTION_RANGE])
else:
raise AssertionError(f"Unexpected response from Language Server: {item}")
abs_path = PathUtils.uri_to_path(uri)
rel_path_str = PathUtils.get_relative_path(abs_path, self.language_server.repository_root_path)
if rel_path_str is None:
log.warning(
"Found a %s in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! "
"Path: %s. This is a bug but we currently simply skip these locations.",
self.request_name,
abs_path,
)
return None
if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str):
log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str)
return None
return ls_types.Location(uri=uri, range=range_d, absolutePath=str(abs_path), relativePath=rel_path_str)
def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None:
return
class DefinitionLocationRequest(SymbolLocationRequest):
def __init__(
self,
language_server: "SolidLanguageServer",
relative_file_path: str,
line: int,
column: int,
*,
request_name: str = "request_definition",
) -> None:
super().__init__(
language_server,
relative_file_path,
line,
column,
request_name=request_name,
)
def send_request(self) -> object | None:
return self.language_server._send_definition_request(
self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column)
)
def normalize_response(self, response: object | None) -> list[ls_types.Location]:
if response is None:
log.warning(
"Language server returned None for %s request at %s:%s:%s",
self.request_name,
self.relative_file_path,
self.line,
self.column,
)
return []
ret: list[ls_types.Location] = []
if isinstance(response, list):
for item in response:
assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}"
if location := self.convert_location_item(cast(dict[str, object], item), allow_location_links=True):
ret.append(location)
return ret
if isinstance(response, dict):
if location := self.convert_location_item(cast(dict[str, object], response), allow_location_links=True):
ret.append(location)
return ret
assert False, f"Unexpected response from Language Server: {response}"
def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
"""
Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server
@@ -857,70 +1022,60 @@ class SolidLanguageServer(ABC):
:return: the list of locations where the symbol is defined
"""
if not self.server_started:
log.error("request_definition called before language server started")
raise SolidLSPException("Language Server not started")
request = self.DefinitionLocationRequest(self, relative_file_path, line, column)
return request.execute()
def _send_implementation_request(self, implementation_params: ImplementationParams) -> Definition | list[LocationLink] | None:
return self.server.send.implementation(implementation_params)
def _create_text_document_position_params(self, relative_file_path: str, line: int, column: int) -> DefinitionParams:
return cast(
DefinitionParams,
{
LSPConstants.TEXT_DOCUMENT: {
LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
},
LSPConstants.POSITION: {
LSPConstants.LINE: line,
LSPConstants.CHARACTER: column,
},
},
)
def _wait_for_cross_file_references_if_needed(self) -> None:
if not self._has_waited_for_cross_file_references:
# Some LS require waiting for a while before they can return cross-file definitions.
# This is a workaround for such LS that don't have a reliable "finished initializing" signal.
# Some LS require waiting for a while before they can return accurate cross-file results.
# The waiting has to happen after at least one file was opened in the LS.
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
with self.open_file(relative_file_path):
# sending request to the language server and waiting for response
definition_params = cast(
DefinitionParams,
{
LSPConstants.TEXT_DOCUMENT: {
LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
},
LSPConstants.POSITION: {
LSPConstants.LINE: line,
LSPConstants.CHARACTER: column,
},
},
class ImplementationLocationRequest(DefinitionLocationRequest):
def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None:
super().__init__(
language_server,
relative_file_path,
line,
column,
request_name="request_implementation",
)
response = self._send_definition_request(definition_params)
ret: list[ls_types.Location] = []
if isinstance(response, list):
# response is either of type Location[] or LocationLink[]
for item in response:
assert isinstance(item, dict)
if LSPConstants.URI in item and LSPConstants.RANGE in item:
new_item: dict = {}
new_item.update(item)
new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
ret.append(ls_types.Location(**new_item)) # type: ignore
elif LSPConstants.TARGET_URI in item and LSPConstants.TARGET_RANGE in item and LSPConstants.TARGET_SELECTION_RANGE in item:
new_item: dict = {} # type: ignore
new_item["uri"] = item[LSPConstants.TARGET_URI] # type: ignore
new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] # type: ignore
ret.append(ls_types.Location(**new_item)) # type: ignore
else:
assert False, f"Unexpected response from Language Server: {item}"
elif isinstance(response, dict):
# response is of type Location
assert LSPConstants.URI in response
assert LSPConstants.RANGE in response
def send_request(self) -> object | None:
return self.language_server._send_implementation_request(
self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column),
)
new_item: dict = {} # type: ignore
new_item.update(response)
new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
ret.append(ls_types.Location(**new_item)) # type: ignore
elif response is None:
# Some language servers return None when they cannot find a definition
# This is expected for certain symbol types like generics or types with incomplete information
log.warning(f"Language server returned None for definition request at {relative_file_path}:{line}:{column}")
else:
assert False, f"Unexpected response from Language Server: {response}"
def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
"""
Raise a [textDocument/implementation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation)
request to the Language Server for the symbol at the given line and column in the given file.
return ret
:param relative_file_path: The relative path of the file that has the symbol for which implementations should be looked up
:param line: The line number of the symbol
:param column: The column number of the symbol
:return: the list of locations where the symbol is implemented
"""
request = self.ImplementationLocationRequest(self, relative_file_path, line, column)
return request.execute()
# Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
@@ -932,6 +1087,46 @@ class SolidLanguageServer(ABC):
}
)
class ReferencesLocationRequest(SymbolLocationRequest):
def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None:
super().__init__(
language_server,
relative_file_path,
line,
column,
request_name="request_references",
)
def send_request(self) -> object | None:
return self.language_server._send_references_request(self.relative_file_path, line=self.line, column=self.column)
def normalize_response(self, response: object | None) -> list[ls_types.Location]:
if response is None:
return []
assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
ret: list[ls_types.Location] = []
for item in response:
assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}"
if location := self.convert_location_item(cast(dict[str, object], item)):
ret.append(location)
return ret
def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None:
elapsed_ms = (perf_counter() - t0) * 1000
if not result:
log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", self.relative_file_path, elapsed_ms)
return
unique_files = len({r["relativePath"] for r in result})
log.debug(
"perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d",
self.relative_file_path,
elapsed_ms,
len(result),
unique_files,
)
def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
"""
Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
@@ -944,111 +1139,8 @@ class SolidLanguageServer(ABC):
:return: A list of locations where the symbol is referenced (excluding ignored directories)
"""
if not self.server_started:
log.error("request_references called before Language Server started")
raise SolidLSPException("Language Server not started")
with self.open_file(relative_file_path):
if not self._has_waited_for_cross_file_references:
# Some LS require waiting for a while before they can return cross-file references.
# This is a workaround for such LS that don't have a reliable "finished initializing" signal.
# The waiting has to happen after at least one file was opened in the ls
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
t0 = perf_counter() if _debug_enabled else 0.0
try:
response = self._send_references_request(relative_file_path, line=line, column=column)
except Exception as e:
# Catch LSP internal error (-32603) and raise a more informative exception
if isinstance(e, LSPError) and getattr(e, "code", None) == -32603:
raise RuntimeError(
f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. "
"This often occurs when requesting references for a symbol not referenced in the expected way. "
) from e
raise
if response is None:
if _debug_enabled:
elapsed_ms = (perf_counter() - t0) * 1000
log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms)
return []
ret: list[ls_types.Location] = []
assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
for item in response:
assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}"
assert LSPConstants.URI in item
assert LSPConstants.RANGE in item
abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) # type: ignore
if not Path(abs_path).is_relative_to(self.repository_root_path):
log.warning(
"Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! "
f"Path: {abs_path}. This is a bug but we currently simply skip these references."
)
continue
rel_path = Path(abs_path).relative_to(self.repository_root_path)
if self.is_ignored_path(str(rel_path)):
log.debug("Ignoring reference in %s since it should be ignored", rel_path)
continue
new_item: dict = {}
new_item.update(item)
new_item["absolutePath"] = str(abs_path)
new_item["relativePath"] = str(rel_path)
ret.append(ls_types.Location(**new_item)) # type: ignore
if _debug_enabled:
elapsed_ms = (perf_counter() - t0) * 1000
unique_files = len({r["relativePath"] for r in ret})
log.debug(
"perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d",
relative_file_path,
elapsed_ms,
len(ret),
unique_files,
)
return ret
def request_text_document_diagnostics(self, relative_file_path: str) -> 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
:return: A list of diagnostics for the file
"""
if not self.server_started:
log.error("request_text_document_diagnostics called before Language Server started")
raise SolidLSPException("Language Server not started")
with self.open_file(relative_file_path):
response = self.server.send.text_document_diagnostic(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
}
}
)
if response is None:
return [] # type: ignore
assert isinstance(response, dict), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
ret: list[ls_types.Diagnostic] = []
for item in response["items"]: # type: ignore
new_item: ls_types.Diagnostic = {
"uri": pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri(),
"severity": item["severity"],
"message": item["message"],
"range": item["range"],
"code": item["code"], # type: ignore
}
ret.append(ls_types.Diagnostic(**new_item))
return ret
request = self.ReferencesLocationRequest(self, relative_file_path, line, column)
return request.execute()
def retrieve_full_file_content(self, file_path: str) -> str:
"""
@@ -1173,6 +1265,14 @@ class SolidLanguageServer(ABC):
"""
Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol)
request to the language server to find symbols in the given file - or returns a cached result if available.
The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`).
NOTE: This method can be overridden in subclasses to post-process the raw results.
When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols`
to a different version (add 1) to ensure that all caches are invalidated appropriately.
IMPORTANT: Since rebuilding the raw document symbol cache from the language server results
is potentially expensive, prefer overriding the `request_document_symbols` method
if the post-processing can also be done on the processed/high-level symbols.
:param relative_file_path: the relative path of the file that has the symbols.
:param file_data: the file data buffer, if already opened. If None, the file will be opened in this method.
@@ -1218,9 +1318,30 @@ class SolidLanguageServer(ABC):
with self._open_file_context(relative_file_path, file_buffer=file_data) as fd:
return get_raw_document_symbols(fd)
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
"""
Normalizes the name of the given symbol, e.g. by removing parameter lists from method symbols.
Override this method in subclasses to implement language-specific normalization logic.
NOTE: When changing the override of this method after the initial LS implementation,
be sure to also override `_document_symbols_cache_fingerprint` in order to ensure that
the caches are invalidated appropriately.
:param symbol: the symbol
:param relative_file_path: the relative path of the file the symbol is located in
:return: the normalized name of the symbol
"""
# the default implementation does not change the name
return symbol["name"]
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
"""
Retrieves the collection of symbols in the given file
Retrieves the collection of symbols in the given file.
NOTE: This method can be overridden in subclasses to post-process the results.
When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint`
to ensure that the caches are invalidated appropriately.
DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead.
:param relative_file_path: The relative path of the file that has the symbols
:param file_buffer: an optional file buffer if the file is already opened.
@@ -1264,7 +1385,7 @@ class SolidLanguageServer(ABC):
body_factory = SymbolBodyFactory(file_data)
def convert_to_unified_symbol(original_symbol_dict: GenericDocumentSymbol) -> ls_types.UnifiedSymbolInformation:
def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation:
"""
Converts the given symbol dictionary to the unified representation, ensuring
that all required fields are present (except 'children' which is handled separately).
@@ -1305,16 +1426,23 @@ class SolidLanguageServer(ABC):
return item
def convert_symbols_with_common_parent(
symbols: list[DocumentSymbol] | list[SymbolInformation] | list[UnifiedSymbolInformation],
symbols: list[DocumentSymbol] | list[SymbolInformation],
parent: ls_types.UnifiedSymbolInformation | None,
) -> list[ls_types.UnifiedSymbolInformation]:
"""
Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships,
adding overload indices for symbols with the same name under the same parent.
"""
# apply name normalization and count occurrences of each symbol name
total_name_counts: dict[str, int] = defaultdict(lambda: 0)
for symbol in symbols:
total_name_counts[symbol["name"]] += 1
name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path)
symbol["name"] = name
total_name_counts[name] += 1
# convert symbols to the unified representation and
# * add overload indices where necessary
# * ensure that the "parent" field is set correctly
name_counts: dict[str, int] = defaultdict(lambda: 0)
unified_symbols = []
for symbol in symbols:
@@ -1629,7 +1757,7 @@ class SolidLanguageServer(ABC):
def create_symbol_body(
self,
symbol: ls_types.UnifiedSymbolInformation | LSPTypes.SymbolInformation,
symbol: ls_types.UnifiedSymbolInformation,
factory: SymbolBodyFactory | None = None,
) -> SymbolBody:
if factory is None:
@@ -1945,6 +2073,153 @@ class SolidLanguageServer(ABC):
"""
return definitions[0]
def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]:
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
document_symbols = self.request_document_symbols(relative_file_path)
symbols = list(document_symbols.iter_symbols())
# Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every
# symbol exposes a normalized location/range in the current workspace.
for symbol in symbols:
location = symbol["location"]
location["absolutePath"] = absolute_file_path
location["relativePath"] = relative_file_path
location["uri"] = Path(absolute_file_path).as_uri()
return symbols
@staticmethod
def _position_matches_range(range_d: ls_types.Range, line: int, column: int | None = None) -> bool:
start = range_d["start"]
end = range_d["end"]
if not (start["line"] <= line <= end["line"]):
return False
if column is None:
return True
if line == start["line"] and column < start["character"]:
return False
if line == end["line"] and column > end["character"]:
return False
return True
@staticmethod
def _symbol_match_sort_key(symbol: ls_types.UnifiedSymbolInformation, match_priority: int) -> tuple[int, int, int, int, int]:
location = symbol["location"]
symbol_range = location["range"]
start = symbol_range["start"]
end = symbol_range["end"]
line_span = end["line"] - start["line"]
character_span = end["character"] - start["character"] if line_span == 0 else end["character"]
return match_priority, line_span, character_span, start["line"], start["character"]
def _request_symbol_at_location(
self,
relative_file_path: str,
line: int,
column: int,
include_body: bool = False,
body_factory: SymbolBodyFactory | None = None,
) -> ls_types.UnifiedSymbolInformation | None:
candidates: list[tuple[tuple[int, int, int, int, int], ls_types.UnifiedSymbolInformation]] = []
for symbol in self._get_document_symbols_with_locations(relative_file_path):
location = symbol["location"]
symbol_range = location["range"]
selection_range = symbol.get("selectionRange") or symbol_range
match_priority: int | None = None
if self._position_matches_range(selection_range, line, column):
match_priority = 0
elif self._position_matches_range(symbol_range, line, column):
match_priority = 1
else:
selection_start = selection_range["start"]
symbol_start = symbol_range["start"]
if (selection_start["line"], selection_start["character"]) == (line, column):
match_priority = 2
elif (symbol_start["line"], symbol_start["character"]) == (line, column):
match_priority = 3
elif selection_start["line"] == line and column <= selection_start["character"]:
match_priority = 4
elif symbol_start["line"] == line and column <= symbol_start["character"]:
match_priority = 5
if match_priority is None:
continue
candidates.append((self._symbol_match_sort_key(symbol, match_priority), symbol))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
best_symbol = candidates[0][1]
if include_body:
best_symbol["body"] = self.create_symbol_body(best_symbol, factory=body_factory)
return best_symbol
@staticmethod
def _iter_symbol_descendants(symbol: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]:
"""Yield descendant symbols in depth-first order."""
for child in symbol.get("children", []):
yield child
yield from SolidLanguageServer._iter_symbol_descendants(child)
def _refine_implementing_symbol(
self,
target_symbol: ls_types.UnifiedSymbolInformation | None,
implementing_symbol: ls_types.UnifiedSymbolInformation,
include_body: bool = False,
) -> ls_types.UnifiedSymbolInformation:
"""Resolve member-level implementation symbols when the LS returns a containing type."""
if target_symbol is None:
return implementing_symbol
target_kind = target_symbol["kind"]
if target_kind not in (ls_types.SymbolKind.Method, ls_types.SymbolKind.Function):
return implementing_symbol
if implementing_symbol["kind"] == target_kind and implementing_symbol.get("name") == target_symbol.get("name"):
return implementing_symbol
candidate_descendants: list[ls_types.UnifiedSymbolInformation] = []
for descendant in self._iter_symbol_descendants(implementing_symbol):
if descendant.get("name") != target_symbol.get("name"):
continue
if descendant["kind"] != target_kind:
continue
candidate_descendants.append(descendant)
if not candidate_descendants:
return implementing_symbol
refined_symbol = min(
candidate_descendants,
key=lambda symbol: self._symbol_match_sort_key(symbol, match_priority=0),
)
if include_body:
refined_symbol["body"] = self.create_symbol_body(refined_symbol)
return refined_symbol
def request_symbol_at_location(
self,
relative_file_path: str,
line: int,
column: int,
include_body: bool = False,
) -> ls_types.UnifiedSymbolInformation | None:
"""
Finds the symbol at the given position, preferring exact identifier matches and otherwise
falling back to the innermost symbol whose body contains the position.
:param relative_file_path: The relative path to the file.
:param line: The 0-indexed line number.
:param column: The 0-indexed column number.
:param include_body: whether to include the body of the symbol in the result.
:return: The symbol at the given location, or None if no symbol could be resolved.
"""
if not self.server_started:
log.error("request_symbol_at_location called before language server started")
raise SolidLSPException("Language Server not started")
return self._request_symbol_at_location(relative_file_path, line, column, include_body=include_body)
def request_defining_symbol(
self,
relative_file_path: str,
@@ -1976,14 +2251,79 @@ class SolidLanguageServer(ABC):
# Select the preferred definition (subclasses can override _get_preferred_definition)
definition = self._get_preferred_definition(definitions)
def_path = definition["relativePath"]
assert def_path is not None
if def_path is None:
return None
def_line = definition["range"]["start"]["line"]
def_col = definition["range"]["start"]["character"]
# Find the symbol at or containing this location
defining_symbol = self.request_containing_symbol(def_path, def_line, def_col, strict=False, include_body=include_body)
return self._request_symbol_at_location(
def_path,
def_line,
def_col,
include_body=include_body,
)
return defining_symbol
def request_implementing_symbols(
self,
relative_file_path: str,
line: int,
column: int,
include_body: bool = False,
) -> list[ls_types.UnifiedSymbolInformation]:
"""
Finds the symbols that implement the symbol at the given location.
This method first finds implementation locations for the symbol at the given position,
then retrieves the full symbol information for each implementation and de-duplicates
results that map to the same containing symbol.
:param relative_file_path: The relative path to the file.
:param line: The 0-indexed line number.
:param column: The 0-indexed column number.
:param include_body: whether to include the body of the symbols in the result.
:return: The symbol information for each implementation.
"""
if not self.server_started:
log.error("request_implementing_symbols called before language server started")
raise SolidLSPException("Language Server not started")
target_symbol = self._request_symbol_at_location(relative_file_path, line, column, include_body=False)
implementation_locations = self.request_implementation(relative_file_path, line, column)
if not implementation_locations:
return []
result: list[ls_types.UnifiedSymbolInformation] = []
seen_keys: set[tuple[str, int, int, int]] = set()
for implementation in implementation_locations:
implementation_path = implementation["relativePath"]
assert implementation_path is not None
implementation_line = implementation["range"]["start"]["line"]
implementation_col = implementation["range"]["start"]["character"]
implementing_symbol = self._request_symbol_at_location(
implementation_path,
implementation_line,
implementation_col,
include_body=include_body,
body_factory=None,
)
if implementing_symbol is None:
continue
implementing_symbol = self._refine_implementing_symbol(target_symbol, implementing_symbol, include_body=include_body)
if "location" not in implementing_symbol:
continue
symbol_location = implementing_symbol["location"]
symbol_key = (
cast(str, symbol_location["relativePath"]),
symbol_location["range"]["start"]["line"],
symbol_location["range"]["start"]["character"],
implementing_symbol["kind"],
)
if symbol_key in seen_keys:
continue
seen_keys.add(symbol_key)
result.append(implementing_symbol)
return result
def _document_symbols_cache_fingerprint(self) -> Hashable | None:
"""
@@ -2001,7 +2341,9 @@ class SolidLanguageServer(ABC):
The value must be hashable and safe for inclusion in cache version tuples.
E.g. use an integer, a string or a tuple of integers/strings.
Returns None if no context-specific fingerprint is needed.
For example, if there is a single aspect being considered, use an integer to reflect the version
of this aspect (incrementing it whenever the implementation changes).
If multiple versioned aspects exist, use a tuple of versions, etc.
"""
return None
+1 -1
View File
@@ -321,7 +321,7 @@ class LanguageServerProcess:
if not chunk:
if process.poll() is not None:
raise LanguageServerTerminatedException(
f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)",
f"Process terminated while trying to read response (read {len(data)} of {num_bytes} bytes before termination)",
language=self.language,
)
# Process still running but no data available yet, retry after a short delay
+1 -1
View File
@@ -549,7 +549,7 @@ class PlatformUtils:
# If no supported version found, raise exception with all available versions
raise SolidLSPException(
f"No supported dotnet version found. Available versions: {', '.join(available_version_cmd_output)}. Supported versions: 4, 6, 7, 8"
f"No supported dotnet version found. Available versions: {', '.join(available_version_cmd_output)}. Supported versions: 4, 6, 7, 8, 9"
)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
+55
View File
@@ -0,0 +1,55 @@
from types import SimpleNamespace
from serena.dashboard import SerenaDashboardAPI
from solidlsp.ls_config import Language
class _DummyMemoryLogHandler:
def get_log_messages(self, from_idx: int = 0): # pragma: no cover - simple stub
return SimpleNamespace(messages=[], max_idx=-1)
def clear_log_messages(self) -> None: # pragma: no cover - simple stub
pass
class _DummyAgent:
def __init__(self, project: SimpleNamespace | None) -> None:
self._project = project
def execute_task(self, func, *, logged: bool | None = None, name: str | None = None):
del logged, name
return func()
def get_active_project(self):
return self._project
def _make_dashboard(project_languages: list[Language] | None) -> SerenaDashboardAPI:
project = None
if project_languages is not None:
project = SimpleNamespace(project_config=SimpleNamespace(languages=project_languages))
agent = _DummyAgent(project)
return SerenaDashboardAPI(
memory_log_handler=_DummyMemoryLogHandler(),
tool_names=[],
agent=agent,
shutdown_callback=None,
tool_usage_stats=None,
)
def test_available_languages_include_experimental_when_no_active_project():
dashboard = _make_dashboard(project_languages=None)
response = dashboard._get_available_languages()
expected = sorted(lang.value for lang in Language.iter_all(include_experimental=True))
assert response.languages == expected
def test_available_languages_exclude_project_languages():
dashboard = _make_dashboard(project_languages=[Language.PYTHON, Language.MARKDOWN])
response = dashboard._get_available_languages()
available = set(response.languages)
assert Language.PYTHON.value not in available
assert Language.MARKDOWN.value not in available
# ensure experimental languages remain available for selection
assert Language.ANSIBLE.value in available
+7 -2
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
@@ -243,8 +244,12 @@ class TestDartLanguageServer:
def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a variable usage."""
file_path = os.path.join("lib", "main.dart")
# Line 14 contains 'final result = a + b;' - test position on 'result'
defining_symbol = language_server.request_defining_symbol(file_path, 13, 10)
# Find coordinates of 'final result = a + b;' - test position on 'result'
with language_server.open_file(file_path, open_in_ls=False) as f:
pos = find_text_coordinates(f.contents, r"final (result) = a \+ b;")
defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col)
# The defining symbol might be the variable itself or the containing method
# This is acceptable behavior - different language servers handle this differently
@@ -12,6 +12,7 @@ import os
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
@@ -583,15 +584,17 @@ class TestRubyLanguageServerSymbols:
"""Test request_defining_symbol for a method call."""
# Test finding the definition of a method being called
file_path = os.path.join("examples", "user_management.rb")
# Position at a method call like create_user
defining_symbol = language_server.request_defining_symbol(file_path, 30, 15)
# Verify that we can find method definitions
if defining_symbol is not None:
assert "name" in defining_symbol
assert "kind" in defining_symbol
# Should be a method or constructor
assert defining_symbol.get("kind") in [SymbolKind.Method.value, SymbolKind.Constructor.value, SymbolKind.Function.value]
# Position at a method call like create_user
with language_server.open_file(file_path, open_in_ls=False) as fb:
pos = find_text_coordinates(fb.contents, r"user = @service\.(create_user)")
# Verify that we can find the method definition
defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col)
assert "name" in defining_symbol
assert "kind" in defining_symbol
assert defining_symbol.get("name") == "create_user"
assert defining_symbol.get("kind") == SymbolKind.Method.value
@pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True)
def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None:
Generated
+810 -618
View File
File diff suppressed because it is too large. Load diff