patch-chat 0.1.0__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.
Files changed (146) hide show
  1. patch/__init__.py +20 -0
  2. patch/__main__.py +4 -0
  3. patch/_version.py +24 -0
  4. patch/analytics.py +260 -0
  5. patch/args.py +955 -0
  6. patch/args_formatter.py +228 -0
  7. patch/coders/__init__.py +32 -0
  8. patch/coders/architect_coder.py +47 -0
  9. patch/coders/architect_prompts.py +40 -0
  10. patch/coders/ask_coder.py +9 -0
  11. patch/coders/ask_prompts.py +41 -0
  12. patch/coders/base_coder.py +2548 -0
  13. patch/coders/base_prompts.py +60 -0
  14. patch/coders/chat_chunks.py +64 -0
  15. patch/coders/context_coder.py +53 -0
  16. patch/coders/context_prompts.py +75 -0
  17. patch/coders/edit_apply.py +6 -0
  18. patch/coders/editblock_coder.py +620 -0
  19. patch/coders/editblock_fenced_coder.py +10 -0
  20. patch/coders/editblock_fenced_prompts.py +143 -0
  21. patch/coders/editblock_func_coder.py +141 -0
  22. patch/coders/editblock_func_prompts.py +27 -0
  23. patch/coders/editblock_prompts.py +172 -0
  24. patch/coders/editor_diff_fenced_coder.py +9 -0
  25. patch/coders/editor_diff_fenced_prompts.py +11 -0
  26. patch/coders/editor_editblock_coder.py +8 -0
  27. patch/coders/editor_editblock_prompts.py +18 -0
  28. patch/coders/editor_whole_coder.py +8 -0
  29. patch/coders/editor_whole_prompts.py +10 -0
  30. patch/coders/patch_coder.py +731 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +419 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1668 -0
  45. patch/copypaste.py +70 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/dump.py +29 -0
  49. patch/editor.py +147 -0
  50. patch/exceptions.py +113 -0
  51. patch/format_settings.py +47 -0
  52. patch/gui.py +545 -0
  53. patch/history.py +143 -0
  54. patch/io.py +1209 -0
  55. patch/linter.py +304 -0
  56. patch/llm.py +47 -0
  57. patch/main.py +1466 -0
  58. patch/mdstream.py +243 -0
  59. patch/models.py +1342 -0
  60. patch/onboarding.py +428 -0
  61. patch/openrouter.py +128 -0
  62. patch/prompts.py +61 -0
  63. patch/queries/tree-sitter-language-pack/README.md +7 -0
  64. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  65. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  66. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  67. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  68. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  69. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  70. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  71. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  72. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  73. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  74. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  75. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  76. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  77. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  78. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  79. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  80. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  81. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  82. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  83. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  84. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  85. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  86. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  87. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  88. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  89. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  90. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  91. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  92. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  93. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  94. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  95. patch/queries/tree-sitter-languages/README.md +24 -0
  96. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  97. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  98. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  99. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  100. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  101. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  102. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  103. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  104. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  105. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  106. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  107. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  108. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  109. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  110. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  111. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  112. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  113. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  114. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  115. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  116. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  117. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  118. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  119. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  120. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  121. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  122. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  123. patch/reasoning_tags.py +82 -0
  124. patch/repo.py +612 -0
  125. patch/repomap.py +859 -0
  126. patch/report.py +200 -0
  127. patch/resources/__init__.py +3 -0
  128. patch/resources/model-metadata.json +715 -0
  129. patch/resources/model-settings.yml +3128 -0
  130. patch/run_cmd.py +146 -0
  131. patch/scrape.py +316 -0
  132. patch/sendchat.py +61 -0
  133. patch/special.py +203 -0
  134. patch/urls.py +17 -0
  135. patch/utils.py +365 -0
  136. patch/versioncheck.py +113 -0
  137. patch/voice.py +200 -0
  138. patch/waiting.py +222 -0
  139. patch/watch.py +318 -0
  140. patch/watch_prompts.py +12 -0
  141. patch_chat-0.1.0.dist-info/METADATA +375 -0
  142. patch_chat-0.1.0.dist-info/RECORD +146 -0
  143. patch_chat-0.1.0.dist-info/WHEEL +5 -0
  144. patch_chat-0.1.0.dist-info/entry_points.txt +2 -0
  145. patch_chat-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  146. patch_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,731 @@
1
+ import pathlib
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ from typing import Dict, List, Optional, Tuple
5
+
6
+ from .base_coder import Coder, EditApplyError
7
+ from .patch_prompts import PatchPrompts
8
+
9
+
10
+ # --------------------------------------------------------------------------- #
11
+ # Domain objects & Exceptions (Adapted from apply_patch.py)
12
+ # --------------------------------------------------------------------------- #
13
+ class DiffError(ValueError):
14
+ """Any problem detected while parsing or applying a patch."""
15
+
16
+
17
+ class ActionType(str, Enum):
18
+ ADD = "Add"
19
+ DELETE = "Delete"
20
+ UPDATE = "Update"
21
+
22
+
23
+ @dataclass
24
+ class Chunk:
25
+ orig_index: int = -1 # Line number in the *original* file block where the change starts
26
+ del_lines: List[str] = field(default_factory=list)
27
+ ins_lines: List[str] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class PatchAction:
32
+ type: ActionType
33
+ path: str
34
+ # For ADD:
35
+ new_content: Optional[str] = None
36
+ # For UPDATE:
37
+ chunks: List[Chunk] = field(default_factory=list)
38
+ move_path: Optional[str] = None
39
+
40
+
41
+ # Type alias for the return type of get_edits
42
+ EditResult = Tuple[str, PatchAction]
43
+
44
+
45
+ @dataclass
46
+ class Patch:
47
+ actions: Dict[str, PatchAction] = field(default_factory=dict)
48
+ fuzz: int = 0 # Track fuzziness used during parsing
49
+
50
+
51
+ # --------------------------------------------------------------------------- #
52
+ # Helper functions (Adapted from apply_patch.py)
53
+ # --------------------------------------------------------------------------- #
54
+ def _norm(line: str) -> str:
55
+ """Strip CR so comparisons work for both LF and CRLF input."""
56
+ return line.rstrip("\r")
57
+
58
+
59
+ def find_context_core(lines: List[str], context: List[str], start: int) -> Tuple[int, int]:
60
+ """Finds context block, returns start index and fuzz level."""
61
+ if not context:
62
+ return start, 0
63
+
64
+ # Exact match
65
+ for i in range(start, len(lines) - len(context) + 1):
66
+ if lines[i : i + len(context)] == context:
67
+ return i, 0
68
+ # Rstrip match
69
+ norm_context = [s.rstrip() for s in context]
70
+ for i in range(start, len(lines) - len(context) + 1):
71
+ if [s.rstrip() for s in lines[i : i + len(context)]] == norm_context:
72
+ return i, 1 # Fuzz level 1
73
+ # Strip match
74
+ norm_context_strip = [s.strip() for s in context]
75
+ for i in range(start, len(lines) - len(context) + 1):
76
+ if [s.strip() for s in lines[i : i + len(context)]] == norm_context_strip:
77
+ return i, 100 # Fuzz level 100
78
+ return -1, 0
79
+
80
+
81
+ def find_context(lines: List[str], context: List[str], start: int, eof: bool) -> Tuple[int, int]:
82
+ """Finds context, handling EOF marker."""
83
+ if eof:
84
+ # If EOF marker, first try matching at the very end
85
+ if len(lines) >= len(context):
86
+ new_index, fuzz = find_context_core(lines, context, len(lines) - len(context))
87
+ if new_index != -1:
88
+ return new_index, fuzz
89
+ # If not found at end, search from `start` as fallback
90
+ new_index, fuzz = find_context_core(lines, context, start)
91
+ return new_index, fuzz + 10_000 # Add large fuzz penalty if EOF wasn't at end
92
+ # Normal case: search from `start`
93
+ return find_context_core(lines, context, start)
94
+
95
+
96
+ def peek_next_section(lines: List[str], index: int) -> Tuple[List[str], List[Chunk], int, bool]:
97
+ """
98
+ Parses one section (context, -, + lines) of an Update block.
99
+ Returns: (context_lines, chunks_in_section, next_index, is_eof)
100
+ """
101
+ context_lines: List[str] = []
102
+ del_lines: List[str] = []
103
+ ins_lines: List[str] = []
104
+ chunks: List[Chunk] = []
105
+ mode = "keep" # Start by expecting context lines
106
+ start_index = index
107
+
108
+ while index < len(lines):
109
+ line = lines[index]
110
+ norm_line = _norm(line)
111
+
112
+ # Check for section terminators
113
+ if norm_line.startswith(
114
+ (
115
+ "@@",
116
+ "*** End Patch",
117
+ "*** Update File:",
118
+ "*** Delete File:",
119
+ "*** Add File:",
120
+ "*** End of File", # Special terminator
121
+ )
122
+ ):
123
+ break
124
+ if norm_line == "***": # Legacy/alternative terminator? Handle just in case.
125
+ break
126
+ if norm_line.startswith("***"): # Invalid line
127
+ raise DiffError(f"Invalid patch line found in update section: {line}")
128
+
129
+ index += 1
130
+ last_mode = mode
131
+
132
+ # Determine line type and strip prefix
133
+ if line.startswith("+"):
134
+ mode = "add"
135
+ line_content = line[1:]
136
+ elif line.startswith("-"):
137
+ mode = "delete"
138
+ line_content = line[1:]
139
+ elif line.startswith(" "):
140
+ mode = "keep"
141
+ line_content = line[1:]
142
+ elif line.strip() == "": # Treat blank lines in patch as context ' '
143
+ mode = "keep"
144
+ line_content = "" # Keep it as a blank line
145
+ else:
146
+ # Assume lines without prefix are context if format is loose,
147
+ # but strict format requires ' '. Raise error for strictness.
148
+ raise DiffError(f"Invalid line prefix in update section: {line}")
149
+
150
+ # If mode changes from add/delete back to keep, finalize the previous chunk
151
+ if mode == "keep" and last_mode != "keep":
152
+ if del_lines or ins_lines:
153
+ chunks.append(
154
+ Chunk(
155
+ # orig_index is relative to the start of the *context* block found
156
+ orig_index=len(context_lines) - len(del_lines),
157
+ del_lines=del_lines,
158
+ ins_lines=ins_lines,
159
+ )
160
+ )
161
+ del_lines, ins_lines = [], []
162
+
163
+ # Collect lines based on mode
164
+ if mode == "delete":
165
+ del_lines.append(line_content)
166
+ context_lines.append(line_content) # Deleted lines are part of the original context
167
+ elif mode == "add":
168
+ ins_lines.append(line_content)
169
+ elif mode == "keep":
170
+ context_lines.append(line_content)
171
+
172
+ # Finalize any pending chunk at the end of the section
173
+ if del_lines or ins_lines:
174
+ chunks.append(
175
+ Chunk(
176
+ orig_index=len(context_lines) - len(del_lines),
177
+ del_lines=del_lines,
178
+ ins_lines=ins_lines,
179
+ )
180
+ )
181
+
182
+ # Check for EOF marker
183
+ is_eof = False
184
+ if index < len(lines) and _norm(lines[index]) == "*** End of File":
185
+ index += 1
186
+ is_eof = True
187
+
188
+ if index == start_index and not is_eof: # Should not happen if patch is well-formed
189
+ raise DiffError("Empty patch section found.")
190
+
191
+ return context_lines, chunks, index, is_eof
192
+
193
+
194
+ def identify_files_needed(text: str) -> List[str]:
195
+ """Extracts file paths from Update and Delete actions."""
196
+ lines = text.splitlines()
197
+ paths = set()
198
+ for line in lines:
199
+ norm_line = _norm(line)
200
+ if norm_line.startswith("*** Update File: "):
201
+ paths.add(norm_line[len("*** Update File: ") :].strip())
202
+ elif norm_line.startswith("*** Delete File: "):
203
+ paths.add(norm_line[len("*** Delete File: ") :].strip())
204
+ return list(paths)
205
+
206
+
207
+ # --------------------------------------------------------------------------- #
208
+ # PatchCoder Class Implementation
209
+ # --------------------------------------------------------------------------- #
210
+ class PatchCoder(Coder):
211
+ """
212
+ A coder that uses a custom patch format for code modifications,
213
+ inspired by the format described in tmp.gpt41edits.txt.
214
+ Applies patches using logic adapted from the reference apply_patch.py script.
215
+ """
216
+
217
+ edit_format = "patch"
218
+ gpt_prompts = PatchPrompts()
219
+
220
+ def get_edit_paths(self, edit):
221
+ paths = super().get_edit_paths(edit)
222
+ move_path = edit[1].move_path
223
+ if move_path and move_path not in paths:
224
+ paths.append(move_path)
225
+ return paths
226
+
227
+ def get_edits(self) -> List[EditResult]:
228
+ """
229
+ Parses the LLM response content (containing the patch) into a list of
230
+ tuples, where each tuple contains the file path and the PatchAction object.
231
+ """
232
+ content = self.partial_response_content
233
+ if not content or not content.strip():
234
+ return []
235
+
236
+ # Check for patch sentinels
237
+ lines = content.splitlines()
238
+ if (
239
+ len(lines) < 2
240
+ or not _norm(lines[0]).startswith("*** Begin Patch")
241
+ # Allow flexible end, might be EOF or just end of stream
242
+ # or _norm(lines[-1]) != "*** End Patch"
243
+ ):
244
+ # Tolerate missing sentinels if content looks like a patch action
245
+ is_patch_like = any(
246
+ _norm(line).startswith(
247
+ ("@@", "*** Update File:", "*** Add File:", "*** Delete File:")
248
+ )
249
+ for line in lines
250
+ )
251
+ if not is_patch_like:
252
+ # If it doesn't even look like a patch, return empty
253
+ self.io.tool_warning("Response does not appear to be in patch format.")
254
+ return []
255
+ # If it looks like a patch but lacks sentinels, try parsing anyway but warn.
256
+ self.io.tool_warning(
257
+ "Patch format warning: Missing '*** Begin Patch'/'*** End Patch' sentinels."
258
+ )
259
+ start_index = 0
260
+ else:
261
+ start_index = 1 # Skip "*** Begin Patch"
262
+
263
+ # Identify files needed for context lookups during parsing
264
+ needed_paths = identify_files_needed(content)
265
+ current_files: Dict[str, str] = {}
266
+ for rel_path in needed_paths:
267
+ abs_path = self.abs_root_path(rel_path)
268
+ try:
269
+ # Use io.read_text to handle potential errors/encodings
270
+ file_content = self.io.read_text(abs_path)
271
+ if file_content is None:
272
+ raise DiffError(
273
+ f"File referenced in patch not found or could not be read: {rel_path}"
274
+ )
275
+ current_files[rel_path] = file_content
276
+ except FileNotFoundError:
277
+ raise DiffError(f"File referenced in patch not found: {rel_path}")
278
+ except IOError as e:
279
+ raise DiffError(f"Error reading file {rel_path}: {e}")
280
+
281
+ try:
282
+ # Parse the patch text using adapted logic
283
+ patch_obj = self._parse_patch_text(lines, start_index, current_files)
284
+ # Convert Patch object actions dict to a list of tuples (path, action)
285
+ # for compatibility with the base Coder's prepare_to_edit method.
286
+ results = []
287
+ for path, action in patch_obj.actions.items():
288
+ results.append((path, action))
289
+ return results
290
+ except DiffError as e:
291
+ # Raise as ValueError for consistency with other coders' error handling
292
+ raise ValueError(f"Error parsing patch content: {e}")
293
+ except Exception as e:
294
+ # Catch unexpected errors during parsing
295
+ raise ValueError(f"Unexpected error parsing patch: {e}")
296
+
297
+ def _parse_patch_text(
298
+ self, lines: List[str], start_index: int, current_files: Dict[str, str]
299
+ ) -> Patch:
300
+ """
301
+ Parses patch content lines into a Patch object.
302
+ Adapted from the Parser class in apply_patch.py.
303
+ """
304
+ patch = Patch()
305
+ index = start_index
306
+ fuzz_accumulator = 0
307
+
308
+ while index < len(lines):
309
+ line = lines[index]
310
+ norm_line = _norm(line)
311
+
312
+ if norm_line == "*** End Patch":
313
+ index += 1
314
+ break # Successfully reached end
315
+
316
+ # ---------- UPDATE ---------- #
317
+ if norm_line.startswith("*** Update File: "):
318
+ path = norm_line[len("*** Update File: ") :].strip()
319
+ index += 1
320
+ if not path:
321
+ raise DiffError("Update File action missing path.")
322
+
323
+ # Optional move target
324
+ move_to = None
325
+ if index < len(lines) and _norm(lines[index]).startswith("*** Move to: "):
326
+ move_to = _norm(lines[index])[len("*** Move to: ") :].strip()
327
+ index += 1
328
+ if not move_to:
329
+ raise DiffError("Move to action missing path.")
330
+
331
+ if path not in current_files:
332
+ raise DiffError(f"Update File Error - missing file content for: {path}")
333
+
334
+ file_content = current_files[path]
335
+
336
+ existing_action = patch.actions.get(path)
337
+ if existing_action is not None:
338
+ # Merge additional UPDATE block into the existing one
339
+ if existing_action.type != ActionType.UPDATE:
340
+ raise DiffError(f"Conflicting actions for file: {path}")
341
+
342
+ new_action, index, fuzz = self._parse_update_file_sections(
343
+ lines, index, file_content
344
+ )
345
+ existing_action.chunks.extend(new_action.chunks)
346
+
347
+ if move_to:
348
+ if existing_action.move_path and existing_action.move_path != move_to:
349
+ raise DiffError(f"Conflicting move targets for file: {path}")
350
+ existing_action.move_path = move_to
351
+ fuzz_accumulator += fuzz
352
+ else:
353
+ # First UPDATE block for this file
354
+ action, index, fuzz = self._parse_update_file_sections(
355
+ lines, index, file_content
356
+ )
357
+ action.path = path
358
+ action.move_path = move_to
359
+ patch.actions[path] = action
360
+ fuzz_accumulator += fuzz
361
+ continue
362
+
363
+ # ---------- DELETE ---------- #
364
+ elif norm_line.startswith("*** Delete File: "):
365
+ path = norm_line[len("*** Delete File: ") :].strip()
366
+ index += 1
367
+ if not path:
368
+ raise DiffError("Delete File action missing path.")
369
+ existing_action = patch.actions.get(path)
370
+ if existing_action:
371
+ if existing_action.type == ActionType.DELETE:
372
+ # Duplicate delete – ignore the extra block
373
+ self.io.tool_warning(f"Duplicate delete action for file: {path} ignored.")
374
+ continue
375
+ else:
376
+ raise DiffError(f"Conflicting actions for file: {path}")
377
+ if path not in current_files:
378
+ raise DiffError(
379
+ f"Delete File Error - file not found: {path}"
380
+ ) # Check against known files
381
+
382
+ patch.actions[path] = PatchAction(type=ActionType.DELETE, path=path)
383
+ continue
384
+
385
+ # ---------- ADD ---------- #
386
+ elif norm_line.startswith("*** Add File: "):
387
+ path = norm_line[len("*** Add File: ") :].strip()
388
+ index += 1
389
+ if not path:
390
+ raise DiffError("Add File action missing path.")
391
+ if path in patch.actions:
392
+ raise DiffError(f"Duplicate action for file: {path}")
393
+ # Check if file exists in the context provided (should not for Add).
394
+ # Note: We only have needed files, a full check requires FS access.
395
+ # if path in current_files:
396
+ # raise DiffError(f"Add File Error - file already exists: {path}")
397
+
398
+ action, index = self._parse_add_file_content(lines, index)
399
+ action.path = path # Ensure path is set
400
+ patch.actions[path] = action
401
+ continue
402
+
403
+ # If we are here, the line is unexpected
404
+ # Allow blank lines between actions
405
+ if not norm_line.strip():
406
+ index += 1
407
+ continue
408
+
409
+ raise DiffError(f"Unknown or misplaced line while parsing patch: {line}")
410
+
411
+ # Check if we consumed the whole input or stopped early
412
+ # Tolerate missing "*** End Patch" if we processed actions
413
+ # if index < len(lines) and _norm(lines[index-1]) != "*** End Patch":
414
+ # raise DiffError("Patch parsing finished unexpectedly before end of input.")
415
+
416
+ patch.fuzz = fuzz_accumulator
417
+ return patch
418
+
419
+ def _parse_update_file_sections(
420
+ self, lines: List[str], index: int, file_content: str
421
+ ) -> Tuple[PatchAction, int, int]:
422
+ """Parses all sections (@@, context, -, +) for a single Update File action."""
423
+ action = PatchAction(type=ActionType.UPDATE, path="") # Path set by caller
424
+ orig_lines = file_content.splitlines() # Use splitlines for consistency
425
+ current_file_index = 0 # Track position in original file content
426
+ total_fuzz = 0
427
+
428
+ while index < len(lines):
429
+ norm_line = _norm(lines[index])
430
+ # Check for terminators for *this* file update
431
+ if norm_line.startswith(
432
+ (
433
+ "*** End Patch",
434
+ "*** Update File:",
435
+ "*** Delete File:",
436
+ "*** Add File:",
437
+ )
438
+ ):
439
+ break # End of this file's update section
440
+
441
+ # Handle @@ scope lines (optional)
442
+ scope_lines = []
443
+ while index < len(lines) and _norm(lines[index]).startswith("@@"):
444
+ scope_line_content = lines[index][len("@@") :].strip()
445
+ if scope_line_content: # Ignore empty @@ lines?
446
+ scope_lines.append(scope_line_content)
447
+ index += 1
448
+
449
+ # Find the scope in the original file if specified
450
+ if scope_lines:
451
+ # Simple scope finding: search from current position
452
+ # A more robust finder could handle nested scopes like the reference @@ @@
453
+ found_scope = False
454
+ temp_index = current_file_index
455
+ while temp_index < len(orig_lines):
456
+ # Check if all scope lines match sequentially from temp_index
457
+ match = True
458
+ for i, scope in enumerate(scope_lines):
459
+ if (
460
+ temp_index + i >= len(orig_lines)
461
+ or _norm(orig_lines[temp_index + i]).strip() != scope
462
+ ):
463
+ match = False
464
+ break
465
+ if match:
466
+ current_file_index = temp_index + len(scope_lines)
467
+ found_scope = True
468
+ break
469
+ temp_index += 1
470
+
471
+ if not found_scope:
472
+ # Try fuzzy scope matching (strip whitespace)
473
+ temp_index = current_file_index
474
+ while temp_index < len(orig_lines):
475
+ match = True
476
+ for i, scope in enumerate(scope_lines):
477
+ if (
478
+ temp_index + i >= len(orig_lines)
479
+ or _norm(orig_lines[temp_index + i]).strip() != scope.strip()
480
+ ):
481
+ match = False
482
+ break
483
+ if match:
484
+ current_file_index = temp_index + len(scope_lines)
485
+ found_scope = True
486
+ total_fuzz += 1 # Add fuzz for scope match difference
487
+ break
488
+ temp_index += 1
489
+
490
+ if not found_scope:
491
+ scope_txt = "\n".join(scope_lines)
492
+ raise DiffError(f"Could not find scope context:\n{scope_txt}")
493
+
494
+ # Peek and parse the next context/change section
495
+ context_block, chunks_in_section, next_index, is_eof = peek_next_section(lines, index)
496
+
497
+ # Find where this context block appears in the original file
498
+ found_index, fuzz = find_context(orig_lines, context_block, current_file_index, is_eof)
499
+ total_fuzz += fuzz
500
+
501
+ if found_index == -1:
502
+ ctx_txt = "\n".join(context_block)
503
+ marker = "*** End of File" if is_eof else ""
504
+ raise DiffError(
505
+ f"Could not find patch context {marker} starting near line"
506
+ f" {current_file_index}:\n{ctx_txt}"
507
+ )
508
+
509
+ # Adjust chunk original indices to be absolute within the file
510
+ for chunk in chunks_in_section:
511
+ # chunk.orig_index from peek is relative to context_block start
512
+ # We need it relative to the file start
513
+ chunk.orig_index += found_index
514
+ action.chunks.append(chunk)
515
+
516
+ # Advance file index past the matched context block
517
+ current_file_index = found_index + len(context_block)
518
+ # Advance line index past the processed section in the patch
519
+ index = next_index
520
+
521
+ return action, index, total_fuzz
522
+
523
+ def _parse_add_file_content(self, lines: List[str], index: int) -> Tuple[PatchAction, int]:
524
+ """Parses the content (+) lines for an Add File action."""
525
+ added_lines: List[str] = []
526
+ while index < len(lines):
527
+ line = lines[index]
528
+ norm_line = _norm(line)
529
+ # Stop if we hit another action or end marker
530
+ if norm_line.startswith(
531
+ (
532
+ "*** End Patch",
533
+ "*** Update File:",
534
+ "*** Delete File:",
535
+ "*** Add File:",
536
+ )
537
+ ):
538
+ break
539
+
540
+ # Expect lines to start with '+'
541
+ if not line.startswith("+"):
542
+ # Tolerate blank lines? Or require '+'? Reference implies '+' required.
543
+ if norm_line.strip() == "":
544
+ # Treat blank line as adding a blank line
545
+ added_lines.append("")
546
+ else:
547
+ raise DiffError(f"Invalid Add File line (missing '+'): {line}")
548
+ else:
549
+ added_lines.append(line[1:]) # Strip leading '+'
550
+
551
+ index += 1
552
+
553
+ action = PatchAction(type=ActionType.ADD, path="", new_content="\n".join(added_lines))
554
+ return action, index
555
+
556
+ def apply_edits(self, edits: List[PatchAction]):
557
+ """
558
+ Applies the parsed PatchActions to the corresponding files.
559
+ """
560
+ if not edits:
561
+ return
562
+
563
+ # Group edits by original path? Not strictly needed if processed sequentially.
564
+
565
+ errors = []
566
+ applied_paths = set()
567
+
568
+ # Edits are now List[Tuple[str, PatchAction]]
569
+ for _path_tuple_element, action in edits:
570
+ # action is the PatchAction object
571
+ # action.path is the canonical path within the action logic
572
+ full_path = self.abs_root_path(action.path)
573
+ path_obj = pathlib.Path(full_path)
574
+
575
+ # Keep this as a final defense even though prepare_to_edit also checks move_path.
576
+ if action.move_path and not self.path_within_root(self.abs_root_path(action.move_path)):
577
+ raise ValueError(
578
+ f"Move target {action.move_path} is outside the repository: {self.root}"
579
+ )
580
+
581
+ try:
582
+ if action.type == ActionType.ADD:
583
+ # Check existence *before* writing
584
+ if path_obj.exists():
585
+ raise DiffError(f"ADD Error: File already exists: {action.path}")
586
+ if action.new_content is None:
587
+ # Parser should ensure this doesn't happen
588
+ raise DiffError(f"ADD change for {action.path} has no content")
589
+
590
+ self.io.tool_output(f"Adding {action.path}")
591
+ if not self.dry_run:
592
+ path_obj.parent.mkdir(parents=True, exist_ok=True)
593
+ # Ensure single trailing newline, matching reference behavior
594
+ content_to_write = action.new_content
595
+ if not content_to_write.endswith("\n"):
596
+ content_to_write += "\n"
597
+ self.io.write_text(full_path, content_to_write)
598
+ applied_paths.add(action.path)
599
+
600
+ elif action.type == ActionType.DELETE:
601
+ self.io.tool_output(f"Deleting {action.path}")
602
+ if not path_obj.exists():
603
+ self.io.tool_warning(
604
+ f"DELETE Warning: File not found, skipping: {action.path}"
605
+ )
606
+ else:
607
+ if not self.dry_run:
608
+ path_obj.unlink()
609
+ applied_paths.add(action.path)
610
+
611
+ elif action.type == ActionType.UPDATE:
612
+ if not path_obj.exists():
613
+ raise DiffError(f"UPDATE Error: File does not exist: {action.path}")
614
+
615
+ current_content = self.io.read_text(full_path)
616
+ if current_content is None:
617
+ # Should have been caught during parsing if file was needed
618
+ raise DiffError(f"Could not read file for UPDATE: {action.path}")
619
+
620
+ # Apply the update logic using the parsed chunks
621
+ new_content = self._apply_update(current_content, action, action.path)
622
+
623
+ target_full_path = (
624
+ self.abs_root_path(action.move_path) if action.move_path else full_path
625
+ )
626
+ target_path_obj = pathlib.Path(target_full_path)
627
+
628
+ if action.move_path:
629
+ self.io.tool_output(
630
+ f"Updating and moving {action.path} to {action.move_path}"
631
+ )
632
+ # Check if target exists before overwriting/moving
633
+ if target_path_obj.exists() and full_path != target_full_path:
634
+ self.io.tool_warning(
635
+ "UPDATE Warning: Target file for move already exists, overwriting:"
636
+ f" {action.move_path}"
637
+ )
638
+ else:
639
+ self.io.tool_output(f"Updating {action.path}")
640
+
641
+ # Ensure parent directory exists for target
642
+ if not self.dry_run:
643
+ target_path_obj.parent.mkdir(parents=True, exist_ok=True)
644
+ self.io.write_text(target_full_path, new_content)
645
+ applied_paths.add(action.path)
646
+ if action.move_path:
647
+ applied_paths.add(action.move_path)
648
+
649
+ # Remove original file *after* successful write to new location if moved
650
+ if action.move_path and full_path != target_full_path and not self.dry_run:
651
+ path_obj.unlink()
652
+
653
+ else:
654
+ # Should not happen
655
+ raise DiffError(f"Unknown action type encountered: {action.type}")
656
+
657
+ except (DiffError, FileNotFoundError, IOError, OSError) as e:
658
+ errors.append(f"Error applying action '{action.type}' to {action.path}: {e}")
659
+ except Exception as e:
660
+ errors.append(
661
+ f"Unexpected error applying action '{action.type}' to {action.path}: {e}"
662
+ )
663
+
664
+ if errors:
665
+ raise EditApplyError("\n".join(errors), applied_paths)
666
+
667
+ def _apply_update(self, text: str, action: PatchAction, path: str) -> str:
668
+ """
669
+ Applies UPDATE chunks to the given text content.
670
+ Adapted from _get_updated_file in apply_patch.py.
671
+ """
672
+ if action.type is not ActionType.UPDATE:
673
+ # Should not be called otherwise, but check for safety
674
+ raise DiffError("_apply_update called with non-update action")
675
+
676
+ orig_lines = text.splitlines() # Use splitlines to handle endings consistently
677
+ dest_lines: List[str] = []
678
+ current_orig_line_idx = 0 # Tracks index in orig_lines processed so far
679
+
680
+ # Sort chunks by their original index to apply them sequentially
681
+ sorted_chunks = sorted(action.chunks, key=lambda c: c.orig_index)
682
+
683
+ for chunk in sorted_chunks:
684
+ # chunk.orig_index is the absolute line number where the change starts
685
+ # (where the first deleted line was, or where inserted lines go if no deletes)
686
+ chunk_start_index = chunk.orig_index
687
+
688
+ if chunk_start_index < current_orig_line_idx:
689
+ # This indicates overlapping chunks or incorrect indices from parsing
690
+ raise DiffError(
691
+ f"{path}: Overlapping or out-of-order chunk detected."
692
+ f" Current index {current_orig_line_idx}, chunk starts at {chunk_start_index}."
693
+ )
694
+
695
+ # Add lines from original file between the last chunk and this one
696
+ dest_lines.extend(orig_lines[current_orig_line_idx:chunk_start_index])
697
+
698
+ # Verify that the lines to be deleted actually match the original file content
699
+ # (The parser should have used find_context, but double-check here)
700
+ num_del = len(chunk.del_lines)
701
+ actual_deleted_lines = orig_lines[chunk_start_index : chunk_start_index + num_del]
702
+
703
+ # Use the same normalization as find_context_core for comparison robustness
704
+ norm_chunk_del = [_norm(s).strip() for s in chunk.del_lines]
705
+ norm_actual_del = [_norm(s).strip() for s in actual_deleted_lines]
706
+
707
+ if norm_chunk_del != norm_actual_del:
708
+ # This indicates the context matching failed or the file changed since parsing
709
+ # Provide detailed error message
710
+ expected_str = "\n".join(f"- {s}" for s in chunk.del_lines)
711
+ actual_str = "\n".join(f" {s}" for s in actual_deleted_lines)
712
+ raise DiffError(
713
+ f"{path}: Mismatch applying patch near line {chunk_start_index + 1}.\n"
714
+ f"Expected lines to remove:\n{expected_str}\n"
715
+ f"Found lines in file:\n{actual_str}"
716
+ )
717
+
718
+ # Add the inserted lines from the chunk
719
+ dest_lines.extend(chunk.ins_lines)
720
+
721
+ # Advance the original line index past the lines processed (deleted lines)
722
+ current_orig_line_idx = chunk_start_index + num_del
723
+
724
+ # Add any remaining lines from the original file after the last chunk
725
+ dest_lines.extend(orig_lines[current_orig_line_idx:])
726
+
727
+ # Join lines and ensure a single trailing newline
728
+ result = "\n".join(dest_lines)
729
+ if result or orig_lines: # Add newline unless result is empty and original was empty
730
+ result += "\n"
731
+ return result