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