mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 18:16:10 +00:00
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:
1 parent
8ace53c3a1
commit
66ed46bef4
8 files changed
+478
-149
No files matched your search
+2
-1
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user