tunacode-cli 0.1.21__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,311 @@
1
+ """Ripgrep binary management and execution utilities."""
2
+
3
+ import functools
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ @functools.lru_cache(maxsize=1)
12
+ def get_platform_identifier() -> tuple[str, str]:
13
+ """Get the current platform identifier.
14
+
15
+ Returns:
16
+ Tuple of (platform_key, system_name)
17
+ """
18
+ system = platform.system().lower()
19
+ machine = platform.machine().lower()
20
+
21
+ if system == "linux":
22
+ if machine in ["x86_64", "amd64"]:
23
+ return "x64-linux", system
24
+ elif machine in ["aarch64", "arm64"]:
25
+ return "arm64-linux", system
26
+ elif system == "darwin": # noqa: SIM102
27
+ if machine in ["x86_64", "amd64"]:
28
+ return "x64-darwin", system
29
+ elif machine in ["arm64", "aarch64"]:
30
+ return "arm64-darwin", system
31
+ elif system == "windows": # noqa: SIM102
32
+ if machine in ["x86_64", "amd64"]:
33
+ return "x64-win32", system
34
+
35
+ raise ValueError(f"Unsupported platform: {system} {machine}")
36
+
37
+
38
+ @functools.lru_cache(maxsize=1)
39
+ def get_ripgrep_binary_path() -> Path | None:
40
+ """Resolve the path to the ripgrep binary.
41
+
42
+ Resolution order:
43
+ 1. Environment variable override (TUNACODE_RIPGREP_PATH)
44
+ 2. System ripgrep (if newer or equal version)
45
+ 3. Bundled ripgrep binary
46
+ 4. None (fallback to Python-based search)
47
+
48
+ Returns:
49
+ Path to ripgrep binary or None if not available
50
+ """
51
+ # Check for environment variable override
52
+ env_path = os.environ.get("TUNACODE_RIPGREP_PATH")
53
+ if env_path:
54
+ path = Path(env_path)
55
+ if path.exists() and path.is_file():
56
+ return path
57
+
58
+ # Check for system ripgrep
59
+ system_rg = shutil.which("rg")
60
+ if system_rg:
61
+ system_rg_path = Path(system_rg)
62
+ if _check_ripgrep_version(system_rg_path):
63
+ return system_rg_path
64
+
65
+ # Check for bundled ripgrep
66
+ try:
67
+ platform_key, _ = get_platform_identifier()
68
+ binary_name = "rg.exe" if platform_key == "x64-win32" else "rg"
69
+
70
+ # Look for vendor directory relative to this file
71
+ vendor_dir = (
72
+ Path(__file__).parent.parent.parent.parent / "vendor" / "ripgrep" / platform_key
73
+ )
74
+ bundled_path = vendor_dir / binary_name
75
+
76
+ if bundled_path.exists():
77
+ return bundled_path
78
+ except Exception:
79
+ pass
80
+
81
+ return None
82
+
83
+
84
+ def _check_ripgrep_version(rg_path: Path, min_version: str = "13.0.0") -> bool:
85
+ """Check if ripgrep version meets minimum requirement.
86
+
87
+ Args:
88
+ rg_path: Path to ripgrep binary
89
+ min_version: Minimum required version
90
+
91
+ Returns:
92
+ True if version is sufficient, False otherwise
93
+ """
94
+ try:
95
+ result = subprocess.run(
96
+ [str(rg_path), "--version"],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=1,
100
+ )
101
+ if result.returncode == 0:
102
+ # Parse version from output like "ripgrep 14.1.1"
103
+ version_line = result.stdout.split("\n")[0]
104
+ version = version_line.split()[-1]
105
+
106
+ # Simple version comparison (works for x.y.z format)
107
+ current = tuple(map(int, version.split(".")))
108
+ required = tuple(map(int, min_version.split(".")))
109
+
110
+ return current >= required
111
+ except Exception:
112
+ pass
113
+
114
+ return False
115
+
116
+
117
+ class RipgrepExecutor:
118
+ """Wrapper for executing ripgrep commands with error handling."""
119
+
120
+ def __init__(self, binary_path: Path | None = None):
121
+ """Initialize the executor.
122
+
123
+ Args:
124
+ binary_path: Optional path to ripgrep binary
125
+ """
126
+ self.binary_path = binary_path or get_ripgrep_binary_path()
127
+ self._use_python_fallback = self.binary_path is None
128
+
129
+ def search(
130
+ self,
131
+ pattern: str,
132
+ path: str = ".",
133
+ *,
134
+ timeout: int = 10,
135
+ max_matches: int | None = None,
136
+ file_pattern: str | None = None,
137
+ case_insensitive: bool = False,
138
+ multiline: bool = False,
139
+ context_before: int = 0,
140
+ context_after: int = 0,
141
+ **kwargs,
142
+ ) -> list[str]:
143
+ """Execute a ripgrep search.
144
+
145
+ Args:
146
+ pattern: Search pattern (regex)
147
+ path: Directory or file to search
148
+ timeout: Maximum execution time in seconds
149
+ max_matches: Maximum number of matches to return
150
+ file_pattern: Glob pattern for files to include
151
+ case_insensitive: Case-insensitive search
152
+ multiline: Enable multiline mode
153
+ context_before: Lines of context before match
154
+ context_after: Lines of context after match
155
+ **kwargs: Additional ripgrep arguments
156
+
157
+ Returns:
158
+ List of matching lines or file paths
159
+ """
160
+ if self._use_python_fallback:
161
+ return self._python_fallback_search(
162
+ pattern, path, file_pattern=file_pattern, case_insensitive=case_insensitive
163
+ )
164
+
165
+ try:
166
+ cmd = [str(self.binary_path)]
167
+
168
+ # Add flags
169
+ if case_insensitive:
170
+ cmd.append("-i")
171
+ if multiline:
172
+ cmd.extend(["-U", "--multiline-dotall"])
173
+ if context_before > 0:
174
+ cmd.extend(["-B", str(context_before)])
175
+ if context_after > 0:
176
+ cmd.extend(["-A", str(context_after)])
177
+ if max_matches:
178
+ cmd.extend(["-m", str(max_matches)])
179
+ if file_pattern:
180
+ cmd.extend(["-g", file_pattern])
181
+
182
+ # Add pattern and path
183
+ cmd.extend([pattern, path])
184
+
185
+ result = subprocess.run(
186
+ cmd,
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=timeout,
190
+ )
191
+
192
+ if result.returncode in [0, 1]: # 0 = matches found, 1 = no matches
193
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
194
+ else:
195
+ return []
196
+
197
+ except subprocess.TimeoutExpired:
198
+ return []
199
+ except Exception:
200
+ return self._python_fallback_search(pattern, path, file_pattern=file_pattern)
201
+
202
+ def list_files(self, pattern: str, directory: str = ".") -> list[str]:
203
+ """List files matching a glob pattern using ripgrep.
204
+
205
+ Args:
206
+ pattern: Glob pattern for files
207
+ directory: Directory to search
208
+
209
+ Returns:
210
+ List of file paths
211
+ """
212
+ if self._use_python_fallback:
213
+ return self._python_fallback_list_files(pattern, directory)
214
+
215
+ try:
216
+ result = subprocess.run(
217
+ [str(self.binary_path), "--files", "-g", pattern, directory],
218
+ capture_output=True,
219
+ text=True,
220
+ timeout=5,
221
+ )
222
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
223
+ except Exception:
224
+ return self._python_fallback_list_files(pattern, directory)
225
+
226
+ def _python_fallback_search(
227
+ self,
228
+ pattern: str,
229
+ path: str,
230
+ file_pattern: str | None = None,
231
+ case_insensitive: bool = False,
232
+ ) -> list[str]:
233
+ """Python-based fallback search implementation."""
234
+ import re
235
+ from pathlib import Path
236
+
237
+ results = []
238
+ path_obj = Path(path)
239
+
240
+ # Compile regex pattern
241
+ flags = re.IGNORECASE if case_insensitive else 0
242
+ try:
243
+ regex = re.compile(pattern, flags)
244
+ except re.error:
245
+ return []
246
+
247
+ # Search files
248
+ if path_obj.is_file():
249
+ files = [path_obj]
250
+ else:
251
+ glob_pattern = file_pattern or "**/*"
252
+ files = list(path_obj.glob(glob_pattern))
253
+
254
+ for file_path in files:
255
+ if not file_path.is_file():
256
+ continue
257
+
258
+ try:
259
+ with file_path.open("r", encoding="utf-8", errors="ignore") as f:
260
+ for line_num, line in enumerate(f, 1):
261
+ if regex.search(line):
262
+ results.append(f"{file_path}:{line_num}:{line.strip()}")
263
+ except Exception: # nosec B112 - continue on file read errors is appropriate
264
+ continue
265
+
266
+ return results
267
+
268
+ def _python_fallback_list_files(self, pattern: str, directory: str) -> list[str]:
269
+ """Python-based fallback for listing files."""
270
+ from pathlib import Path
271
+
272
+ try:
273
+ base_path = Path(directory)
274
+ return [str(p) for p in base_path.glob(pattern) if p.is_file()]
275
+ except Exception:
276
+ return []
277
+
278
+
279
+ # Performance metrics collection
280
+ class RipgrepMetrics:
281
+ """Collect performance metrics for ripgrep operations."""
282
+
283
+ def __init__(self):
284
+ self.search_count = 0
285
+ self.total_search_time = 0.0
286
+ self.fallback_count = 0
287
+
288
+ def record_search(self, duration: float, used_fallback: bool = False):
289
+ """Record a search operation."""
290
+ self.search_count += 1
291
+ self.total_search_time += duration
292
+ if used_fallback:
293
+ self.fallback_count += 1
294
+
295
+ @property
296
+ def average_search_time(self) -> float:
297
+ """Get average search time."""
298
+ if self.search_count == 0:
299
+ return 0.0
300
+ return self.total_search_time / self.search_count
301
+
302
+ @property
303
+ def fallback_rate(self) -> float:
304
+ """Get fallback usage rate."""
305
+ if self.search_count == 0:
306
+ return 0.0
307
+ return self.fallback_count / self.search_count
308
+
309
+
310
+ # Global metrics instance
311
+ metrics = RipgrepMetrics()
@@ -0,0 +1,352 @@
1
+ """
2
+ Module: tunacode.tools.edit_replacers
3
+
4
+ Fuzzy string replacement strategies for the update_file tool.
5
+ Inspired by:
6
+ - https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
7
+ - https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
8
+ - https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/edit.ts
9
+
10
+ Each replacer is a generator that yields potential matches found in content.
11
+ Replacers are tried in order from strict to fuzzy until one succeeds.
12
+ """
13
+
14
+ from collections.abc import Callable, Generator
15
+
16
+ try:
17
+ from Levenshtein import distance as _levenshtein_c
18
+
19
+ _USE_C_LEVENSHTEIN = True
20
+ except ImportError:
21
+ _USE_C_LEVENSHTEIN = False
22
+
23
+ # Type alias for replacer functions
24
+ Replacer = Callable[[str, str], Generator[str, None, None]]
25
+
26
+ # Similarity thresholds for block anchor fallback matching
27
+ SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
28
+ MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
29
+
30
+
31
+ def levenshtein(a: str, b: str) -> int:
32
+ """Levenshtein edit distance between two strings."""
33
+ if _USE_C_LEVENSHTEIN:
34
+ return _levenshtein_c(a, b)
35
+
36
+ if not a or not b:
37
+ return max(len(a), len(b))
38
+
39
+ matrix = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
40
+ for i in range(len(a) + 1):
41
+ matrix[i][0] = i
42
+ for j in range(len(b) + 1):
43
+ matrix[0][j] = j
44
+
45
+ for i in range(1, len(a) + 1):
46
+ for j in range(1, len(b) + 1):
47
+ cost = 0 if a[i - 1] == b[j - 1] else 1
48
+ matrix[i][j] = min(
49
+ matrix[i - 1][j] + 1, # deletion
50
+ matrix[i][j - 1] + 1, # insertion
51
+ matrix[i - 1][j - 1] + cost, # substitution
52
+ )
53
+ return matrix[len(a)][len(b)]
54
+
55
+
56
+ def simple_replacer(content: str, find: str) -> Generator[str, None, None]:
57
+ """Exact match replacer - yields find string if present in content.
58
+
59
+ This is the strictest matcher and preserves backwards compatibility.
60
+ """
61
+ if find in content:
62
+ yield find
63
+
64
+
65
+ def line_trimmed_replacer(content: str, find: str) -> Generator[str, None, None]:
66
+ """Match lines ignoring leading/trailing whitespace per line.
67
+
68
+ Compares trimmed versions of each line but yields the original
69
+ content with its actual whitespace preserved.
70
+ """
71
+ # Use splitlines(keepends=True) to handle both \n and \r\n correctly
72
+ original_lines = content.splitlines(keepends=True)
73
+ search_lines = find.split("\n")
74
+
75
+ # Remove trailing empty line if present (common from copy-paste)
76
+ if search_lines and search_lines[-1] == "":
77
+ search_lines.pop()
78
+
79
+ if not search_lines:
80
+ return
81
+
82
+ for i in range(len(original_lines) - len(search_lines) + 1):
83
+ matches = True
84
+
85
+ for j in range(len(search_lines)):
86
+ original_trimmed = original_lines[i + j].strip()
87
+ search_trimmed = search_lines[j].strip()
88
+
89
+ if original_trimmed != search_trimmed:
90
+ matches = False
91
+ break
92
+
93
+ if matches:
94
+ # Join the matched lines and strip trailing line ending
95
+ matched_block = "".join(original_lines[i : i + len(search_lines)])
96
+ yield matched_block.rstrip("\r\n")
97
+
98
+
99
+ def indentation_flexible_replacer(content: str, find: str) -> Generator[str, None, None]:
100
+ """Match blocks after normalizing indentation.
101
+
102
+ Strips the minimum common indentation from both content blocks
103
+ and find string, then compares. Useful when the LLM gets the
104
+ indentation level wrong but the code structure is correct.
105
+ """
106
+
107
+ def remove_indentation(text: str) -> str:
108
+ """Remove common leading indentation from all lines."""
109
+ lines = text.split("\n")
110
+ non_empty_lines = [line for line in lines if line.strip()]
111
+
112
+ if not non_empty_lines:
113
+ return text
114
+
115
+ # Find minimum indentation
116
+ min_indent = float("inf")
117
+ for line in non_empty_lines:
118
+ stripped = line.lstrip()
119
+ if stripped:
120
+ indent = len(line) - len(stripped)
121
+ min_indent = min(min_indent, indent)
122
+
123
+ if min_indent == float("inf") or min_indent == 0:
124
+ return text
125
+
126
+ # Remove the common indentation
127
+ result_lines = []
128
+ for line in lines:
129
+ if line.strip():
130
+ result_lines.append(line[int(min_indent) :])
131
+ else:
132
+ result_lines.append(line)
133
+
134
+ return "\n".join(result_lines)
135
+
136
+ content_lines = content.split("\n")
137
+ find_lines = find.split("\n")
138
+
139
+ # Remove trailing empty line if present (MUST happen before normalization)
140
+ if find_lines and find_lines[-1] == "":
141
+ find_lines.pop()
142
+
143
+ if not find_lines:
144
+ return
145
+
146
+ # Normalize find AFTER trimming trailing empty line
147
+ normalized_find = remove_indentation("\n".join(find_lines))
148
+
149
+ # Pre-compute first line stripped for fail-fast check
150
+ first_find_stripped = find_lines[0].strip()
151
+
152
+ for i in range(len(content_lines) - len(find_lines) + 1):
153
+ # Fail-fast: skip if first line doesn't match (ignoring indentation)
154
+ if content_lines[i].strip() != first_find_stripped:
155
+ continue
156
+
157
+ block = "\n".join(content_lines[i : i + len(find_lines)])
158
+ if remove_indentation(block) == normalized_find:
159
+ yield block
160
+
161
+
162
+ def block_anchor_replacer(content: str, find: str) -> Generator[str, None, None]:
163
+ """Match using first/last lines as anchors, fuzzy-match middle.
164
+
165
+ This is the fuzziest matcher. It:
166
+ 1. Finds blocks where first and last lines match exactly (trimmed)
167
+ 2. Uses Levenshtein distance to score middle line similarity
168
+ 3. Returns best match if similarity exceeds threshold
169
+ """
170
+ # Use splitlines(keepends=True) to handle both \n and \r\n correctly
171
+ original_lines = content.splitlines(keepends=True)
172
+ search_lines = find.split("\n")
173
+
174
+ # Need at least 3 lines for anchor matching to make sense
175
+ if len(search_lines) < 3:
176
+ return
177
+
178
+ # Remove trailing empty line if present
179
+ if search_lines[-1] == "":
180
+ search_lines.pop()
181
+
182
+ if len(search_lines) < 3:
183
+ return
184
+
185
+ first_line_search = search_lines[0].strip()
186
+ last_line_search = search_lines[-1].strip()
187
+ search_block_size = len(search_lines)
188
+
189
+ # Build indices in O(n) instead of O(n^2) nested loop
190
+ first_indices = [
191
+ i for i, line in enumerate(original_lines) if line.strip() == first_line_search
192
+ ]
193
+ last_indices = [j for j, line in enumerate(original_lines) if line.strip() == last_line_search]
194
+
195
+ if not first_indices or not last_indices:
196
+ return
197
+
198
+ # Find valid pairs in O(k^2) where k << n typically
199
+ candidates: list[tuple[int, int]] = []
200
+ for i in first_indices:
201
+ for j in last_indices:
202
+ if j > i + 1: # Need at least one line between anchors
203
+ candidates.append((i, j))
204
+ break # Only first matching last anchor after this first
205
+
206
+ if not candidates:
207
+ return
208
+
209
+ def yield_block(start: int, end: int) -> str:
210
+ """Join lines from start to end (inclusive) and strip trailing line ending."""
211
+ return "".join(original_lines[start : end + 1]).rstrip("\r\n")
212
+
213
+ # Handle single candidate (use relaxed threshold)
214
+ if len(candidates) == 1:
215
+ start_line, end_line = candidates[0]
216
+ actual_block_size = end_line - start_line + 1
217
+
218
+ similarity = 0.0
219
+ lines_to_check = min(search_block_size - 2, actual_block_size - 2)
220
+
221
+ if lines_to_check > 0:
222
+ for j in range(1, min(search_block_size - 1, actual_block_size - 1)):
223
+ original_line = original_lines[start_line + j].strip()
224
+ search_line = search_lines[j].strip()
225
+ max_len = max(len(original_line), len(search_line))
226
+
227
+ if max_len == 0:
228
+ continue
229
+
230
+ distance = levenshtein(original_line, search_line)
231
+ similarity += (1 - distance / max_len) / lines_to_check
232
+
233
+ if similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD:
234
+ break
235
+ else:
236
+ # No middle lines to compare, accept based on anchors
237
+ similarity = 1.0
238
+
239
+ if similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD:
240
+ yield yield_block(start_line, end_line)
241
+ return
242
+
243
+ # Multiple candidates - find best match
244
+ best_match: tuple[int, int] | None = None
245
+ max_similarity = -1.0
246
+
247
+ for start_line, end_line in candidates:
248
+ actual_block_size = end_line - start_line + 1
249
+
250
+ similarity = 0.0
251
+ lines_to_check = min(search_block_size - 2, actual_block_size - 2)
252
+
253
+ if lines_to_check > 0:
254
+ for j in range(1, min(search_block_size - 1, actual_block_size - 1)):
255
+ original_line = original_lines[start_line + j].strip()
256
+ search_line = search_lines[j].strip()
257
+ max_len = max(len(original_line), len(search_line))
258
+
259
+ if max_len == 0:
260
+ continue
261
+
262
+ distance = levenshtein(original_line, search_line)
263
+ similarity += 1 - distance / max_len
264
+
265
+ similarity /= lines_to_check # Average similarity
266
+ else:
267
+ similarity = 1.0
268
+
269
+ if similarity > max_similarity:
270
+ max_similarity = similarity
271
+ best_match = (start_line, end_line)
272
+
273
+ # Check threshold for multiple candidates
274
+ if max_similarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD and best_match:
275
+ start_line, end_line = best_match
276
+ yield yield_block(start_line, end_line)
277
+
278
+
279
+ # Ordered list of replacers from strict to fuzzy
280
+ REPLACERS: list[Replacer] = [
281
+ simple_replacer,
282
+ line_trimmed_replacer,
283
+ indentation_flexible_replacer,
284
+ block_anchor_replacer,
285
+ ]
286
+
287
+
288
+ def replace(content: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
289
+ """Replace old_string with new_string using fuzzy matching.
290
+
291
+ Tries each replacer in order until one succeeds. Replacers are ordered
292
+ from strict (exact match) to fuzzy (anchor-based with Levenshtein).
293
+
294
+ Args:
295
+ content: The file content to modify
296
+ old_string: The text to find and replace
297
+ new_string: The replacement text
298
+ replace_all: If True, replace all occurrences; if False, require unique match
299
+
300
+ Returns:
301
+ Modified content with replacement applied
302
+
303
+ Raises:
304
+ ValueError: If old_string equals new_string
305
+ ValueError: If no match found after trying all strategies
306
+ ValueError: If multiple matches found and replace_all is False
307
+ """
308
+ if old_string == "":
309
+ raise ValueError("old_string cannot be empty; handle file overwrite separately")
310
+
311
+ if old_string == new_string:
312
+ raise ValueError("old_string and new_string must be different")
313
+
314
+ found_multiple = False
315
+ found_fuzzy_match_for_replace_all = False
316
+
317
+ for replacer_idx, replacer in enumerate(REPLACERS):
318
+ is_exact_match = replacer_idx == 0 # simple_replacer is exact
319
+
320
+ for search in replacer(content, old_string):
321
+ index = content.find(search)
322
+ if index == -1:
323
+ continue
324
+
325
+ if replace_all:
326
+ if not is_exact_match:
327
+ # Fuzzy match with replace_all is risky - track but don't use
328
+ found_fuzzy_match_for_replace_all = True
329
+ continue
330
+ return content.replace(search, new_string)
331
+
332
+ # Check for uniqueness - only replace if single occurrence
333
+ last_index = content.rfind(search)
334
+ if index != last_index:
335
+ found_multiple = True
336
+ continue # Try next replacer for potentially more specific match
337
+
338
+ return content[:index] + new_string + content[index + len(search) :]
339
+
340
+ if found_fuzzy_match_for_replace_all:
341
+ raise ValueError(
342
+ "replace_all=True only allowed with exact matches. "
343
+ "Fuzzy matching found a match but replace_all is risky for non-exact matches."
344
+ )
345
+
346
+ if found_multiple:
347
+ raise ValueError(
348
+ "Found multiple matches for old_string. "
349
+ "Provide more surrounding lines in old_string to identify the correct match."
350
+ )
351
+
352
+ raise ValueError("old_string not found in content (tried all fuzzy matching strategies)")