diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 772294f0..4f5fe6bb 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -46,6 +46,14 @@ class CodeEditor(Generic[TSymbol], ABC): :return: the contents of the file. """ + @abstractmethod + def set_contents(self, contents: str) -> None: + """ + Fully resets the contents of the file. + + :param contents: the new contents + """ + @abstractmethod def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: pass @@ -62,7 +70,7 @@ class CodeEditor(Generic[TSymbol], ABC): raise NotImplementedError("This method must be overridden for each subclass") @contextmanager - def _edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: + def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: """ Context manager for editing a file. """ @@ -99,7 +107,7 @@ class CodeEditor(Generic[TSymbol], ABC): start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: # make sure the replacement adds no additional newlines (before or after) - all newlines # and whitespace before/after should remain the same, so we strip it entirely body = body.strip() @@ -154,7 +162,7 @@ class CodeEditor(Generic[TSymbol], ABC): # `line += 1`, is replaced body = body.rstrip("\r\n") + "\n" - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: @@ -182,7 +190,7 @@ class CodeEditor(Generic[TSymbol], ABC): body += "\n" * num_trailing_newlines # apply edit - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) def insert_at_line(self, relative_path: str, line: int, content: str) -> None: @@ -193,7 +201,7 @@ class CodeEditor(Generic[TSymbol], ABC): :param line: the 0-based index of the line to insert content at :param content: the content to insert """ - with self._edited_file_context(relative_path) as edited_file: + with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: @@ -207,7 +215,7 @@ class CodeEditor(Generic[TSymbol], ABC): start_col = 0 end_line_for_delete = end_line + 1 end_col = 0 - with self._edited_file_context(relative_path) as edited_file: + with self.edited_file_context(relative_path) as edited_file: start_pos = PositionInFile(line=start_line, col=start_col) end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) @@ -219,7 +227,7 @@ class CodeEditor(Generic[TSymbol], ABC): symbol = self._find_unique_symbol(name_path, relative_file_path) start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) @abstractmethod @@ -251,6 +259,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def get_contents(self) -> str: return self._file_buffer.contents + def set_contents(self, contents: str) -> None: + self._file_buffer.contents = contents + def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: self._lang_server.delete_text_between_positions(self.relative_path, start_pos.to_lsp_position(), end_pos.to_lsp_position()) @@ -289,7 +300,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): self._text_edits = text_edits def apply(self) -> None: - with self._code_editor._edited_file_context(self._relative_path) as edited_file: + with self._code_editor.edited_file_context(self._relative_path) as edited_file: edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) @@ -382,6 +393,9 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): def get_contents(self) -> str: return self._content + def set_contents(self, contents: str) -> None: + self._content = contents + def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: self._content, _ = TextUtils.delete_text_between_positions( self._content, start_pos.line, start_pos.col, end_pos.line, end_pos.col diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 09e7a0e3..d243cd31 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -256,7 +256,7 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): This function can be used internally by other tools. """ self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) - with EditedFileContext(relative_path, self.agent) as context: + with EditedFileContext(relative_path, self.create_code_editor()) as context: original_content = context.get_original_content() if mode == "literal": diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c1b244c3..879f8184 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,6 +1,5 @@ import inspect import json -import os from abc import ABC from collections.abc import Iterable from dataclasses import dataclass @@ -310,24 +309,23 @@ class EditedFileContext: When exiting the context without an exception, the updated content will be written back to the file. """ - def __init__(self, relative_path: str, agent: "SerenaAgent"): - self._project = agent.get_active_project() - assert self._project is not None - self._abs_path = os.path.join(self._project.project_root, relative_path) - if not os.path.isfile(self._abs_path): - raise FileNotFoundError(f"File {self._abs_path} does not exist.") - with open(self._abs_path, encoding=self._project.project_config.encoding) as f: - self._original_content = f.read() - self._updated_content: str | None = None + def __init__(self, relative_path: str, code_editor: "CodeEditor"): + self._relative_path = relative_path + self._code_editor = code_editor + self._edited_file: CodeEditor.EditedFile | None = None + self._edited_file_context: Any = None def __enter__(self) -> Self: + self._edited_file_context = self._code_editor.edited_file_context(self._relative_path) + self._edited_file = self._edited_file_context.__enter__() return self def get_original_content(self) -> str: """ :return: the original content of the file before any modifications. """ - return self._original_content + assert self._edited_file is not None + return self._edited_file.get_contents() def set_updated_content(self, content: str) -> None: """ @@ -336,16 +334,12 @@ class EditedFileContext: :param content: the updated content of the file """ - self._updated_content = content + assert self._edited_file is not None + self._edited_file.set_contents(content) def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: - if self._updated_content is not None and exc_type is None: - assert self._project is not None - with open(self._abs_path, "w", encoding=self._project.project_config.encoding) as f: - f.write(self._updated_content) - log.info(f"Updated content written to {self._abs_path}") - # Language servers should automatically detect the change and update its state accordingly. - # If they do not, we may have to add a call to notify it. + assert self._edited_file_context is not None + self._edited_file_context.__exit__(exc_type, exc_value, traceback) @dataclass(kw_only=True)