From 66ed46bef44430ed2d1a568b146c3e0aca4ebaf4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 7 Sep 2026 12:02:03 +0200 Subject: [PATCH] Add editing facade (edit) covering the backend-independent edit operations Introduce EditApi as the facade for editing operations which depend only on the code editor abstraction and thus work with any language backend: create_text_file, replace_content, replace_in_files, the optional line-level operations delete_lines, replace_lines and insert_at_line, and the symbol-level operations replace_symbol_body, insert_after_symbol and insert_before_symbol (moved from LspApi, which retains the LSP-specific rename_symbol and safe_delete_symbol). replace_in_files builds on MultiFileReplacement: a dry run returns a ReplacementPreview, which exposes the occurrences to code and renders the listing of prospective changes; rejections raise a ValueError carrying the listing where applicable. FacadeApi gains a backend-agnostic _create_code_editor; the LSP API's retriever-based factory is renamed to _create_ls_code_editor. The editing tools (file and symbol level) delegate to EditApi via EditApiMixin, retaining only the diagnostics context. The edit facade is always part of the REPL. --- src/serena/agent.py | 3 +- src/serena/facades/api/edit.py | 318 ++++++++++++++++++++++++++++ src/serena/facades/api/lsp.py | 56 +---- src/serena/facades/facade.py | 17 ++ src/serena/facades/representable.py | 11 +- src/serena/tools/file_tools.py | 129 ++++------- src/serena/tools/symbol_tools.py | 12 +- test/serena/test_edit_api.py | 81 +++++++ 8 files changed, 478 insertions(+), 149 deletions(-) create mode 100644 src/serena/facades/api/edit.py create mode 100644 test/serena/test_edit_api.py diff --git a/src/serena/agent.py b/src/serena/agent.py index cab5d93d..dbab9d56 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -42,6 +42,7 @@ from serena.config.serena_config import ( ToolInclusionDefinition, ) from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser +from serena.facades.api.edit import EditApi from serena.facades.api.jb import JetBrainsApi from serena.facades.api.lsp import LspApi from serena.facades.facade import ApiScope, Facade @@ -1183,7 +1184,7 @@ class SerenaAgent: api_scope.exclude_editing() # gather facades - facades = [] + facades = [Facade.from_api(EditApi(self), api_scope)] if self._language_backend.is_lsp(): facades.append(Facade.from_api(LspApi(self), api_scope)) elif self._language_backend.is_jetbrains(): diff --git a/src/serena/facades/api/edit.py b/src/serena/facades/api/edit.py new file mode 100644 index 00000000..815a8da0 --- /dev/null +++ b/src/serena/facades/api/edit.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +""" +The implementation of editing operations, which are independent of the language backend. +""" + +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +from serena.code_editor import EditedFileContext +from serena.util.text_utils import ContentReplacer, MultiFileReplacement, ReplacementOccurrence, ReplacementRejectedError + +from ..facade import SUCCESS_RESULT, FacadeApi, facade_method +from ..representable import Renderer, RepresentableViaRenderer + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + + +class ReplacementPreview(RepresentableViaRenderer): + """ + The prospective changes of a multi-file replacement (nothing has been modified). + Each entry of `occurrences` (`ReplacementOccurrence`) has an `occurrence_id` (to be passed to `replace_in_files` + in order to apply exactly that occurrence), `relative_path`, `start_line`, `end_line`, `matched_text`, `replacement` + and `is_ambiguous`. + """ + + def __init__(self, replacement: MultiFileReplacement, renderer: "ReplacementPreviewRenderer"): + """ + :param replacement: the replacement + :param renderer: the renderer to use for representing the preview + """ + super().__init__(renderer) + self.replacement_ = replacement + + @property + def occurrences(self) -> list[ReplacementOccurrence]: + return self.replacement_.occurrences + + @property + def affected_files(self) -> list[str]: + return self.replacement_.affected_files + + +class ReplacementPreviewRenderer(Renderer[ReplacementPreview]): + """ + Renders the listing of prospective changes (minimal line diffs with occurrence ids), subject to the length limit. + """ + + def __init__(self, agent: "SerenaAgent", max_answer_chars: int, dry_run: bool): + """ + :param agent: the agent + :param max_answer_chars: the maximum number of characters; -1 for the configured default + :param dry_run: whether the listing is the result of a dry run (adding instructions on how to proceed) + """ + super().__init__(agent, max_answer_chars) + self._dry_run = dry_run + + def render(self, obj: ReplacementPreview) -> str: + return obj.replacement_.render_listing(self._get_max_answer_chars(), dry_run=self._dry_run) + + +class EditApi(FacadeApi): + def __init__(self, agent: "SerenaAgent") -> None: + super().__init__( + agent, + name="edit", + description="editing operations independent of the language backend (creating files, replacing content in one or " + "many files, editing at the symbol or line level)", + ) + + # file-level operations + + @facade_method(can_edit=True) + def create_text_file(self, relative_path: str, content: str) -> str: + """ + Writes a new file or overwrites an existing file with the given content. + + :param relative_path: the relative path to the file to create + :param content: the (appropriately encoded) content to write to the file + :return: a message indicating success + """ + project = self._get_project() + project_root = Path(project.project_root) + abs_path = (project_root / relative_path).resolve() + will_overwrite_existing = abs_path.exists() + + # validate the destination path + if will_overwrite_existing: + project.validate_relative_path(relative_path) + else: + assert abs_path.is_relative_to(project_root), f"Cannot create file outside of the project directory, got {relative_path=}" + + # write the file + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding=project.project_config.encoding, newline=project.line_ending.newline_str) + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." + return answer + + @facade_method(can_edit=True) + def replace_content( + self, + relative_path: str, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + allow_multiple_occurrences: bool = False, + ) -> str: + r""" + Replaces one or more occurrences of a given pattern in a file with new content. + + VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced WITHOUT + quoting them fully: use a needle of the form "beginning.*?end-of-text-to-be-replaced" with + wildcards instead of pasting the exact original text — shorter, cheaper, and you cannot make + mistakes, because an ambiguous match returns an error you can refine, so wildcards are safe. + Prefer regex mode with suitable wildcards for long multi-line replacements; use the + symbol-level editors when replacing a whole method/class. + + :param relative_path: the relative path to the file + :param needle: the string or regex pattern to search for. + If `mode` is "literal", this string will be matched exactly. + If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, + with flags DOTALL and MULTILINE enabled). + :param repl: the replacement string (verbatim). + If mode is "regex", the string can contain backreferences to matched groups in the needle regex, + specified using the syntax $!1, $!2, etc. for groups 1, 2, etc. + :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. + :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. + If false and multiple occurrences are found, an error will be raised + :return: a success message + """ + self._get_project().validate_relative_path(relative_path) + with EditedFileContext(relative_path, self._create_code_editor()) as context: + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + context.set_updated_content(replacer.replace(context.get_original_content(), needle, repl)) + return SUCCESS_RESULT + + @facade_method(can_edit=True) + def replace_in_files( + self, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + relative_path: str = "", + paths_include_glob: str = "", + paths_exclude_glob: str = "", + dry_run: bool = False, + occurrence_ids: list[str] | None = None, + expected_count: int = -1, + max_answer_chars: int = -1, + ) -> ReplacementPreview | str: + r""" + Replaces occurrences of a pattern across multiple files in ONE call. + + This is the preferred operation for repeated small edits (renames, import swaps, annotation changes, + path prefixes) spanning several files or many places in one file: one call with a SHORT pattern + replaces many single-file replacements with long disambiguating needles. + + Recommended protocol whenever there is ANY risk of unintended replacements: + 1. Call with dry_run=True: every prospective change is returned as a minimal line diff with an + occurrence id; nothing is modified. + 2. Call again with dry_run=False, passing the ids you want in occurrence_ids (omit it to apply + all). You pick the desired replacements from the list - no counting, no needle-crafting. + + For clearly unambiguous bulk replacements you may skip the dry run; pass expected_count as a + guard. If the actual number of matches differs, NOTHING is changed and an error containing the + prospective changes is raised, so a failed guard costs one call and gives you the dry-run output to select from. + + :param needle: the string (mode "literal") or regular expression (mode "regex"; Python `re` + syntax with DOTALL and MULTILINE) to search for + :param repl: the replacement string. In regex mode, backreferences to matched groups can be + specified as $!1, $!2, etc. + :param mode: either "literal" or "regex", specifying how `needle` is to be interpreted + :param relative_path: only consider this file or directory (default: the whole project) + :param paths_include_glob: optional glob (relative to the project root, e.g. "src/**/*.java") + restricting which files are considered + :param paths_exclude_glob: optional glob of files to exclude; takes precedence over the include glob + :param dry_run: if True, do not modify anything; return the prospective changes with occurrence ids + :param occurrence_ids: optional list of occurrence ids (obtained from a dry run) to which the + replacement is restricted; if any id is unknown or stale, NOTHING is changed. If omitted, + all occurrences are replaced. + :param expected_count: optional guard for calls without occurrence_ids: the number of + occurrences you expect to be replaced. If the actual count differs, nothing is changed and + an error containing the prospective changes is raised. -1 disables the guard. + :param max_answer_chars: if the rendered output exceeds this many characters, a shortened version is + used. -1 uses the configured default. + :return: in a dry run, the prospective changes (`ReplacementPreview`); otherwise a summary of the applied replacements + """ + replacement = MultiFileReplacement( + self._get_project(), + needle, + repl, + mode, + relative_path=relative_path, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + ) + if dry_run: + return ReplacementPreview(replacement, ReplacementPreviewRenderer(self._agent, max_answer_chars, dry_run=True)) + + # select the occurrences to replace + try: + if occurrence_ids is not None: + occurrences = replacement.select(occurrence_ids) + else: + occurrences = replacement.select_all_guarded(expected_count) + except ReplacementRejectedError as e: + message = str(e) + if e.show_prospective_changes: + preview = ReplacementPreview(replacement, ReplacementPreviewRenderer(self._agent, max_answer_chars, dry_run=False)) + message += "\n" + preview.represent() + raise ValueError(message) from e + + return replacement.apply(self._create_code_editor(), occurrences).to_display_string() + + # line-level operations + + @facade_method(optional=True, can_edit=True) + def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> str: + """ + Deletes the given lines in the file. + Requires that the same range of lines was previously read to verify correctness of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be deleted + :param end_line: the 0-based index of the last line to be deleted + :return: a success message + """ + self._create_code_editor().delete_lines(relative_path, start_line, end_line) + return SUCCESS_RESULT + + @facade_method(optional=True, can_edit=True) + def replace_lines(self, relative_path: str, start_line: int, end_line: int, content: str) -> str: + """ + Replaces the given range of lines in the given file. + Requires that the same range of lines was previously read to verify correctness of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be replaced + :param end_line: the 0-based index of the last line to be replaced + :param content: the content to insert + :return: a success message + """ + code_editor = self._create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, self._normalize_inserted_content(content)) + return SUCCESS_RESULT + + @facade_method(optional=True, can_edit=True) + def insert_at_line(self, relative_path: str, line: int, content: str) -> str: + """ + Inserts the given content at the given line in the file, pushing existing content of the line down. + In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which + symbol you are looking for. + However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body). + + :param relative_path: the relative path to the file + :param line: the 0-based index of the line to insert content at + :param content: the content to be inserted + :return: a success message + """ + self._create_code_editor().insert_at_line(relative_path, line, self._normalize_inserted_content(content)) + return SUCCESS_RESULT + + @staticmethod + def _normalize_inserted_content(content: str) -> str: + return content if content.endswith("\n") else content + "\n" + + # symbol-level operations + + @facade_method(can_edit=True) + def replace_symbol_body(self, name_path: str, relative_path: str, body: str) -> str: + """ + Replaces the body of the given symbol. + + IMPORTANT: Only replace symbol bodies if you have previously made a retrieval with include_body=True and thus know what + constitutes the body! + + :param name_path: name path of the symbol whose body to replace + :param relative_path: the relative path to the file containing the symbol + :param body: the new symbol body. The symbol body is the definition of a symbol + in the programming language, including e.g. the signature line for functions. + Depending on the language, it may or may not include a preceding docstring or other preceding annotations. + :return: a success message + """ + self._create_code_editor().replace_body(name_path, relative_file_path=relative_path, body=body) + return SUCCESS_RESULT + + @facade_method(can_edit=True) + def insert_after_symbol(self, name_path: str, relative_path: str, body: str) -> str: + """ + Inserts code after a class/method/function definition. + Don't use this to insert after assignments (constants, fields). + + :param name_path: name path of the symbol after which to insert content + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted. The inserted code shall begin with the next line after + the symbol. + :return: a success message + """ + self._create_code_editor().insert_after_symbol(name_path, relative_file_path=relative_path, body=body) + return SUCCESS_RESULT + + @facade_method(can_edit=True) + def insert_before_symbol(self, name_path: str, relative_path: str, body: str) -> str: + """ + Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location). + A typical use case is to insert a new class, function, method, field or variable assignment; or + a new import statement before the first symbol in the file. + + :param name_path: name path of the symbol before which to insert content + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted before the line in which the referenced symbol is defined + :return: a success message + """ + self._create_code_editor().insert_before_symbol(name_path, relative_file_path=relative_path, body=body) + return SUCCESS_RESULT diff --git a/src/serena/facades/api/lsp.py b/src/serena/facades/api/lsp.py index 46cdc882..4b2fe62d 100644 --- a/src/serena/facades/api/lsp.py +++ b/src/serena/facades/api/lsp.py @@ -299,7 +299,7 @@ class LspApi(FacadeApi): assert self._agent.get_language_backend().is_lsp(), "Language server operations require the language server backend" return LanguageServerSymbolRetriever(self._get_project()) - def _create_code_editor(self, symbol_retriever: LanguageServerSymbolRetriever | None = None) -> LanguageServerCodeEditor: + def _create_ls_code_editor(self, symbol_retriever: LanguageServerSymbolRetriever | None = None) -> LanguageServerCodeEditor: return LanguageServerCodeEditor(symbol_retriever or self._create_symbol_retriever()) @staticmethod @@ -560,7 +560,7 @@ class LspApi(FacadeApi): symbol_retriever = self._create_symbol_retriever() # find relevant location for lookup - editor = self._create_code_editor(symbol_retriever) + editor = self._create_ls_code_editor(symbol_retriever) if not containing_symbol_name_path: content = editor.read_file(relative_path) coords = find_text_coordinates(content, regex, require_unique=True) @@ -666,54 +666,6 @@ class LspApi(FacadeApi): # edit operations - @facade_method(can_edit=True) - def replace_symbol_body(self, name_path: str, relative_path: str, body: str) -> str: - """ - Replaces the body of the given symbol. - - IMPORTANT: Only replace symbol bodies if you have previously made a retrieval with include_body=True and thus know what - constitutes the body! - - :param name_path: name path of the symbol whose body to replace - :param relative_path: the relative path to the file containing the symbol - :param body: the new symbol body. The symbol body is the definition of a symbol - in the programming language, including e.g. the signature line for functions. - Depending on the language, it may or may not include a preceding docstring or other preceding annotations. - :return: a success message - """ - self._create_code_editor().replace_body(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT - - @facade_method(can_edit=True) - def insert_after_symbol(self, name_path: str, relative_path: str, body: str) -> str: - """ - Inserts code after a class/method/function definition. - Don't use this to insert after assignments (constants, fields). - - :param name_path: name path of the symbol after which to insert content - :param relative_path: the relative path to the file containing the symbol - :param body: the body/content to be inserted. The inserted code shall begin with the next line after - the symbol. - :return: a success message - """ - self._create_code_editor().insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT - - @facade_method(can_edit=True) - def insert_before_symbol(self, name_path: str, relative_path: str, body: str) -> str: - """ - Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location). - A typical use case is to insert a new class, function, method, field or variable assignment; or - a new import statement before the first symbol in the file. - - :param name_path: name path of the symbol before which to insert content - :param relative_path: the relative path to the file containing the symbol - :param body: the body/content to be inserted before the line in which the referenced symbol is defined - :return: a success message - """ - self._create_code_editor().insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT - @facade_method(can_edit=True) def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: """ @@ -727,7 +679,7 @@ class LspApi(FacadeApi): :return: a result summary indicating success or failure """ self._get_project().ls_sync_file_system_changes() - return self._create_code_editor().rename_symbol(name_path, relative_path=relative_path, new_name=new_name) + return self._create_ls_code_editor().rename_symbol(name_path, relative_path=relative_path, new_name=new_name) @facade_method(can_edit=True) def safe_delete_symbol(self, name_path_pattern: str, relative_path: str) -> str: @@ -765,5 +717,5 @@ class LspApi(FacadeApi): if file_to_lines: return f"Cannot delete, the symbol {symbol_name_path} is referenced in: {TextOutputUtils.to_json(file_to_lines)}" - self._create_code_editor(symbol_retriever).delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path) + self._create_ls_code_editor(symbol_retriever).delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path) return SUCCESS_RESULT diff --git a/src/serena/facades/facade.py b/src/serena/facades/facade.py index dc7395cb..a20080d7 100644 --- a/src/serena/facades/facade.py +++ b/src/serena/facades/facade.py @@ -16,6 +16,7 @@ from serena.project import Project if TYPE_CHECKING: from serena.agent import SerenaAgent + from serena.code_editor import CodeEditor log = logging.getLogger(__name__) TCallable = TypeVar("TCallable", bound=Callable[..., Any]) @@ -104,6 +105,22 @@ class FacadeApi(ABC): def _get_project(self) -> Project: return self._agent.get_active_project_or_raise() + def _create_code_editor(self) -> "CodeEditor": + """ + :return: a code editor for the active project, using the active language backend + """ + from serena.code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor + from serena.symbol import LanguageServerSymbolRetriever + + project = self._get_project() + backend = self._agent.get_language_backend() + if backend.is_lsp(): + return LanguageServerCodeEditor(LanguageServerSymbolRetriever(project)) + elif backend.is_jetbrains(): + return JetBrainsCodeEditor(project) + else: + raise ValueError(f"Unsupported language backend: {backend}") + class FacadeMethod: """ diff --git a/src/serena/facades/representable.py b/src/serena/facades/representable.py index 8b79e0c8..1b89a40e 100644 --- a/src/serena/facades/representable.py +++ b/src/serena/facades/representable.py @@ -35,13 +35,16 @@ class Renderer(Generic[T], ABC): version of the result. They are tried in order until one fits within ``max_answer_chars``. :return: the result string, potentially replaced by a shortened version """ - max_answer_chars = self._max_answer_chars - if max_answer_chars == -1: - max_answer_chars = self._agent.serena_config.default_max_tool_answer_chars return TextOutputUtils.limit_length( - result=result, max_answer_chars=max_answer_chars, shortened_result_factories=shortened_result_factories + result=result, max_answer_chars=self._get_max_answer_chars(), shortened_result_factories=shortened_result_factories ) + def _get_max_answer_chars(self) -> int: + """ + :return: the effective maximum number of characters, resolving the default from the configuration + """ + return self._agent.serena_config.default_max_tool_answer_chars if self._max_answer_chars == -1 else self._max_answer_chars + def _to_json(self, x: Any) -> str: return TextOutputUtils.to_json(x) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 7874f999..dcd8a7cc 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -10,15 +10,24 @@ File and file system-related tools, specifically for import os from collections import defaultdict from fnmatch import fnmatch -from pathlib import Path -from typing import Literal +from typing import Literal, cast -from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional +from serena.facades.api.edit import EditApi +from serena.tools import EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.util.file_system import scan_directory -from serena.util.text_utils import ContentReplacer, MultiFileReplacement, ReplacementRejectedError from solidlsp.ls_utils import TextUtils +class EditApiMixin: + """ + Mixin for tools which delegate to the editing API + """ + + def _api(self) -> EditApi: + tool = cast(Tool, cast(object, self)) + return EditApi(tool.agent) + + class ReadFileTool(Tool): """ Reads a file within the project directory. @@ -51,7 +60,7 @@ class ReadFileTool(Tool): return self._limit_length(result, max_answer_chars) -class CreateTextFileTool(EditingToolWithDiagnostics): +class CreateTextFileTool(EditingToolWithDiagnostics, EditApiMixin): """ Creates/overwrites a file in the project directory. """ @@ -65,26 +74,7 @@ class CreateTextFileTool(EditingToolWithDiagnostics): :return: a message indicating success or failure """ with self.diagnostics_context(relative_path) as diagnostics_context: - # validating the destination path - project_root = self.get_project_root() - abs_path = (Path(project_root) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() - - if will_overwrite_existing: - self.project.validate_relative_path(relative_path) - else: - assert abs_path.is_relative_to(self.get_project_root()), ( - f"Cannot create file outside of the project directory, got {relative_path=}" - ) - - # writing the file - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) - answer = f"File created: {relative_path}." - if will_overwrite_existing: - answer += " Overwrote existing file." - - return diagnostics_context.format_result(answer) + return diagnostics_context.format_result(self._api().create_text_file(relative_path, content)) class ListDirTool(Tool): @@ -166,7 +156,7 @@ class FindFileTool(Tool): return result -class ReplaceContentTool(EditingToolWithDiagnostics): +class ReplaceContentTool(EditingToolWithDiagnostics, EditApiMixin): """ Replaces content in a file (optionally using regular expressions). """ @@ -202,16 +192,12 @@ class ReplaceContentTool(EditingToolWithDiagnostics): If false and multiple occurrences are found, an error will be returned """ with self.diagnostics_context(relative_path) as diagnostics_context: - self.project.validate_relative_path(relative_path) - with EditedFileContext(relative_path, self.create_code_editor()) as context: - original_content = context.get_original_content() - replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) - updated_content = replacer.replace(original_content, needle, repl) - context.set_updated_content(updated_content) - return diagnostics_context.format_result(SUCCESS_RESULT) + return diagnostics_context.format_result( + self._api().replace_content(relative_path, needle, repl, mode, allow_multiple_occurrences=allow_multiple_occurrences) + ) -class ReplaceInFilesTool(EditingToolWithDiagnostics): +class ReplaceInFilesTool(EditingToolWithDiagnostics, EditApiMixin): """ Replaces occurrences of a pattern across multiple files, with dry-run preview and per-occurrence selection. """ @@ -267,38 +253,28 @@ class ReplaceInFilesTool(EditingToolWithDiagnostics): returned. -1 uses the configured default. :return: in a dry run, the prospective changes; otherwise a summary of the applied replacements """ - max_answer_chars = self._resolve_max_answer_chars(max_answer_chars) - replacement = MultiFileReplacement( - self.project, - needle, - repl, - mode, - relative_path=relative_path, - paths_include_glob=paths_include_glob, - paths_exclude_glob=paths_exclude_glob, - ) + api = self._api() if dry_run: - return replacement.render_listing(max_answer_chars, dry_run=True) - - # select the occurrences to replace - try: - if occurrence_ids is not None: - occurrences = replacement.select(occurrence_ids) - else: - occurrences = replacement.select_all_guarded(expected_count) - except ReplacementRejectedError as e: - message = str(e) - if e.show_prospective_changes: - message += "\n" + replacement.render_listing(max_answer_chars, dry_run=False) - raise ValueError(message) from e - - # apply the replacement - with self.diagnostics_context(*replacement.affected_files) as diagnostics_context: - result = replacement.apply(self.create_code_editor(), occurrences) - return diagnostics_context.format_result(result.to_display_string()) + return api.replace_in_files( + needle, repl, mode, relative_path, paths_include_glob, paths_exclude_glob, dry_run=True, max_answer_chars=max_answer_chars + ).represent() + with self.diagnostics_context() as diagnostics_context: + result = api.replace_in_files( + needle, + repl, + mode, + relative_path, + paths_include_glob, + paths_exclude_glob, + occurrence_ids=occurrence_ids, + expected_count=expected_count, + max_answer_chars=max_answer_chars, + ) + assert isinstance(result, str) + return diagnostics_context.format_result(result) -class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin): """ Deletes a range of lines within a file. """ @@ -319,12 +295,10 @@ class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): :param end_line: the 0-based index of the last line to be deleted """ with self.diagnostics_context(relative_path) as diagnostics_context: - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - return diagnostics_context.format_result(SUCCESS_RESULT) + return diagnostics_context.format_result(self._api().delete_lines(relative_path, start_line, end_line)) -class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin): """ Replaces a range of lines within a file with new content. """ @@ -346,19 +320,11 @@ class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): :param end_line: the 0-based index of the last line to be deleted :param content: the content to insert """ - # normalizing the replacement content - if not content.endswith("\n"): - content += "\n" - with self.diagnostics_context(relative_path) as diagnostics_context: - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - code_editor.insert_at_line(relative_path, start_line, content) - - return diagnostics_context.format_result(SUCCESS_RESULT) + return diagnostics_context.format_result(self._api().replace_lines(relative_path, start_line, end_line, content)) -class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional, EditApiMixin): """ Inserts content at a given line in a file. """ @@ -379,15 +345,8 @@ class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): :param line: the 0-based index of the line to insert content at :param content: the content to be inserted """ - # normalizing the inserted content - if not content.endswith("\n"): - content += "\n" - with self.diagnostics_context(relative_path) as diagnostics_context: - code_editor = self.create_code_editor() - code_editor.insert_at_line(relative_path, line, content) - - return diagnostics_context.format_result(SUCCESS_RESULT) + return diagnostics_context.format_result(self._api().insert_at_line(relative_path, line, content)) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index b6df9c2c..a505325d 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -3,7 +3,7 @@ Language server-related tools """ # SPDX-License-Identifier: GPL-3.0-or-later -from typing import TYPE_CHECKING, cast +from typing import cast from serena.facades.api.lsp import LspApi from serena.tools import ( @@ -12,11 +12,9 @@ from serena.tools import ( ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, ) +from serena.tools.file_tools import EditApiMixin from serena.tools.tools_base import ToolMarkerOptional -if TYPE_CHECKING: - pass - class LspApiMixin: """ @@ -333,7 +331,7 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption ) -class ReplaceSymbolBodyTool(EditingToolWithDiagnostics, LspApiMixin): +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics, EditApiMixin): """ Replaces the full definition of a symbol using the language server backend. """ @@ -361,7 +359,7 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics, LspApiMixin): return diagnostics_context.format_result(result) -class InsertAfterSymbolTool(EditingToolWithDiagnostics, LspApiMixin): +class InsertAfterSymbolTool(EditingToolWithDiagnostics, EditApiMixin): """ Inserts content after the end of the definition of a given symbol. """ @@ -386,7 +384,7 @@ class InsertAfterSymbolTool(EditingToolWithDiagnostics, LspApiMixin): return diagnostics_context.format_result(result) -class InsertBeforeSymbolTool(EditingToolWithDiagnostics, LspApiMixin): +class InsertBeforeSymbolTool(EditingToolWithDiagnostics, EditApiMixin): """ Inserts content before the beginning of the definition of a given symbol. """ diff --git a/test/serena/test_edit_api.py b/test/serena/test_edit_api.py new file mode 100644 index 00000000..fe158be7 --- /dev/null +++ b/test/serena/test_edit_api.py @@ -0,0 +1,81 @@ +""" +Tests for the editing facade API (backend-independent parts, without a language server). +""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from serena.config.serena_config import SerenaConfig +from serena.facades.api.edit import EditApi, ReplacementPreview +from serena.facades.facade import ApiScope, Facade +from serena.project import Project + + +@pytest.fixture +def project(tmp_path: Path) -> Project: + (tmp_path / "a.py").write_text("x = foo(1)\ny = foo(2)\n", encoding="utf-8") + (tmp_path / "b.py").write_text("z = foo(3)\n", encoding="utf-8") + return Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False)) + + +@pytest.fixture +def api(project: Project) -> EditApi: + agent = MagicMock() + agent.get_active_project_or_raise.return_value = project + agent.serena_config.default_max_tool_answer_chars = 10000 + return EditApi(agent) + + +def test_facade_exposes_editing_operations(api: EditApi) -> None: + default_methods = { + "create_text_file", + "replace_content", + "replace_in_files", + "replace_symbol_body", + "insert_after_symbol", + "insert_before_symbol", + } + optional_methods = {"delete_lines", "replace_lines", "insert_at_line"} # the line-level operations are optional (as are the tools) + + facade = Facade.from_api(api, ApiScope()) + assert facade.name == "edit" + assert set(facade.enabled_method_names) == default_methods + for name in optional_methods: + assert facade.get_method(name).info.optional + assert all(facade.get_method(name).info.can_edit for name in default_methods | optional_methods) + + +def test_create_text_file(api: EditApi, project: Project) -> None: + result = api.create_text_file("sub/new.txt", "hello\n") + assert "new.txt" in result + assert (Path(project.project_root) / "sub" / "new.txt").read_text(encoding="utf-8") == "hello\n" + + result = api.create_text_file("sub/new.txt", "changed\n") + assert "Overwrote" in result + + with pytest.raises(AssertionError): + api.create_text_file("../outside.txt", "nope") + + +def test_replace_in_files_dry_run_returns_inspectable_preview(api: EditApi, project: Project) -> None: + preview = api.replace_in_files("foo", "bar", mode="literal", dry_run=True) + assert isinstance(preview, ReplacementPreview) + + # the occurrences are accessible from code + assert [o.relative_path for o in preview.occurrences] == ["a.py", "a.py", "b.py"] + assert preview.affected_files == ["a.py", "b.py"] + assert all(o.replacement == "bar" for o in preview.occurrences) + + # the rendering lists the occurrence ids and diffs; nothing was modified + rendered = preview.represent() + assert "DRY RUN" in rendered + assert all(o.occurrence_id in rendered for o in preview.occurrences) + assert (Path(project.project_root) / "a.py").read_text(encoding="utf-8") == "x = foo(1)\ny = foo(2)\n" + + +def test_replace_in_files_guard_failure_includes_preview(api: EditApi) -> None: + with pytest.raises(ValueError, match="expected_count=1") as exc_info: + api.replace_in_files("foo", "bar", mode="literal", expected_count=1) + assert "b.py" in str(exc_info.value) # the listing of prospective changes is included