feat: Add configurable line_ending setting for file writes

Resolves #1139
This commit is contained in:
Craig Kolb authored and Craig Kolb committed 2026-03-07 22:36:12 -08:00
1 parent 66cbe31045
commit d6a832b5d9
7 files changed
+73 -9

No files matched your search

+8 -5
View File
@@ -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):
+47 -1
View File
@@ -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.
+3
View File
@@ -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
+4 -1
View File
@@ -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: []
read_only_memory_patterns: []
@@ -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.
+1 -1
View File
@@ -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."
+5 -1
View File
@@ -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 _: