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.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- 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)")
|