Merge branch 'feature/docstring_and_signature' into add_news_display

# Conflicts:
#	src/serena/code_editor.py
This commit is contained in:
Michael Panchenko committed 2026-01-11 19:28:49 +07:00
commit f3a508b7f2
19 files changed
+750 -83

No files matched your search

+2 -1
View File
@@ -35,6 +35,7 @@ dependencies = [
"tqdm>=4.67.1",
"tiktoken>=0.9.0",
"anthropic>=0.54.0",
"beautifulsoup4>=4.14.2",
]
[[tool.uv.index]]
@@ -314,4 +315,4 @@ markers = [
skip = '.git*,*.svg,*.lock,*.min.*'
check-hidden = true
# ignore-regex = ''
ignore-words-list = 'paket'
ignore-words-list = 'paket'
+13 -4
View File
@@ -7,21 +7,30 @@ import json
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT
from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, JetBrainsFindSymbolTool, SearchForPatternTool
from serena.tools import (
FindFileTool,
FindReferencingSymbolsTool,
JetBrainsFindSymbolTool,
JetBrainsGetSymbolsOverviewTool,
SearchForPatternTool,
)
if __name__ == "__main__":
agent = SerenaAgent(project=REPO_ROOT)
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
agent = SerenaAgent(project=REPO_ROOT, serena_config=serena_config)
# apply a tool
find_symbol_tool = agent.get_tool(JetBrainsFindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
find_file_tool = agent.get_tool(FindFileTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool)
result = agent.execute_task(
lambda: find_symbol_tool.apply("displayBasicStats"),
lambda: find_symbol_tool.apply("SerenaAgent/get_tool_description_override"),
)
pprint(json.loads(result))
# input("Press Enter to continue...")
+6
View File
@@ -717,3 +717,9 @@ class SerenaAgent:
def get_tool_by_name(self, tool_name: str) -> Tool:
tool_class = ToolRegistry().get_tool_class_by_name(tool_name)
return self.get_tool(tool_class)
def get_active_lsp_languages(self) -> list[Language]:
ls_manager = self.get_language_server_manager()
if ls_manager is None:
return []
return ls_manager.get_active_languages()
+8
View File
@@ -182,6 +182,14 @@ class LanguageServerManager:
ls = self._language_servers.pop(language)
self._stop_language_server(ls, save_cache=save_cache)
def get_active_languages(self) -> list[Language]:
"""
Returns the list of languages for which language servers are currently managed.
:return: list of languages
"""
return list(self._language_servers.keys())
@staticmethod
def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False, timeout: float = 2.0) -> None:
if ls.is_running():
@@ -13,3 +13,4 @@ included_optional_tools:
- jet_brains_find_symbol
- jet_brains_find_referencing_symbols
- jet_brains_get_symbols_overview
- jet_brains_type_hierarchy
+36 -10
View File
@@ -4,10 +4,11 @@ import os
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator, Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union
from typing import TYPE_CHECKING, Any, Self, Union
from sensai.util.string import ToStringMixin
import serena.tools.jetbrains_types as jb
from solidlsp import SolidLanguageServer
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
@@ -496,6 +497,39 @@ class LanguageServerSymbolRetriever:
self._ls_manager: LanguageServerManager = ls_manager
self.agent = agent
def _request_info(self, relative_file_path: str, line: int, column: int) -> str | None:
"""Retrieves information (in a sanitized format) about the symbol at the desired location,
typically containing the docstring and signature.
Returns None if no information is available.
"""
lang_server = self.get_language_server(relative_file_path)
hover_info = lang_server.request_hover(relative_file_path=relative_file_path, line=line, column=column)
if hover_info is None:
return None
contents = hover_info["contents"]
# Handle various response formats
if isinstance(contents, list):
# Array format: extract all parts and join them
stripped_parts = []
for part in contents:
if isinstance(part, str) and (stripped_part := part.strip()):
stripped_parts.append(stripped_part)
else:
# should be a dict with "value" key
stripped_parts.append(part["value"].strip()) # type: ignore
return "\n".join(stripped_parts) if stripped_parts else None
if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()):
return stripped_contents
if isinstance(contents, str) and (stripped_contents := contents.strip()):
return stripped_contents
return None
def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None:
if None in [symbol.relative_path, symbol.line, symbol.column]:
return None
return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type]
def get_root_path(self) -> str:
return self._ls_manager.get_root_path()
@@ -672,15 +706,7 @@ class LanguageServerSymbolRetriever:
class JetBrainsSymbol(Symbol):
class SymbolDict(TypedDict):
name_path: str
relative_path: str
type: str
text_range: NotRequired[dict]
body: NotRequired[str]
children: NotRequired[list["JetBrainsSymbol.SymbolDict"]]
def __init__(self, symbol_dict: SymbolDict, project: Project) -> None:
def __init__(self, symbol_dict: jb.SymbolDTO, project: Project) -> None:
"""
:param symbol_dict: dictionary as returned by the JetBrains plugin client.
"""
+19
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, Self
from bs4 import BeautifulSoup
from joblib import Parallel, delayed
from serena.constants import DEFAULT_SOURCE_FILE_ENCODING
@@ -405,3 +406,21 @@ def search_files(
log.info(f"Found {len(matches)} total matches across {len(filtered_paths)} files")
return matches
def render_html(html: str) -> str:
"""
Remove HTML tags and decode HTML entities from text while preserving the actual content.
This keeps type information and structure but removes all formatting.
:param html: HTML text to clean
:return: Plain text without HTML tags and with decoded entities
"""
soup = BeautifulSoup(html, "html.parser")
# join text with spaces to avoid concatenation of words
text = soup.get_text(separator=" ", strip=True)
# normalize non-breaking spaces
text = text.replace("\xa0", " ")
return text.strip()
+182 -38
View File
@@ -5,13 +5,17 @@ Client for the Serena JetBrains Plugin
import json
import logging
from pathlib import Path
from typing import Any, Optional, Self, TypeVar
from typing import Any, Literal, Optional, Self, TypeVar, cast
import requests
from requests import Response
from sensai.util.string import ToStringMixin
import serena.tools.jetbrains_types as jb
from serena.project import Project
from serena.text_utils import render_html
from serena.tools.jetbrains_types import PluginStatusDTO
from serena.util.version import Version
T = TypeVar("T")
log = logging.getLogger(__name__)
@@ -26,7 +30,28 @@ class ConnectionError(SerenaClientError):
class APIError(SerenaClientError):
"""Raised when the API returns an error response."""
"""Raised when the API returns an error response that shall be reported on the caller/LLM."""
def __init__(self, response: Response):
try:
error_data = response.json()
message = error_data.get("error", response.text)
except:
message = response.text
super().__init__(message)
class PluginServerError(SerenaClientError):
"""Raised when the plugin's service encounters an internal error (which may need to be reported)."""
def __init__(self, message: str):
unrecoverable_error_prefix = (
"The request failed due to an error on the Serena JetBrains plugin side. This may be caused "
"by a bug or an incompatibility in the plugin. In that case, please report the error below together with "
"your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues "
"(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n"
)
super().__init__(unrecoverable_error_prefix + message)
class ServerNotFoundError(Exception):
@@ -48,13 +73,25 @@ class JetBrainsPluginClient(ToStringMixin):
last_port: int | None = None
def __init__(self, port: int, timeout: int = PLUGIN_REQUEST_TIMEOUT):
self.base_url = f"http://127.0.0.1:{port}"
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
self._port = port
self._base_url = f"http://127.0.0.1:{port}"
self._timeout = timeout
self._session = requests.Session()
self._session.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
# connect and obtain status
self._project_root: str | None
self._plugin_version: Version | None
try:
status_response: PluginStatusDTO = cast(jb.PluginStatusDTO, self._make_request("GET", "/status"))
self._project_root = status_response["project_root"]
self._plugin_version = Version(status_response["plugin_version"])
except ConnectionError:
self._project_root = None
self._plugin_version = None
def _tostring_includes(self) -> list[str]:
return ["base_url", "timeout"]
return ["_port", "_project_root", "_plugin_version"]
@classmethod
def from_project(cls, project: Project) -> Self:
@@ -68,28 +105,45 @@ class JetBrainsPluginClient(ToStringMixin):
for port in range(cls.BASE_PORT, cls.BASE_PORT + 20):
client = JetBrainsPluginClient(port)
if client.matches(resolved_path):
log.info("Found JetBrains IDE service at port %d for project %s", port, resolved_path)
log.info("Found matching %s", client)
cls.last_port = port
return client
raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path))
def matches(self, resolved_path: Path) -> bool:
try:
return Path(self.project_root()).resolve() == resolved_path
except ConnectionError:
if self._project_root is None:
return False
return Path(self._project_root).resolve() == resolved_path
def is_version_at_least(self, *version_parts: int) -> bool:
if self._plugin_version is None:
return False
return self._plugin_version.is_at_least(*version_parts)
def _require_version_at_least(self, *version_parts: int) -> None:
"""
Ensures that the plugin version is at least the given version and raises an error otherwise.
:param version_parts: the minimum required version parts (major, minor, patch)
"""
if not self.is_version_at_least(*version_parts):
raise SerenaClientError(
f"This operation requires Serena JetBrains plugin version "
f"{'.'.join(map(str, version_parts))} or higher, but the installed version is "
f"{self._plugin_version}. Ask the user to update the plugin!"
)
def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
url = f"{self.base_url}{endpoint}"
url = f"{self._base_url}{endpoint}"
response: Response | None = None
try:
if method.upper() == "GET":
response = self.session.get(url, timeout=self.timeout)
response = self._session.get(url, timeout=self._timeout)
elif method.upper() == "POST":
json_data = json.dumps(data) if data else None
response = self.session.post(url, data=json_data, timeout=self.timeout)
response = self._session.post(url, data=json_data, timeout=self._timeout)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@@ -108,8 +162,18 @@ class JetBrainsPluginClient(ToStringMixin):
raise ConnectionError(f"Request to {url} timed out: {e}")
except requests.exceptions.HTTPError as e:
if response is not None:
raise APIError(f"API request failed with status {response.status_code}: {response.text}")
raise APIError(f"API request failed with HTTP error: {e}")
# check for recoverable error (i.e. errors where the problem can be resolved by the caller or
# other errors where the error text shall simply be passed on to the LLM).
# The plugin returns 400 for such errors (typically illegal arguments, e.g. non-unique name path)
# but only since version 2023.2.6
if self.is_version_at_least(2023, 2, 6):
is_recoverable_error = response.status_code == 400
else:
is_recoverable_error = True # assume recoverable for older versions (mix of errors)
if is_recoverable_error:
raise APIError(response)
raise PluginServerError(f"API request failed with status {response.status_code}: {response.text}")
raise PluginServerError(f"API request failed with HTTP error: {e}")
except requests.exceptions.RequestException as e:
raise SerenaClientError(f"Request failed: {e}")
@@ -132,30 +196,52 @@ class JetBrainsPluginClient(ToStringMixin):
return convert(response)
def project_root(self) -> str:
response = self._make_request("GET", "/status")
return response["project_root"]
def _postprocess_symbol_collection_response(self, response_dict: jb.SymbolCollectionResponse) -> None:
"""
Postprocesses a symbol collection response in-place, converting HTML documentation to plain text.
:param response_dict: the response dictionary
"""
def convert_html(key: Literal["documentation", "quick_info"], symbol: jb.SymbolDTO) -> None:
if key in symbol:
doc_html: str = symbol[key]
doc_text = render_html(doc_html)
if doc_text:
symbol[key] = doc_text
else:
del symbol[key]
# convert documentation and quick info from HTML to plain text (if present)
symbols = response_dict["symbols"]
for s in symbols:
convert_html("documentation", s)
convert_html("quick_info", s)
def find_symbol(
self,
name_path: str,
relative_path: str | None = None,
include_body: bool = False,
include_quick_info: bool = False,
include_documentation: bool = False,
include_num_usages: bool = False,
depth: int = 0,
include_location: bool = False,
search_deps: bool = False,
) -> dict[str, Any]:
) -> jb.SymbolCollectionResponse:
"""
Finds symbols by name.
:param name_path: the name path to match
:param relative_path: the relative path to which to restrict the search
:param include_body: whether to include symbol body content
:param include_quick_info: whether to include quick info
:param include_documentation: whether to include documentation
:param include_num_usages: whether to include number of usages
:param depth: depth of children to include (0 = no children)
:param include_location: whether to include symbol location information
:param search_deps: whether to also search in dependencies
:return: Dictionary containing 'symbols' list with matching symbols
"""
request_data = {
"namePath": name_path,
@@ -164,27 +250,92 @@ class JetBrainsPluginClient(ToStringMixin):
"depth": depth,
"includeLocation": include_location,
"searchDeps": search_deps,
"includeQuickInfo": include_quick_info,
"includeDocumentation": include_documentation,
"includeNumUsages": include_num_usages,
}
return self._make_request("POST", "/findSymbol", request_data)
symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data))
self._postprocess_symbol_collection_response(symbol_collection)
return symbol_collection
def find_references(self, name_path: str, relative_path: str) -> dict[str, Any]:
def find_references(self, name_path: str, relative_path: str, include_quick_info: bool) -> jb.SymbolCollectionResponse:
"""
Finds references to a symbol.
:param name_path: the name path of the symbol
:param relative_path: the relative path
:return: dictionary containing 'symbols' list with symbol references
:param include_quick_info: whether to include quick info about references
"""
request_data = {"namePath": name_path, "relativePath": relative_path}
return self._make_request("POST", "/findReferences", request_data)
request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info}
symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data))
self._postprocess_symbol_collection_response(symbol_collection)
return symbol_collection
def get_symbols_overview(self, relative_path: str, depth: int) -> dict[str, Any]:
def get_symbols_overview(
self, relative_path: str, depth: int, include_file_documentation: bool = False
) -> jb.GetSymbolsOverviewResponse:
"""
:param relative_path: the relative path to a source file
:param depth: the depth of children to include (0 = no children)
:param include_file_documentation: whether to include the file's documentation string (if any)
"""
request_data = {"relativePath": relative_path, "depth": depth}
return self._make_request("POST", "/getSymbolsOverview", request_data)
request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation}
response = cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data))
self._postprocess_symbol_collection_response(response)
# process file documentation
if "documentation" in response:
response["documentation"] = render_html(response["documentation"])
return response
def get_supertypes(
self,
name_path: str,
relative_path: str,
depth: int | None = None,
limit_children: int | None = None,
) -> jb.TypeHierarchyResponse:
"""
Gets the supertypes (parent classes/interfaces) of a symbol.
:param name_path: the name path of the symbol
:param relative_path: the relative path to the file containing the symbol
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited)
:param limit_children: optional limit on children per level
"""
self._require_version_at_least(2023, 2, 6)
request_data = {
"namePath": name_path,
"relativePath": relative_path,
"depth": depth,
"limitChildren": limit_children,
}
return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSupertypes", request_data))
def get_subtypes(
self,
name_path: str,
relative_path: str,
depth: int | None = None,
limit_children: int | None = None,
) -> jb.TypeHierarchyResponse:
"""
Gets the subtypes (subclasses/implementations) of a symbol.
:param name_path: the name path of the symbol
:param relative_path: the relative path to the file containing the symbol
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited)
:param limit_children: optional limit on children per level
"""
self._require_version_at_least(2023, 2, 6)
request_data = {
"namePath": name_path,
"relativePath": relative_path,
"depth": depth,
"limitChildren": limit_children,
}
return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSubtypes", request_data))
def rename_symbol(
self, name_path: str, relative_path: str, new_name: str, rename_in_comments: bool, rename_in_text_occurrences: bool
@@ -218,15 +369,8 @@ class JetBrainsPluginClient(ToStringMixin):
}
self._make_request("POST", "/refreshFile", request_data)
def is_service_available(self) -> bool:
try:
self.project_root()
return True
except (ConnectionError, APIError):
return False
def close(self) -> None:
self.session.close()
self._session.close()
def __enter__(self) -> Self:
return self
+119 -7
View File
@@ -1,9 +1,13 @@
import logging
from collections import defaultdict
from typing import Any
from typing import Any, Literal
import serena.tools.jetbrains_types as jb
from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead
from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient
log = logging.getLogger(__name__)
class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
@@ -16,6 +20,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
search_deps: bool = False,
max_answer_chars: int = -1,
) -> str:
@@ -46,6 +51,9 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol (ignored if include_body is True).
Default False; info is never included for child symbols and is not included when body is requested.
:param search_deps: If True, also search in project dependencies (e.g., libraries).
: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.
@@ -54,11 +62,15 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
if relative_path == ".":
relative_path = None
with JetBrainsPluginClient.from_project(self.project) as client:
include_documentation = include_info and not include_body
include_quick_info = not include_documentation and not include_info
response_dict = client.find_symbol(
name_path=name_path_pattern,
relative_path=relative_path,
depth=depth,
include_body=include_body,
include_documentation=include_documentation,
include_quick_info=include_quick_info,
search_deps=search_deps,
)
result = self._to_json(response_dict)
@@ -70,6 +82,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
Finds symbols that reference the given symbol using the JetBrains backend
"""
# TODO: (maybe) - add content snippets showing the references like in LS based version?
def apply(
self,
name_path: str,
@@ -91,6 +104,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
response_dict = client.find_references(
name_path=name_path,
relative_path=relative_path,
include_quick_info=False,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
@@ -102,7 +116,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
"""
@staticmethod
def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]:
def _transform_symbols_to_compact_format(symbols: list[jb.SymbolDTO]) -> dict[str, list]:
"""
Transform symbol overview from verbose format to compact grouped format.
@@ -128,7 +142,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
result[kind].append({name: children_dict})
else:
# Symbol has no children: just add the name
result[kind].append(name)
result[kind].append(name) # type: ignore
return result
@@ -152,8 +166,106 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
:return: a JSON object containing the symbols grouped by kind in a compact format.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.get_symbols_overview(relative_path=relative_path, depth=depth)
symbols = response_dict["symbols"]
compact_symbols = self._transform_symbols_to_compact_format(symbols)
result = self._to_json(compact_symbols)
symbol_overview = client.get_symbols_overview(relative_path=relative_path, depth=depth, include_file_documentation=False)
symbols = symbol_overview["symbols"]
result: dict[str, Any] = {"symbols": self._transform_symbols_to_compact_format(symbols)}
documentation = symbol_overview.pop("documentation", None)
if documentation:
result["docstring"] = documentation
json_result = self._to_json(result)
return self._limit_length(json_result, max_answer_chars)
class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend
"""
@staticmethod
def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]:
"""
Transform a list of TypeHierarchyNode into a file-grouped compact format.
Returns a dict where keys are relative_paths and values are lists of either:
- "SymbolNamePath" (leaf node)
- {"SymbolNamePath": {nested_file_grouped_children}} (node with children)
"""
if not nodes:
return {}
result: dict[str, list] = {}
for node in nodes:
symbol = node["symbol"]
name_path = symbol["name_path"]
rel_path = symbol["relative_path"]
children = node.get("children", [])
if rel_path not in result:
result[rel_path] = []
if children:
# Node with children - recurse
nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children)
result[rel_path].append({name_path: nested})
else:
# Leaf node
result[rel_path].append(name_path)
return result
def apply(
self,
name_path: str,
relative_path: str,
hierarchy_type: Literal["super", "sub", "both"] = "both",
depth: int | None = 1,
max_answer_chars: int = -1,
) -> str:
"""
Gets the type hierarchy of a symbol (supertypes, subtypes, or both).
:param name_path: name path of the symbol for which to get the type hierarchy.
:param relative_path: the relative path to the file containing the symbol.
:param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces,
"sub" for subclasses/implementations, or "both" for both directions. Default is "sub".
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1.
: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: Compact JSON with file-grouped hierarchy. Error string if not applicable.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
subtypes = None
supertypes = None
levels_not_included = {}
if hierarchy_type in ("super", "both"):
supertypes_response = client.get_supertypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in supertypes_response:
levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"]
supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy"))
if hierarchy_type in ("sub", "both"):
subtypes_response = client.get_subtypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in subtypes_response:
levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"]
subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy"))
result_dict: dict[str, dict | list] = {}
if supertypes is not None:
result_dict["supertypes"] = supertypes
if subtypes is not None:
result_dict["subtypes"] = subtypes
if levels_not_included:
result_dict["levels_not_included"] = levels_not_included
result = self._to_json(result_dict)
return self._limit_length(result, max_answer_chars)
+50
View File
@@ -0,0 +1,50 @@
from typing import NotRequired, TypedDict
class PluginStatusDTO(TypedDict):
project_root: str
plugin_version: str
class PositionDTO(TypedDict):
line: int
col: int
class TextRangeDTO(TypedDict):
start_pos: PositionDTO
end_pos: PositionDTO
class SymbolDTO(TypedDict):
name_path: str
relative_path: str
type: str
body: NotRequired[str]
quick_info: NotRequired[str]
"""Quick info text (e.g., type signature) for the symbol, as HTML string."""
documentation: NotRequired[str]
"""Documentation text for the symbol (if available), as HTML string."""
text_range: NotRequired[TextRangeDTO]
children: NotRequired[list["SymbolDTO"]]
num_usages: NotRequired[int]
class SymbolCollectionResponse(TypedDict):
symbols: list[SymbolDTO]
class GetSymbolsOverviewResponse(SymbolCollectionResponse):
documentation: NotRequired[str]
"""Docstring of the collection (if applicable - usually present only if the collection is from a single file),
as HTML string."""
class TypeHierarchyNodeDTO(TypedDict):
symbol: SymbolDTO
children: NotRequired[list["TypeHierarchyNodeDTO"]]
class TypeHierarchyResponse(TypedDict):
hierarchy: NotRequired[list[TypeHierarchyNodeDTO]]
num_levels_not_included: NotRequired[int]
+17 -7
View File
@@ -122,6 +122,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
depth: int = 0,
relative_path: str = "",
include_body: bool = False,
include_info: bool = True,
include_kinds: list[int] = [], # noqa: B006
exclude_kinds: list[int] = [], # noqa: B006
substring_matching: bool = False,
@@ -146,18 +147,16 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Default 0.
for the case where the symbol is a class, this will return its methods). Default 0.
:param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file.
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param include_kinds: Optional. List of LSP symbol kind integers to include. (e.g., 5 for Class, 12 for Function).
Valid kinds: 1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum,
11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object,
20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter.
:param include_body: whether to include the symbol's source code. Use judiciously.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol (ignored if include_body is True). Default True, info is never included for child symbols.
:param include_kinds: List of LSP symbol kind integers to include.
If not provided, all kinds are included.
:param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`.
If not provided, no kinds are excluded.
@@ -178,6 +177,12 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
within_relative_path=relative_path,
)
symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols]
if not include_body and include_info:
# we add an info field to the symbol dicts if requested
for s, s_dict in zip(symbols, symbol_dicts, strict=True):
if symbol_info := symbol_retriever.request_info_for_symbol(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)
@@ -192,6 +197,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
self,
name_path: str,
relative_path: str,
include_info: bool = True,
include_kinds: list[int] = [], # noqa: B006
exclude_kinds: list[int] = [], # noqa: B006
max_answer_chars: int = -1,
@@ -203,6 +209,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
:param name_path: for finding the symbol to find references 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 references.
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 referencing symbols. Default True.
: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.
@@ -226,6 +234,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
if not include_body:
ref_relative_path = ref.symbol.location.relative_path
assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug."
if include_info and (referencing_symbol_info := symbol_retriever.request_info_for_symbol(ref.symbol)):
ref_dict["info"] = referencing_symbol_info
content_around_ref = self.project.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1
)
+85
View File
@@ -0,0 +1,85 @@
class Version:
"""
Represents a version, specifically the numeric components of a version string.
Suffixes like "rc1" or "-dev" are ignored, i.e. for a version string like "1.2.3rc1",
the components are [1, 2, 3].
"""
def __init__(self, package_or_version: object | str):
"""
:param package_or_version: a package object (with a `__version__` attribute) or a version string like "1.2.3".
If a version contains a suffix (like "1.2.3rc1" or "1.2.3-dev"), the suffix is ignored.
"""
if isinstance(package_or_version, str):
version_string = package_or_version
elif hasattr(package_or_version, "__version__"):
package_version_string = getattr(package_or_version, "__version__", None)
if package_version_string is None:
raise ValueError(f"The given package object {package_or_version} has no __version__ attribute")
version_string = package_version_string
else:
raise ValueError("The given argument must be either a version string or a package object with a __version__ attribute")
self.version_string = version_string
self.components = self._get_version_components(version_string)
def __str__(self) -> str:
return self.version_string
@staticmethod
def _get_version_components(version_string: str) -> list[int]:
components = version_string.split(".")
int_components = []
for c in components:
num_str = ""
for ch in c:
if ch.isdigit():
num_str += ch
else:
break
if num_str == "":
break
int_components.append(int(num_str))
return int_components
def is_at_least(self, *components: int) -> bool:
"""
Checks this version against the given version components.
This version object must contain at least the respective number of components
:param components: version components in order (i.e. major, minor, patch, etc.)
:return: True if the version is at least the given version, False otherwise
"""
for i, desired_min_version in enumerate(components):
actual_version = self.components[i]
if actual_version < desired_min_version:
return False
elif actual_version > desired_min_version:
return True
return True
def is_at_most(self, *components: int) -> bool:
"""
Checks this version against the given version components.
This version object must contain at least the respective number of components
:param components: version components in order (i.e. major, minor, patch, etc.)
:return: True if the version is at most the given version, False otherwise
"""
for i, desired_max_version in enumerate(components):
actual_version = self.components[i]
if actual_version > desired_max_version:
return False
elif actual_version < desired_max_version:
return True
return True
def is_equal(self, *components: int) -> bool:
"""
Checks this version against the given version components.
This version object must contain at least the respective number of components
:param components: version components in order (i.e. major, minor, patch, etc.)
:return: True if the version is the given version, False otherwise
"""
return self.components[: len(components)] == list(components)
+58 -11
View File
@@ -10,10 +10,12 @@ import shutil
import threading
import uuid
from pathlib import PurePath
from time import sleep
from typing import cast
from overrides import override
from solidlsp import ls_types
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_types import UnifiedSymbolInformation
@@ -144,9 +146,9 @@ class EclipseJDTLS(SolidLanguageServer):
f"{data_dir}",
]
self.service_ready_event = threading.Event()
self.intellicode_enable_command_available = threading.Event()
self.initialize_searcher_command_available = threading.Event()
self._service_ready_event = threading.Event()
self._project_ready_event = threading.Event()
self._intellicode_enable_command_available = threading.Event()
super().__init__(
config, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings
@@ -612,6 +614,7 @@ class EclipseJDTLS(SolidLanguageServer):
"maven": {"downloadSources": True, "updateSnapshots": False},
"eclipse": {"downloadSources": True},
"signatureHelp": {"enabled": True, "description": {"enabled": True}},
"hover": {"javadoc": {"enabled": True}},
"implementationsCodeLens": {"enabled": True},
"format": {
"enabled": True,
@@ -721,15 +724,15 @@ class EclipseJDTLS(SolidLanguageServer):
self.completions_available.set()
if registration["method"] == "workspace/executeCommand":
if "java.intellicode.enable" in registration["registerOptions"]["commands"]:
self.intellicode_enable_command_available.set()
self._intellicode_enable_command_available.set()
return
def lang_status_handler(params: dict) -> None:
# TODO: Should we wait for
# server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}}
# Before proceeding?
if params["type"] == "ServiceReady" and params["message"] == "ServiceReady":
self.service_ready_event.set()
self._service_ready_event.set()
if params["type"] == "ProjectStatus":
if params["message"] == "OK":
self._project_ready_event.set()
def execute_client_command_handler(params: dict) -> list:
assert params["command"] == "_java.reloadBundles.command"
@@ -764,7 +767,7 @@ class EclipseJDTLS(SolidLanguageServer):
self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore
self.intellicode_enable_command_available.wait()
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)
@@ -776,8 +779,52 @@ class EclipseJDTLS(SolidLanguageServer):
)
assert intellicode_enable_result
# TODO: Add comments about why we wait here, and how this can be optimized
self.service_ready_event.wait()
self._service_ready_event.wait()
self._project_ready_event.wait()
@override
def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None:
# Eclipse JDTLS lazily loads javadocs on first hover request, then caches them.
# This means the first request often returns incomplete info (just the signature),
# while subsequent requests return the full javadoc.
#
# The response format also differs based on javadoc presence:
# - contents: list[...] when javadoc IS present (preferred, richer format)
# - contents: {value: info} when javadoc is NOT present
#
# There's no LSP signal for "javadoc fully loaded" and no way to request
# hover with "wait for complete info". The retry approach is the only viable
# workaround - we keep requesting until we get the richer list format or
# the content stops growing.
#
# The file is kept open by the caller (request_hover), so retries are cheap
# and don't cause repeated didOpen/didClose cycles.
def content_score(result: ls_types.Hover | None) -> tuple[int, int]:
"""Return (format_priority, length) for comparison. Higher is better."""
if result is None:
return (0, 0)
contents = result["contents"]
if isinstance(contents, list):
return (2, len(contents)) # List format (has javadoc) is best
elif isinstance(contents, dict):
return (1, len(contents.get("value", "")))
else:
return (1, len(contents))
max_retries = 5
best_result = super()._request_hover(uri, line, column)
best_score = content_score(best_result)
for _ in range(max_retries):
sleep(0.05)
new_result = super()._request_hover(uri, line, column)
new_score = content_score(new_result)
if new_score > best_score:
best_result = new_result
best_score = new_score
return best_result
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
+48 -2
View File
@@ -1322,11 +1322,57 @@ class SolidLanguageServer(ABC):
:param relative_file_path: The relative path of the file that has the hover information
:param line: The line number of the symbol
:param column: The column number of the symbol
"""
with self.open_file(relative_file_path):
uri = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
return self._request_hover(uri, line, column)
def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None:
"""
Internal method that performs the actual hover request.
The file must already be open when calling this method.
Subclasses can override this to customize hover behavior (e.g., retries).
:param uri: The URI of the file
:param line: The line number of the symbol
:param column: The column number of the symbol
"""
response = self.server.send.hover(
{
"textDocument": {"uri": uri},
"position": {
"line": line,
"character": column,
},
}
)
if response is None:
return None
assert isinstance(response, dict)
contents = response.get("contents")
if not contents:
return None
if isinstance(contents, dict) and not contents.get("value"):
return None
return ls_types.Hover(**response) # type: ignore
def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None:
"""
Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp)
request to the Language Server to find the signature help at the given line and column in the given file.
Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition.
This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured).
:param relative_file_path: The relative path of the file that has the signature help
:param line: The line number of the symbol
:param column: The column number of the symbol
:return None
"""
with self.open_file(relative_file_path):
response = self.server.send.hover(
response = self.server.send.signature_help(
{
"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()},
"position": {
@@ -1341,7 +1387,7 @@ class SolidLanguageServer(ABC):
assert isinstance(response, dict)
return ls_types.Hover(**response) # type: ignore
return ls_types.SignatureHelp(**response) # type: ignore
def retrieve_symbol_body(
self,
+72
View File
@@ -371,3 +371,75 @@ class Diagnostic(TypedDict):
""" The code of the diagnostic. """
source: NotRequired[str]
""" The source of the diagnostic, e.g. the name of the tool that produced it. """
class SignatureHelp(TypedDict):
"""
Signature help represents the signature of something
callable. There can be multiple signature but only one
active and only one active parameter.
See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#signatureHelp
"""
signatures: list[SignatureInformation]
""" One or more signatures. """
activeSignature: NotRequired[int]
""" The active signature. If omitted or the value lies outside the
range of `signatures` the value defaults to zero or is ignored if
the `SignatureHelp` has no signatures.
Whenever possible implementers should make an active decision about
the active signature and shouldn't rely on a default value.
In future version of the protocol this property might become
mandatory to better express this. """
activeParameter: NotRequired[int]
""" The active parameter of the active signature. If omitted or the value
lies outside the range of `signatures[activeSignature].parameters`
defaults to 0 if the active signature has parameters. If
the active signature has no parameters it is ignored.
In future version of the protocol this property might become
mandatory to better express the active parameter if the
active signature does have any. """
class SignatureInformation(TypedDict):
"""Represents the signature of something callable. A signature
can have a label, like a function-name, a doc-comment, and
a set of parameters.
"""
label: str
""" The label of this signature. Will be shown in
the UI. """
documentation: NotRequired[MarkupContent | str]
""" The human-readable doc-comment of this signature. Will be shown
in the UI but can be omitted. """
parameters: NotRequired[list[ParameterInformation]]
""" The parameters of this signature. """
activeParameter: NotRequired[int]
""" The index of the active parameter.
If provided, this is used in place of `SignatureHelp.activeParameter`.
@since 3.16.0 """
class ParameterInformation(TypedDict):
"""Represents a parameter of a callable-signature. A parameter can
have a label and a doc-comment.
"""
label: str | list[int]
""" The label of this parameter information.
Either a string or an inclusive start and exclusive end offsets within its containing
signature label. (see SignatureInformation.label). The offsets are based on a UTF-16
string representation as `Position` and `Range` does.
*Note*: a label of type string should be a substring of its containing signature label.
Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """
documentation: NotRequired[MarkupContent | str]
""" The human-readable doc-comment of this parameter. Will be shown
in the UI but can be omitted. """
@@ -1,5 +1,8 @@
package test_repo;
/**
* A simple model class that holds a name and provides methods to retrieve it.
*/
public class Model {
private String name;
+16 -2
View File
@@ -14,6 +14,7 @@ from serena.config.serena_config import ProjectConfig, RegisteredProject, Serena
from serena.project import Project
from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.conftest import get_repo_path, language_tests_enabled
from test.solidlsp import clojure as clj
@@ -120,7 +121,7 @@ class TestSerenaAgent:
],
indirect=["serena_agent"],
)
def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str):
def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str):
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name)
@@ -130,6 +131,19 @@ class TestSerenaAgent:
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
for s in symbols:
if s["kind"] == SymbolKind.File.name:
# we ignore file 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']}"
# 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.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
@@ -172,7 +186,7 @@ class TestSerenaAgent:
],
indirect=["serena_agent"],
)
def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str) -> None:
def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
agent = serena_agent
# Find the symbol location first
+13 -1
View File
@@ -1,6 +1,8 @@
import pytest
from serena.symbol import NamePathMatcher
from serena.symbol import LanguageServerSymbolRetriever, NamePathMatcher
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
class TestSymbolNameMatching:
@@ -176,3 +178,13 @@ class TestSymbolNameMatching:
result = matcher.matches_components(symbol_name_path_parts, symbol_overload_idx)
error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, False, expected, result)
assert result == expected, error_msg
@pytest.mark.python
class TestLanguageServerSymbolRetriever:
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
def test_request_info(self, language_server: SolidLanguageServer):
symbol_retriever = LanguageServerSymbolRetriever(language_server)
create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0]
create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol)
assert "Create a new user and store it" in create_user_method_symbol_info
Generated
+2
View File
@@ -1933,6 +1933,7 @@ version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "beautifulsoup4" },
{ name = "docstring-parser" },
{ name = "dotenv" },
{ name = "flask" },
@@ -1988,6 +1989,7 @@ google = [
requires-dist = [
{ name = "agno", marker = "extra == 'agno'", specifier = ">=2.2.1" },
{ name = "anthropic", specifier = ">=0.54.0" },
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
{ name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" },
{ name = "docstring-parser", specifier = ">=0.16" },
{ name = "dotenv", specifier = ">=0.9.9" },