tsugite-cli 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Replacement strategies for smart file editing.
|
|
2
|
+
|
|
3
|
+
This module provides various strategies for finding and replacing text in files,
|
|
4
|
+
with progressive fallback from exact matching to more flexible approaches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Generator, List
|
|
9
|
+
|
|
10
|
+
MIN_BLOCK_ANCHOR_LINES = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReplacementStrategy(ABC):
|
|
14
|
+
"""Base class for text replacement strategies."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
18
|
+
"""Find all matches of search string in content.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
content: The full file content to search in
|
|
22
|
+
search: The text pattern to find
|
|
23
|
+
|
|
24
|
+
Yields:
|
|
25
|
+
Exact strings from content that match the search pattern
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExactStrategy(ReplacementStrategy):
|
|
31
|
+
"""Exact string matching - the most precise strategy."""
|
|
32
|
+
|
|
33
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
34
|
+
"""Find exact string matches."""
|
|
35
|
+
if not search:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
start_index = 0
|
|
39
|
+
while True:
|
|
40
|
+
index = content.find(search, start_index)
|
|
41
|
+
if index == -1:
|
|
42
|
+
break
|
|
43
|
+
yield search
|
|
44
|
+
start_index = index + len(search)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LineTrimmedStrategy(ReplacementStrategy):
|
|
48
|
+
"""Match lines with trimmed whitespace.
|
|
49
|
+
|
|
50
|
+
Ignores leading/trailing whitespace on each line while preserving
|
|
51
|
+
the original whitespace in the matched content.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
55
|
+
"""Find matches with trimmed line comparison."""
|
|
56
|
+
if not search:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
content_lines = content.split("\n")
|
|
60
|
+
search_lines = search.split("\n")
|
|
61
|
+
|
|
62
|
+
# Remove trailing empty line if present
|
|
63
|
+
if search_lines and search_lines[-1] == "":
|
|
64
|
+
search_lines.pop()
|
|
65
|
+
|
|
66
|
+
if not search_lines:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Search for matching blocks
|
|
70
|
+
for i in range(len(content_lines) - len(search_lines) + 1):
|
|
71
|
+
# Check if all lines match when trimmed
|
|
72
|
+
matches = True
|
|
73
|
+
for j in range(len(search_lines)):
|
|
74
|
+
if content_lines[i + j].strip() != search_lines[j].strip():
|
|
75
|
+
matches = False
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
if matches:
|
|
79
|
+
# Yield the original content with its whitespace preserved
|
|
80
|
+
match_start = sum(len(line) + 1 for line in content_lines[:i])
|
|
81
|
+
match_end = match_start + sum(
|
|
82
|
+
len(content_lines[i + j]) + (1 if j < len(search_lines) - 1 else 0)
|
|
83
|
+
for j in range(len(search_lines))
|
|
84
|
+
)
|
|
85
|
+
yield content[match_start:match_end]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BlockAnchorStrategy(ReplacementStrategy):
|
|
89
|
+
"""Match blocks using first and last lines as anchors.
|
|
90
|
+
|
|
91
|
+
Uses Levenshtein distance for fuzzy matching of middle content.
|
|
92
|
+
Requires at least MIN_BLOCK_ANCHOR_LINES (first anchor, middle content, last anchor).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
SINGLE_CANDIDATE_THRESHOLD = 0.0
|
|
96
|
+
MULTIPLE_CANDIDATES_THRESHOLD = 0.3
|
|
97
|
+
|
|
98
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
99
|
+
"""Find matches using anchor lines with fuzzy middle content."""
|
|
100
|
+
search_lines = search.split("\n")
|
|
101
|
+
|
|
102
|
+
if search_lines and search_lines[-1] == "":
|
|
103
|
+
search_lines.pop()
|
|
104
|
+
|
|
105
|
+
if len(search_lines) < MIN_BLOCK_ANCHOR_LINES:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
content_lines = content.split("\n")
|
|
109
|
+
first_line_search = search_lines[0].strip()
|
|
110
|
+
last_line_search = search_lines[-1].strip()
|
|
111
|
+
search_block_size = len(search_lines)
|
|
112
|
+
|
|
113
|
+
# Find all candidate blocks with matching anchors
|
|
114
|
+
candidates: List[tuple[int, int]] = []
|
|
115
|
+
for i in range(len(content_lines)):
|
|
116
|
+
if content_lines[i].strip() != first_line_search:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Look for matching last line
|
|
120
|
+
for j in range(i + 2, len(content_lines)):
|
|
121
|
+
if content_lines[j].strip() == last_line_search:
|
|
122
|
+
candidates.append((i, j))
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if not candidates:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Handle single candidate with relaxed threshold
|
|
129
|
+
if len(candidates) == 1:
|
|
130
|
+
start_line, end_line = candidates[0]
|
|
131
|
+
actual_block_size = end_line - start_line + 1
|
|
132
|
+
similarity = self._calculate_similarity(
|
|
133
|
+
content_lines, search_lines, start_line, search_block_size, actual_block_size
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if similarity >= self.SINGLE_CANDIDATE_THRESHOLD:
|
|
137
|
+
yield self._extract_block(content, content_lines, start_line, end_line)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Handle multiple candidates - find best match
|
|
141
|
+
best_match = None
|
|
142
|
+
max_similarity = -1
|
|
143
|
+
|
|
144
|
+
for start_line, end_line in candidates:
|
|
145
|
+
actual_block_size = end_line - start_line + 1
|
|
146
|
+
similarity = self._calculate_similarity(
|
|
147
|
+
content_lines, search_lines, start_line, search_block_size, actual_block_size
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if similarity > max_similarity:
|
|
151
|
+
max_similarity = similarity
|
|
152
|
+
best_match = (start_line, end_line)
|
|
153
|
+
|
|
154
|
+
if max_similarity >= self.MULTIPLE_CANDIDATES_THRESHOLD and best_match:
|
|
155
|
+
start_line, end_line = best_match
|
|
156
|
+
yield self._extract_block(content, content_lines, start_line, end_line)
|
|
157
|
+
|
|
158
|
+
def _calculate_similarity(
|
|
159
|
+
self,
|
|
160
|
+
content_lines: List[str],
|
|
161
|
+
search_lines: List[str],
|
|
162
|
+
start_line: int,
|
|
163
|
+
search_block_size: int,
|
|
164
|
+
actual_block_size: int,
|
|
165
|
+
) -> float:
|
|
166
|
+
"""Calculate similarity between middle lines using Levenshtein distance."""
|
|
167
|
+
lines_to_check = min(search_block_size - 2, actual_block_size - 2)
|
|
168
|
+
|
|
169
|
+
if lines_to_check <= 0:
|
|
170
|
+
return 1.0
|
|
171
|
+
|
|
172
|
+
similarity = 0.0
|
|
173
|
+
for j in range(1, min(search_block_size - 1, actual_block_size - 1)):
|
|
174
|
+
content_line = content_lines[start_line + j].strip()
|
|
175
|
+
search_line = search_lines[j].strip()
|
|
176
|
+
max_len = max(len(content_line), len(search_line))
|
|
177
|
+
|
|
178
|
+
if max_len == 0:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
distance = self._levenshtein_distance(content_line, search_line)
|
|
182
|
+
similarity += (1 - distance / max_len) / lines_to_check
|
|
183
|
+
|
|
184
|
+
# Early exit if we've already hit threshold
|
|
185
|
+
if similarity >= self.SINGLE_CANDIDATE_THRESHOLD:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
return similarity
|
|
189
|
+
|
|
190
|
+
def _extract_block(self, content: str, content_lines: List[str], start_line: int, end_line: int) -> str:
|
|
191
|
+
"""Extract block of text from content."""
|
|
192
|
+
match_start = sum(len(line) + 1 for line in content_lines[:start_line])
|
|
193
|
+
match_end = match_start + sum(
|
|
194
|
+
len(content_lines[i]) + (1 if i < end_line else 0) for i in range(start_line, end_line + 1)
|
|
195
|
+
)
|
|
196
|
+
return content[match_start:match_end]
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _levenshtein_distance(s1: str, s2: str) -> int:
|
|
200
|
+
"""Calculate Levenshtein distance between two strings."""
|
|
201
|
+
if not s1 or not s2:
|
|
202
|
+
return max(len(s1), len(s2))
|
|
203
|
+
|
|
204
|
+
# Create distance matrix
|
|
205
|
+
m, n = len(s1), len(s2)
|
|
206
|
+
matrix = [[0] * (n + 1) for _ in range(m + 1)]
|
|
207
|
+
|
|
208
|
+
# Initialize first row and column
|
|
209
|
+
for i in range(m + 1):
|
|
210
|
+
matrix[i][0] = i
|
|
211
|
+
for j in range(n + 1):
|
|
212
|
+
matrix[0][j] = j
|
|
213
|
+
|
|
214
|
+
# Fill matrix
|
|
215
|
+
for i in range(1, m + 1):
|
|
216
|
+
for j in range(1, n + 1):
|
|
217
|
+
cost = 0 if s1[i - 1] == s2[j - 1] else 1
|
|
218
|
+
matrix[i][j] = min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
|
219
|
+
|
|
220
|
+
return matrix[m][n]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class WhitespaceNormalizedStrategy(ReplacementStrategy):
|
|
224
|
+
"""Match with normalized whitespace.
|
|
225
|
+
|
|
226
|
+
Normalizes all whitespace to single spaces and trims before comparison.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
230
|
+
"""Find matches with normalized whitespace."""
|
|
231
|
+
|
|
232
|
+
if not search:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
normalized_search = self._normalize_whitespace(search)
|
|
236
|
+
lines = content.split("\n")
|
|
237
|
+
|
|
238
|
+
# Check single line matches
|
|
239
|
+
for line in lines:
|
|
240
|
+
if self._normalize_whitespace(line) == normalized_search:
|
|
241
|
+
yield line
|
|
242
|
+
|
|
243
|
+
# Check multi-line matches
|
|
244
|
+
search_lines = search.split("\n")
|
|
245
|
+
if len(search_lines) <= 1:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
for i in range(len(lines) - len(search_lines) + 1):
|
|
249
|
+
block = "\n".join(lines[i : i + len(search_lines)])
|
|
250
|
+
if self._normalize_whitespace(block) == normalized_search:
|
|
251
|
+
yield block
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _normalize_whitespace(text: str) -> str:
|
|
255
|
+
"""Normalize whitespace to single spaces and trim."""
|
|
256
|
+
import re
|
|
257
|
+
|
|
258
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class IndentationFlexibleStrategy(ReplacementStrategy):
|
|
262
|
+
"""Match ignoring indentation differences.
|
|
263
|
+
|
|
264
|
+
Strips minimum common indentation from both search and content blocks
|
|
265
|
+
before comparison.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
|
|
269
|
+
"""Find matches ignoring indentation."""
|
|
270
|
+
if not search:
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
normalized_search = self._remove_indentation(search)
|
|
274
|
+
content_lines = content.split("\n")
|
|
275
|
+
search_lines = search.split("\n")
|
|
276
|
+
|
|
277
|
+
for i in range(len(content_lines) - len(search_lines) + 1):
|
|
278
|
+
block = "\n".join(content_lines[i : i + len(search_lines)])
|
|
279
|
+
if self._remove_indentation(block) == normalized_search:
|
|
280
|
+
yield block
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _remove_indentation(text: str) -> str:
|
|
284
|
+
"""Remove minimum common indentation from text."""
|
|
285
|
+
lines = text.split("\n")
|
|
286
|
+
non_empty_lines = [line for line in lines if line.strip()]
|
|
287
|
+
|
|
288
|
+
if not non_empty_lines:
|
|
289
|
+
return text
|
|
290
|
+
|
|
291
|
+
# Find minimum indentation
|
|
292
|
+
min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
|
|
293
|
+
|
|
294
|
+
# Remove minimum indentation from all lines
|
|
295
|
+
dedented_lines = [line[min_indent:] if line.strip() else line for line in lines]
|
|
296
|
+
|
|
297
|
+
return "\n".join(dedented_lines)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def apply_replacement(content: str, search: str, replace: str, expected_count: int = 1) -> tuple[str, int, str | None]:
|
|
301
|
+
"""Apply replacement using progressive fallback strategies.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
content: Full file content
|
|
305
|
+
search: Text to find
|
|
306
|
+
replace: Text to replace with
|
|
307
|
+
expected_count: Expected number of matches (default: 1)
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Tuple of (new_content, match_count, error_message)
|
|
311
|
+
error_message is None on success
|
|
312
|
+
"""
|
|
313
|
+
if not search:
|
|
314
|
+
return content, 0, "Search string cannot be empty"
|
|
315
|
+
|
|
316
|
+
if search == replace:
|
|
317
|
+
return content, 0, "Search and replace strings must be different"
|
|
318
|
+
|
|
319
|
+
# Try strategies in order of precision
|
|
320
|
+
strategies = [
|
|
321
|
+
("exact", ExactStrategy()),
|
|
322
|
+
("line-trimmed", LineTrimmedStrategy()),
|
|
323
|
+
("block-anchor", BlockAnchorStrategy()),
|
|
324
|
+
("whitespace-normalized", WhitespaceNormalizedStrategy()),
|
|
325
|
+
("indentation-flexible", IndentationFlexibleStrategy()),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
for strategy_name, strategy in strategies:
|
|
329
|
+
matches = list(strategy.find_matches(content, search))
|
|
330
|
+
|
|
331
|
+
if not matches:
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
match_count = len(matches)
|
|
335
|
+
|
|
336
|
+
# Check if match count matches expectation
|
|
337
|
+
if match_count != expected_count:
|
|
338
|
+
if expected_count == 1:
|
|
339
|
+
error = (
|
|
340
|
+
f"Found {match_count} matches but expected 1. "
|
|
341
|
+
"Either add more context to make the match unique or use expected_replacements parameter."
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
error = f"Found {match_count} matches but expected {expected_count}."
|
|
345
|
+
return content, match_count, error
|
|
346
|
+
|
|
347
|
+
# Apply replacement
|
|
348
|
+
new_content = content
|
|
349
|
+
for match in matches:
|
|
350
|
+
new_content = new_content.replace(match, replace, 1)
|
|
351
|
+
|
|
352
|
+
return new_content, match_count, None
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
content,
|
|
356
|
+
0,
|
|
357
|
+
"No matches found. Ensure old_string matches file content exactly (including whitespace/indentation). Use read_file_lines to verify.",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def detect_line_ending(content: str) -> str:
|
|
362
|
+
"""Detect line ending style in content.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
content: File content to analyze
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
'\r\n' for Windows-style or '\n' for Unix-style
|
|
369
|
+
"""
|
|
370
|
+
return "\r\n" if "\r\n" in content else "\n"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def preserve_line_ending(original_content: str, modified_content: str) -> str:
|
|
374
|
+
"""Preserve original line endings in modified content.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
original_content: Original file content
|
|
378
|
+
modified_content: Modified content (with \n line endings)
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Modified content with original line ending style
|
|
382
|
+
"""
|
|
383
|
+
original_ending = detect_line_ending(original_content)
|
|
384
|
+
|
|
385
|
+
# If original had CRLF but modified has only LF, convert
|
|
386
|
+
if original_ending == "\r\n" and "\r\n" not in modified_content:
|
|
387
|
+
return modified_content.replace("\n", "\r\n")
|
|
388
|
+
|
|
389
|
+
# If original had LF but modified has CRLF, convert
|
|
390
|
+
if original_ending == "\n" and "\r\n" in modified_content:
|
|
391
|
+
return modified_content.replace("\r\n", "\n")
|
|
392
|
+
|
|
393
|
+
return modified_content
|