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.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-15 12:50:43 +02:00
1 parent 8ace53c3a1
commit 66ed46bef4
8 files changed
+478 -149

No files matched your search

+2 -1
View File
@@ -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():
+318
View File
@@ -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
+4 -52
View File
@@ -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
+17
View File
@@ -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:
"""
+7 -4
View File
@@ -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)
+44 -85
View File
@@ -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):
+5 -7
View File
@@ -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.
"""
+81
View File
@@ -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