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

+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