diff --git a/pyproject.toml b/pyproject.toml index 53bb740d..e83c5d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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' \ No newline at end of file +ignore-words-list = 'paket' diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 8cbaae79..bad7ed60 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -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...") diff --git a/src/serena/agent.py b/src/serena/agent.py index 02d44079..547c8a5e 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -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() diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 8f3681bf..6fdf0c24 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -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(): diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 7af49efe..863f3beb 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -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 diff --git a/src/serena/symbol.py b/src/serena/symbol.py index ecc13353..ad807a95 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -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. """ diff --git a/src/serena/text_utils.py b/src/serena/text_utils.py index d87e9ad6..c36dce78 100644 --- a/src/serena/text_utils.py +++ b/src/serena/text_utils.py @@ -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() diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 1695e4db..0bbefddc 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -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 diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 6407de7c..9e716616 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -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) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py new file mode 100644 index 00000000..80f02947 --- /dev/null +++ b/src/serena/tools/jetbrains_types.py @@ -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] diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index bcd244a5..7571cc9f 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -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 ) diff --git a/src/serena/util/version.py b/src/serena/util/version.py new file mode 100644 index 00000000..d8d8381f --- /dev/null +++ b/src/serena/util/version.py @@ -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) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d058521e..3d516c58 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -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 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index be66e36c..e5e9e785 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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, diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index e8899602..06e6ae9b 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -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. """ diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java index 5a6a044e..8834f3ca 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java @@ -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; diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 761affc2..52055975 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -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 diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index d7dc772d..77558ecd 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -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 diff --git a/uv.lock b/uv.lock index e8083592..c8bbfb14 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },