diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 430c8c2a..dfe4fe47 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -57,6 +57,16 @@ class CodeEditor(Generic[TSymbol], ABC): """ raise NotImplementedError("This method must be overridden for each subclass") + def read_file(self, relative_path: str) -> str: + """ + Reads the content of a file. + + :param relative_path: the relative path of the file to read + :return: the content of the file + """ + with self._open_file_context(relative_path) as file: + return file.get_contents() + @contextmanager def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: """ diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index b79b226a..52d21516 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -587,6 +587,29 @@ class JetBrainsPluginClient(ToStringMixin): } self._make_request("POST", "/refreshFile", request_data) + def find_declaration( + self, relative_path: str, line: int, col: int, include_body: bool, include_quick_info: bool + ) -> jb.SymbolCollectionResponse: + """ + Finds the declaration of the symbol at the given location. + + :param relative_path: the relative path to the file + :param line: the line number (0-based) + :param col: the column number (0-based) + :param include_body: whether to include the symbol body + :param include_quick_info: whether to include quick info about the symbol + """ + request_data = { + "relativePath": relative_path, + "line": line, + "col": col, + "includeBody": include_body, + "includeQuickInfo": include_quick_info, + } + symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findDeclaration", request_data)) + self._postprocess_symbol_collection_response(symbol_collection) + return symbol_collection + def close(self) -> None: self._session.close() diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index f96bcd67..7a943922 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -7,6 +7,7 @@ from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.jetbrains.jetbrains_types import SymbolDTO from serena.symbol import JetBrainsSymbolDictGrouper from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead +from serena.util.text_utils import find_text_coordinates log = logging.getLogger(__name__) @@ -416,3 +417,32 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona result = self._to_json(result_dict) return self._limit_length(result, max_answer_chars) + + +class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the declaration of a symbol using the JetBrains backend + """ + + def apply(self, relative_path: str, regex: str, include_body: bool = False) -> str: + r""" + Finds the declaration of a symbol. + + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(\)". + Provide as much context as necessary to render the regex unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. + :param include_body: whether to include the symbol's body in the result. Default False. + """ + editor = self.create_code_editor() + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + with JetBrainsPluginClient.from_project(self.project) as client: + symbol_collection = client.find_declaration( + relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body + ) + result = self._to_json(symbol_collection) + return result diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index e3c63e77..20c7b0b7 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup from joblib import Parallel, delayed from serena.constants import DEFAULT_SOURCE_FILE_ENCODING +from solidlsp.ls_utils import TextUtils log = logging.getLogger(__name__) @@ -523,3 +524,43 @@ class ContentReplacer: "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." ) return updated_content + + +@dataclass +class TextCoords: + line: int + """ + 0-based line number + """ + col: int + """ + 0-based column number + """ + + +def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoords | None: + """ + Finds the line and column number of the first match of a regex pattern in the given content. + + :param content: the text content to search through + :param regex: the regular expression pattern to search for; it must match part of a single line, + and contain exactly one group that captures the position of interest (e.g., the exact variable name to find the coordinates of) + :param require_unique: if True, raises an error if not exactly one match is found; + if False, returns None if no match is found, and returns the coordinates of the first match if multiple matches are found + :return: the coordinates of the match or None + """ + pattern = re.compile(regex, flags=re.MULTILINE | re.DOTALL) + matches = list(pattern.finditer(content)) + if len(matches) == 0: + if require_unique: + raise ValueError(f"No match found for regex: {regex}") + return None + else: + if require_unique and len(matches) > 1: + raise ValueError(f"Match must be unique; found {len(matches)} matches for regex: {regex}") + match = matches[0] + if len(match.groups()) != 1: + raise ValueError(f"Regex must contain exactly one group to capture the position, but found {len(match.groups())} groups.") + index_in_content = match.start(1) + line, col = TextUtils.get_line_col_from_index(content, index_in_content) + return TextCoords(line, col)