mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 08:15:59 +00:00
Merge branch 'feature/docstring_and_signature' into add_news_display
# Conflicts: # src/serena/code_editor.py
This commit is contained in:
commit
f3a508b7f2
19 files changed
+750
-83
No files matched your search
+2
-1
@@ -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'
|
||||
@@ -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...")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user