From d6a832b5d9a106cbe561094fd917029381a536e5 Mon Sep 17 00:00:00 2001 From: Craig Kolb Date: Sat, 7 Mar 2026 21:48:03 -0800 Subject: [PATCH] feat: Add configurable line_ending setting for file writes Resolves #1139 --- src/serena/code_editor.py | 13 +++-- src/serena/config/serena_config.py | 48 ++++++++++++++++++- src/serena/project.py | 3 ++ src/serena/resources/project.template.yml | 5 +- .../resources/serena_config.template.yml | 5 ++ src/serena/tools/file_tools.py | 2 +- src/serena/tools/tools_base.py | 6 ++- 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index ea83da25..43774195 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -21,11 +21,12 @@ TSymbol = TypeVar("TSymbol", bound=Symbol) class CodeEditor(Generic[TSymbol], ABC): - def __init__(self, project_root: str, project_config: ProjectConfig | None = None) -> None: + def __init__(self, project_root: str, project_config: ProjectConfig | None = None, newline: str | None = None) -> None: self.project_root = project_root # set encoding based on project configuration, if available self.encoding = project_config.encoding if project_config is not None else DEFAULT_SOURCE_FILE_ENCODING + self.newline = newline class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -73,7 +74,7 @@ class CodeEditor(Generic[TSymbol], ABC): def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: abs_path = os.path.join(self.project_root, edited_file.relative_path) new_contents = edited_file.get_contents() - with open(abs_path, "w", encoding=self.encoding) as f: + with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) @abstractmethod @@ -235,8 +236,10 @@ class CodeEditor(Generic[TSymbol], ABC): class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): - def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, project_config: ProjectConfig | None = None): - super().__init__(project_root=symbol_retriever.get_root_path(), project_config=project_config) + def __init__( + self, symbol_retriever: LanguageServerSymbolRetriever, project_config: ProjectConfig | None = None, newline: str | None = None + ): + super().__init__(project_root=symbol_retriever.get_root_path(), project_config=project_config, newline=newline) self._symbol_retriever = symbol_retriever def _get_language_server(self, relative_path: str) -> SolidLanguageServer: @@ -372,7 +375,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): def __init__(self, project: Project) -> None: self._project = project - super().__init__(project_root=project.project_root, project_config=project.project_config) + super().__init__(project_root=project.project_root, project_config=project.project_config, newline=project.line_ending.newline_str) class EditedFile(CodeEditor.EditedFile): def __init__(self, relative_path: str, project: Project): diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 74077c96..7daf3922 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -182,6 +182,35 @@ class LanguageBackend(Enum): return self == LanguageBackend.JETBRAINS +class LineEnding(Enum): + """Line ending convention for file writes.""" + + LF = "lf" + CRLF = "crlf" + NATIVE = "native" + + @property + def newline_str(self) -> str | None: + """The newline parameter value for :func:`open` and :meth:`Path.write_text`. + + Returns ``None`` for native mode (platform default). + """ + if self is LineEnding.LF: + return "\n" + elif self is LineEnding.CRLF: + return "\r\n" + return None + + @classmethod + def from_str(cls, value: str) -> "LineEnding": + """Parse a string value into a :class:`LineEnding`.""" + try: + return cls(value.lower()) + except ValueError as e: + valid = [le.value for le in cls] + raise ValueError(f"Invalid line_ending: {value!r}. Valid values are: {valid}") from e + + @dataclass class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form @@ -191,6 +220,7 @@ class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMix symbol_info_budget: float | None = None language_backend: LanguageBackend | None = None + line_ending: LineEnding | None = None read_only_memory_patterns: list[str] = field(default_factory=list) @@ -388,6 +418,9 @@ class ProjectConfig(SharedConfig): language_backend_value = data.get("language_backend") language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None + line_ending_value = data.get("line_ending") + line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None + return cls( project_name=data["project_name"], languages=languages, @@ -400,6 +433,7 @@ class ProjectConfig(SharedConfig): ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], + line_ending=line_ending, language_backend=language_backend, base_modes=data["base_modes"], default_modes=data["default_modes"], @@ -413,6 +447,7 @@ class ProjectConfig(SharedConfig): d = dataclasses.asdict(self) d["languages"] = [lang.value for lang in self.languages] d["language_backend"] = self.language_backend.value if self.language_backend is not None else None + d["line_ending"] = self.line_ending.value if self.line_ending is not None else None return d @classmethod @@ -595,6 +630,7 @@ class SerenaConfig(SharedConfig): # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") symbol_info_budget: float = 10.0 + line_ending: LineEnding = LineEnding.NATIVE """ Time budget (seconds) for requests when tools request include_info (currently only supported for LSP-based tools). @@ -614,7 +650,7 @@ class SerenaConfig(SharedConfig): # *** static members *** CONFIG_FILE = "serena_config.yml" - CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend"} + CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "line_ending"} # *** methods *** @classmethod @@ -746,6 +782,13 @@ class SerenaConfig(SharedConfig): del loaded_commented_yaml["jetbrains"] instance.language_backend = language_backend + # determine line ending + line_ending = get_dataclass_default(SerenaConfig, "line_ending") + line_ending_value = loaded_commented_yaml.get("line_ending") + if line_ending_value: + line_ending = LineEnding.from_str(line_ending_value) + instance.line_ending = line_ending + # migrate deprecated "gui_log_level" field if necessary if "gui_log_level" in loaded_commented_yaml: num_migrations += 1 @@ -913,6 +956,9 @@ class SerenaConfig(SharedConfig): # convert language backend to string commented_yaml["language_backend"] = self.language_backend.value + # convert line ending to string + commented_yaml["line_ending"] = self.line_ending.value + # transfer comments from the template file # NOTE: The template file now uses leading comments, but we previously used trailing comments, # so we apply a conversion, which detects the old style and transforms it. diff --git a/src/serena/project.py b/src/serena/project.py index 1500dbcc..b7f445f3 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -249,6 +249,9 @@ class Project(ToStringMixin): read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns self.memories_manager = MemoriesManager(self._serena_data_folder, read_only_memory_patterns=read_only_memory_patterns) + # resolve line ending (project -> global) + self.line_ending = project_config.line_ending or serena_config.line_ending + self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 7d508412..5273b5b6 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -31,6 +31,9 @@ languages: ["python"] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" +# line ending convention for file writes: "lf", "crlf", or "native" (platform default) +line_ending: "native" + # The language backend to use for this project. # If not set, the global setting from serena_config.yml is used. # Valid values: LSP, JetBrains @@ -126,4 +129,4 @@ symbol_info_budget: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. -read_only_memory_patterns: [] \ No newline at end of file +read_only_memory_patterns: [] diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index c6549807..cc3f3ffd 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -7,6 +7,11 @@ # in your IDE). language_backend: LSP +# line ending convention for file writes across all projects. +# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). +# Can be overridden per project in project.yml. +line_ending: native + # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. # If you prefer a browser-based tool, use the `web_dashboard` option instead. diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 07393333..8476070c 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -73,7 +73,7 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): ), f"Cannot create file outside of the project directory, got {relative_path=}" abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding=self.project.project_config.encoding) + 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." diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index e35c96e0..89520713 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -63,7 +63,11 @@ class Component(ABC): match self.agent.get_language_backend(): case LanguageBackend.LSP: - return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), project_config=self.project.project_config) + return LanguageServerCodeEditor( + self.create_language_server_symbol_retriever(), + project_config=self.project.project_config, + newline=self.project.line_ending.newline_str, + ) case LanguageBackend.JETBRAINS: return JetBrainsCodeEditor(project=self.project) case _: