11from __future__ import annotations
22
33from pathlib import Path
4+ from typing import Union , List
45
56from patchwork .step import Step , StepStatus
67
78
8- def save_file_contents (file_path , content ):
9- """Utility function to save content to a file."""
10- with open (file_path , "w" ) as file :
9+ def save_file_contents (file_path : str , content : Union [str , bytes ]) -> None :
10+ """Utility function to save content to a file in binary mode to preserve line endings.
11+
12+ Args:
13+ file_path: Path to the file to write
14+ content: Content to write, either as string or bytes. If string, it will be encoded as UTF-8."""
15+ with open (file_path , "wb" ) as file :
16+ # Convert string to bytes if needed
17+ if isinstance (content , str ):
18+ content = content .encode ('utf-8' )
1119 file .write (content )
1220
1321
14- def handle_indent (src : list [str ], target : list [str ], start : int , end : int ) -> list [str ]:
22+ def handle_indent (src : List [str ], target : List [str ], start : int , end : int ) -> List [str ]:
23+ """Handles indentation of new code to match the original code's indentation level.
24+
25+ Args:
26+ src: Source lines from the original file
27+ target: New lines that need to be indented
28+ start: Start line number in the source file
29+ end: End line number in the source file
30+
31+ Returns:
32+ List of strings with proper indentation applied
33+
34+ Note:
35+ - If target is empty, returns it as is
36+ - If start equals end, uses start + 1 as end to ensure at least one line
37+ - Preserves existing indentation characters (spaces or tabs)
38+ """
1539 if len (target ) < 1 :
1640 return target
1741
1842 if start == end :
1943 end = start + 1
2044
45+ # Find first non-empty line in source and target
2146 first_src_line = next ((line for line in src [start :end ] if line .strip () != "" ), "" )
2247 src_indent_count = len (first_src_line ) - len (first_src_line .lstrip ())
2348 first_target_line = next ((line for line in target if line .strip () != "" ), "" )
@@ -26,36 +51,101 @@ def handle_indent(src: list[str], target: list[str], start: int, end: int) -> li
2651
2752 indent = ""
2853 if indent_diff > 0 :
54+ # Use the same indentation character as the source (space or tab)
2955 indent_unit = first_src_line [0 ]
3056 indent = indent_unit * indent_diff
3157
3258 return [indent + line for line in target ]
3359
3460
61+ def detect_line_ending (content : bytes ) -> bytes :
62+ """Detect the dominant line ending style in the given bytes content.
63+
64+ Args:
65+ content: File content in bytes to analyze
66+
67+ Returns:
68+ The detected line ending as bytes (b'\\ r\\ n', b'\\ n', or b'\\ r')
69+
70+ Note:
71+ - Counts occurrences of different line endings (CRLF, LF, CR)
72+ - Returns the most common line ending
73+ - Handles cases where \r \n is treated as one ending, not two
74+ - Defaults to \\ n if no line endings are found"""
75+ crlf_count = content .count (b'\r \n ' )
76+ lf_count = content .count (b'\n ' ) - crlf_count # Don't count \n that are part of \r\n
77+ cr_count = content .count (b'\r ' ) - crlf_count # Don't count \r that are part of \r\n
78+
79+ if crlf_count > max (lf_count , cr_count ):
80+ return b'\r \n '
81+ elif lf_count > cr_count :
82+ return b'\n '
83+ elif cr_count > 0 :
84+ return b'\r '
85+ return b'\n ' # Default to \n if no line endings found
86+
3587def replace_code_in_file (
3688 file_path : str ,
37- start_line : int | None ,
38- end_line : int | None ,
89+ start_line : Union [ int , None ] ,
90+ end_line : Union [ int , None ] ,
3991 new_code : str ,
4092) -> None :
93+ """Replace specified lines in a file with new code while preserving line endings.
94+
95+ Args:
96+ file_path: Path to the file to modify
97+ start_line: Starting line number for replacement (0-based). If None, writes entire file
98+ end_line: Ending line number for replacement (0-based). If None, writes entire file
99+ new_code: New content to insert
100+
101+ Note:
102+ - Preserves the original file's line ending style (CRLF, LF, or CR)
103+ - Handles indentation to match the original code
104+ - Creates new file if it doesn't exist
105+ - Uses system default line ending for new files
106+ - Ensures all lines end with proper line ending
107+ - Preserves UTF-8 encoding
108+ """
41109 path = Path (file_path )
110+
111+ # Convert new_code to use \n for initial splitting
42112 new_code_lines = new_code .splitlines (keepends = True )
43113 if len (new_code_lines ) > 0 and not new_code_lines [- 1 ].endswith ("\n " ):
44114 new_code_lines [- 1 ] += "\n "
45115
46116 if path .exists () and start_line is not None and end_line is not None :
47117 """Replaces specified lines in a file with new code."""
48- text = path .read_text ()
49-
118+ # Read file in binary mode to preserve original line endings
119+ with open (file_path , 'rb' ) as f :
120+ content = f .read ()
121+
122+ # Detect original line ending
123+ line_ending = detect_line_ending (content )
124+
125+ # Decode content for line operations
126+ text = content .decode ('utf-8' )
50127 lines = text .splitlines (keepends = True )
51-
52- # Insert the new code at the start line after converting it into a list of lines
128+
129+ # Handle indentation for new code lines
53130 lines [start_line :end_line ] = handle_indent (lines , new_code_lines , start_line , end_line )
131+
132+ # Join all lines and encode ensuring all line endings match the original
133+ result = '' .join (lines )
134+ # Normalize to \n first
135+ result = result .replace ('\r \n ' , '\n ' ).replace ('\r ' , '\n ' )
136+ # Then convert to detected line ending
137+ if line_ending == b'\r \n ' :
138+ result = result .replace ('\n ' , '\r \n ' )
139+ elif line_ending == b'\r ' :
140+ result = result .replace ('\n ' , '\r ' )
141+
142+ content = result .encode ('utf-8' )
54143 else :
55- lines = new_code_lines
144+ # For new files, use system default line ending
145+ content = '' .join (new_code_lines ).encode ('utf-8' )
56146
57147 # Save the modified contents back to the file
58- save_file_contents (file_path , "" . join ( lines ) )
148+ save_file_contents (file_path , content )
59149
60150
61151class ModifyCode (Step ):
0 commit comments