vamp-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.
- vamp_cli/__init__.py +0 -0
- vamp_cli/agent/__init__.py +0 -0
- vamp_cli/agent/context_sync.py +72 -0
- vamp_cli/agent/graph.py +159 -0
- vamp_cli/agent/instructions.py +18 -0
- vamp_cli/agent/nodes.py +375 -0
- vamp_cli/agent/roles.py +8 -0
- vamp_cli/agent/runtime/__init__.py +0 -0
- vamp_cli/agent/runtime/runtime.py +133 -0
- vamp_cli/agent/state.py +55 -0
- vamp_cli/agent/status.py +17 -0
- vamp_cli/config/__init__.py +0 -0
- vamp_cli/config/loader.py +152 -0
- vamp_cli/config/schema.py +82 -0
- vamp_cli/llm/__init__.py +0 -0
- vamp_cli/llm/adapter.py +52 -0
- vamp_cli/llm/adapter_registry.py +26 -0
- vamp_cli/llm/debug_callbacks.py +18 -0
- vamp_cli/llm/router.py +247 -0
- vamp_cli/llm/usage.py +170 -0
- vamp_cli/logging_setup/logging_setup.py +37 -0
- vamp_cli/main.py +1023 -0
- vamp_cli/persistence/__init__.py +29 -0
- vamp_cli/persistence/compaction.py +372 -0
- vamp_cli/persistence/emitter.py +371 -0
- vamp_cli/persistence/event_log.py +303 -0
- vamp_cli/persistence/fork.py +81 -0
- vamp_cli/persistence/loader.py +616 -0
- vamp_cli/persistence/transcript.py +160 -0
- vamp_cli/prompts/prompts.py +182 -0
- vamp_cli/sandbox/__init__.py +0 -0
- vamp_cli/sandbox/base.py +22 -0
- vamp_cli/sandbox/config.py +16 -0
- vamp_cli/sandbox/docker.py +293 -0
- vamp_cli/sandbox/manager.py +31 -0
- vamp_cli/sandbox/result.py +14 -0
- vamp_cli/sandbox/terminal.py +151 -0
- vamp_cli/tools/__init__.py +0 -0
- vamp_cli/tools/base.py +44 -0
- vamp_cli/tools/diagnostic.py +120 -0
- vamp_cli/tools/execute_cmd.py +16 -0
- vamp_cli/tools/files.py +652 -0
- vamp_cli/tools/registry.py +163 -0
- vamp_cli/tools/tools_classes.py +622 -0
- vamp_cli/utils/__init__.py +0 -0
- vamp_cli/utils/error_codes.py +7 -0
- vamp_cli/utils/ignore.py +36 -0
- vamp_cli/utils/sha_256.py +32 -0
- vamp_cli/utils/token_tracker.py +31 -0
- vamp_cli/workspace/__init__.py +0 -0
- vamp_cli/workspace/workspace.py +16 -0
- vamp_cli-0.1.0.dist-info/METADATA +317 -0
- vamp_cli-0.1.0.dist-info/RECORD +57 -0
- vamp_cli-0.1.0.dist-info/WHEEL +5 -0
- vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
vamp_cli/tools/files.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import fnmatch
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Any
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from vamp_cli.utils.ignore import IgnoreMatcher
|
|
9
|
+
from vamp_cli.workspace.workspace import Workspace
|
|
10
|
+
from vamp_cli.utils.error_codes import Error_codes
|
|
11
|
+
from vamp_cli.tools.diagnostic import DiagnosticTools
|
|
12
|
+
|
|
13
|
+
class FileTools():
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
workspace: Workspace,
|
|
17
|
+
ignore: IgnoreMatcher,
|
|
18
|
+
diagnostic: DiagnosticTools
|
|
19
|
+
) -> None:
|
|
20
|
+
self.workspace = workspace
|
|
21
|
+
self.ignore = ignore
|
|
22
|
+
self.diagnostic = diagnostic
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_dir(
|
|
26
|
+
self,
|
|
27
|
+
path: str,
|
|
28
|
+
max_depth: int = 2
|
|
29
|
+
) -> dict[str, str | list[tuple[str, str]]]:
|
|
30
|
+
|
|
31
|
+
res_list = []
|
|
32
|
+
_path = self.workspace.safe_path(path)
|
|
33
|
+
|
|
34
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
35
|
+
return {
|
|
36
|
+
"parent": path,
|
|
37
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
res_list = _walk_dir(root=_path, ignore=self.ignore, max_depth=max_depth, res=res_list)
|
|
41
|
+
return {
|
|
42
|
+
"parent_path": path,
|
|
43
|
+
"list": res_list
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_file(
|
|
48
|
+
self,
|
|
49
|
+
path: str,
|
|
50
|
+
start: int | None = None,
|
|
51
|
+
end: int | None = None
|
|
52
|
+
) -> dict[str, str | int]:
|
|
53
|
+
_path = self.workspace.safe_path(path)
|
|
54
|
+
|
|
55
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
56
|
+
return {
|
|
57
|
+
"parent": path,
|
|
58
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if not Path(_path).is_file():
|
|
62
|
+
return {
|
|
63
|
+
"parent": path,
|
|
64
|
+
"error": Error_codes.NOT_FILE_ERROR.value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
MAX_RANGE = 500
|
|
68
|
+
start_line = max(1, start) if start is not None else 1
|
|
69
|
+
|
|
70
|
+
if end is not None:
|
|
71
|
+
end_line = min(max(end, start_line), start_line + MAX_RANGE - 1)
|
|
72
|
+
else:
|
|
73
|
+
end_line = start_line + MAX_RANGE - 1
|
|
74
|
+
|
|
75
|
+
lines = []
|
|
76
|
+
try:
|
|
77
|
+
total_bytes = os.path.getsize(_path)
|
|
78
|
+
total_lines = 0
|
|
79
|
+
# TODO: v2: cache file metadata / line count
|
|
80
|
+
# invalidate cache after mutations
|
|
81
|
+
# possibly use more efficient random-access line indexing
|
|
82
|
+
|
|
83
|
+
with open(_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
84
|
+
for curr_line, line in enumerate(f, start=1):
|
|
85
|
+
if start_line <= curr_line <= end_line:
|
|
86
|
+
lines.append(f"{curr_line}: {line}")
|
|
87
|
+
total_lines = curr_line
|
|
88
|
+
|
|
89
|
+
content = "".join(lines)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"path": path,
|
|
93
|
+
"start": start_line,
|
|
94
|
+
"end": min(end_line, total_lines) if total_lines > 0 else end_line,
|
|
95
|
+
"total_line": total_lines,
|
|
96
|
+
"content": content,
|
|
97
|
+
"file_size_bytes": total_bytes
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
return {
|
|
102
|
+
"parent": path,
|
|
103
|
+
"error": Error_codes.FILE_NOT_FOUND_ERROR.value
|
|
104
|
+
}
|
|
105
|
+
except Exception as e:
|
|
106
|
+
return {
|
|
107
|
+
"parent": path,
|
|
108
|
+
"error": f"Failed to read file: {str(e)}"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def read_many_files(
|
|
112
|
+
self,
|
|
113
|
+
paths: list[str],
|
|
114
|
+
start: int | None = None,
|
|
115
|
+
end: int | None = None
|
|
116
|
+
) -> dict[str, list[dict[str, str | int]] | str]:
|
|
117
|
+
"""
|
|
118
|
+
Read multiple files in a single call.
|
|
119
|
+
|
|
120
|
+
Accepts a list of file paths and optional line range parameters.
|
|
121
|
+
For each file path, validates it's within workspace scope.
|
|
122
|
+
Returns a structured result containing the content of all requested files
|
|
123
|
+
with their paths, line ranges, and any errors for files that couldn't be read.
|
|
124
|
+
"""
|
|
125
|
+
results: list[dict[str, str | int]] = []
|
|
126
|
+
|
|
127
|
+
for path in paths:
|
|
128
|
+
_path = self.workspace.safe_path(path)
|
|
129
|
+
|
|
130
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
131
|
+
results.append({
|
|
132
|
+
"path": path,
|
|
133
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value
|
|
134
|
+
})
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if not Path(_path).is_file():
|
|
138
|
+
results.append({
|
|
139
|
+
"path": path,
|
|
140
|
+
"error": Error_codes.NOT_FILE_ERROR.value
|
|
141
|
+
})
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
MAX_RANGE = 500
|
|
145
|
+
start_line = max(1, start) if start is not None else 1
|
|
146
|
+
|
|
147
|
+
if end is not None:
|
|
148
|
+
end_line = min(max(end, start_line), start_line + MAX_RANGE - 1)
|
|
149
|
+
else:
|
|
150
|
+
end_line = start_line + MAX_RANGE - 1
|
|
151
|
+
|
|
152
|
+
lines = []
|
|
153
|
+
try:
|
|
154
|
+
total_bytes = os.path.getsize(_path)
|
|
155
|
+
total_lines = 0
|
|
156
|
+
|
|
157
|
+
with open(_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
158
|
+
for curr_line, line in enumerate(f, start=1):
|
|
159
|
+
if start_line <= curr_line <= end_line:
|
|
160
|
+
lines.append(f"{curr_line}: {line}")
|
|
161
|
+
total_lines = curr_line
|
|
162
|
+
|
|
163
|
+
content = "".join(lines)
|
|
164
|
+
|
|
165
|
+
results.append({
|
|
166
|
+
"path": path,
|
|
167
|
+
"start": start_line,
|
|
168
|
+
"end": min(end_line, total_lines) if total_lines > 0 else end_line,
|
|
169
|
+
"total_line": total_lines,
|
|
170
|
+
"content": content,
|
|
171
|
+
"file_size_bytes": total_bytes
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
results.append({
|
|
176
|
+
"path": path,
|
|
177
|
+
"error": Error_codes.FILE_NOT_FOUND_ERROR.value
|
|
178
|
+
})
|
|
179
|
+
except Exception as e:
|
|
180
|
+
results.append({
|
|
181
|
+
"path": path,
|
|
182
|
+
"error": f"Failed to read file: {str(e)}"
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"files": results
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def write_file(
|
|
190
|
+
self,
|
|
191
|
+
path: str,
|
|
192
|
+
content: str,
|
|
193
|
+
overwrite: bool = False,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""
|
|
196
|
+
Creates a new file or overwrites an existing file.
|
|
197
|
+
|
|
198
|
+
If overwrite=False, an existing file is not modified.
|
|
199
|
+
If overwrite=True, the entire file is replaced.
|
|
200
|
+
"""
|
|
201
|
+
_path = self.workspace.safe_path(path)
|
|
202
|
+
|
|
203
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
204
|
+
return {
|
|
205
|
+
"parent": path,
|
|
206
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
file_path = Path(_path)
|
|
210
|
+
|
|
211
|
+
if file_path.exists() and file_path.is_dir():
|
|
212
|
+
return {
|
|
213
|
+
"path": path,
|
|
214
|
+
"success": False,
|
|
215
|
+
"error": "Path is a directory, not a file.",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
was_existing = file_path.exists()
|
|
219
|
+
|
|
220
|
+
if was_existing and not overwrite:
|
|
221
|
+
return {
|
|
222
|
+
"path": path,
|
|
223
|
+
"success": False,
|
|
224
|
+
"error": (
|
|
225
|
+
"File already exists. "
|
|
226
|
+
"Set overwrite=true to replace the entire file."
|
|
227
|
+
),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
|
|
233
|
+
file_path.write_text(
|
|
234
|
+
content,
|
|
235
|
+
encoding="utf-8",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
syntax_status = self.diagnostic.check_file_syntax(str(file_path))
|
|
239
|
+
|
|
240
|
+
response = {
|
|
241
|
+
"path": path,
|
|
242
|
+
"success": True,
|
|
243
|
+
"created": not was_existing,
|
|
244
|
+
"overwritten": was_existing,
|
|
245
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if not syntax_status["valid"]:
|
|
249
|
+
response["syntax_warning"] = (
|
|
250
|
+
"Modification was applied, but created a syntax error: "
|
|
251
|
+
f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return response
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
except OSError as e:
|
|
258
|
+
return {
|
|
259
|
+
"path": path,
|
|
260
|
+
"success": False,
|
|
261
|
+
"error": f"Failed to write file: {str(e)}",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def apply_search_replace(
|
|
265
|
+
self,
|
|
266
|
+
file_path: str,
|
|
267
|
+
search: str,
|
|
268
|
+
replace: str
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
|
|
271
|
+
path = self.workspace.safe_path(file_path)
|
|
272
|
+
|
|
273
|
+
if path == Error_codes.PATH_SCOPE_ERROR:
|
|
274
|
+
return {
|
|
275
|
+
"parent": file_path,
|
|
276
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if not os.path.exists(path):
|
|
281
|
+
return {
|
|
282
|
+
"path": file_path,
|
|
283
|
+
"success": False,
|
|
284
|
+
"error": f"File {file_path} not found."
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
288
|
+
content = f.read()
|
|
289
|
+
|
|
290
|
+
# exact match
|
|
291
|
+
first = content.find(search)
|
|
292
|
+
|
|
293
|
+
if first != -1:
|
|
294
|
+
second = content.find(
|
|
295
|
+
search,
|
|
296
|
+
first + len(search),
|
|
297
|
+
)
|
|
298
|
+
if second != -1:
|
|
299
|
+
return {
|
|
300
|
+
"path": path,
|
|
301
|
+
"success": False,
|
|
302
|
+
"error": (
|
|
303
|
+
"Search string found multiple times. "
|
|
304
|
+
"Include more surrounding code to make it unique."
|
|
305
|
+
),
|
|
306
|
+
}
|
|
307
|
+
new_content = (
|
|
308
|
+
content[:first]
|
|
309
|
+
+ replace
|
|
310
|
+
+ content[first + len(search):]
|
|
311
|
+
)
|
|
312
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
313
|
+
f.write(new_content)
|
|
314
|
+
|
|
315
|
+
syntax_status = self.diagnostic.check_file_syntax(path)
|
|
316
|
+
|
|
317
|
+
response = {
|
|
318
|
+
"path": path,
|
|
319
|
+
"success": True,
|
|
320
|
+
"match_type": "exact"
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if not syntax_status["valid"]:
|
|
324
|
+
response["syntax_warning"] = (
|
|
325
|
+
"Modification was applied, but created a syntax error: "
|
|
326
|
+
f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return response
|
|
330
|
+
|
|
331
|
+
# Fuzzy Whitespace Fallback if exact matche is zero
|
|
332
|
+
search_words = search.split()
|
|
333
|
+
if not search_words:
|
|
334
|
+
return {
|
|
335
|
+
"path": path,
|
|
336
|
+
"success": False,
|
|
337
|
+
"error": "Search string is empty or only contains whitespace."
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
escaped_words = [re.escape(word) for word in search_words]
|
|
341
|
+
|
|
342
|
+
fuzzy_pattern = r'\s+'.join(escaped_words)
|
|
343
|
+
|
|
344
|
+
matches = list(re.finditer(fuzzy_pattern, content))
|
|
345
|
+
|
|
346
|
+
if len(matches) == 0:
|
|
347
|
+
return {
|
|
348
|
+
"path": path,
|
|
349
|
+
"success": False,
|
|
350
|
+
"error": "Search string not found. Ensure you are matching "
|
|
351
|
+
"the exact characters of the existing code."
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if len(matches) > 1:
|
|
355
|
+
return {
|
|
356
|
+
"path": path,
|
|
357
|
+
"success": False,
|
|
358
|
+
"error": f"Fuzzy match found {len(matches)} times. "
|
|
359
|
+
"Include more surrounding lines to make your search block unique."
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
# exact one match found
|
|
363
|
+
match = matches[0]
|
|
364
|
+
|
|
365
|
+
new_content = content[:match.start()] + replace + content[match.end():]
|
|
366
|
+
|
|
367
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
368
|
+
f.write(new_content)
|
|
369
|
+
|
|
370
|
+
syntax_status = self.diagnostic.check_file_syntax(path)
|
|
371
|
+
|
|
372
|
+
response = {
|
|
373
|
+
"path": path,
|
|
374
|
+
"success": True,
|
|
375
|
+
"match_type": "exact_with_fuzzy_whitespace"
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if not syntax_status["valid"]:
|
|
379
|
+
response["syntax_warning"] = (
|
|
380
|
+
"Modification was applied, but created a syntax error: "
|
|
381
|
+
f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return response
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def file_search(
|
|
389
|
+
self,
|
|
390
|
+
query: str,
|
|
391
|
+
path: str = ".",
|
|
392
|
+
max_results: int = 100,
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
"""
|
|
395
|
+
Finds files by filename or glob pattern like "config", "*.py", "*test*", "src/**/config.*"
|
|
396
|
+
Uses ripgrep's file listing, which respects .gitignore
|
|
397
|
+
"""
|
|
398
|
+
_path = self.workspace.safe_path(path)
|
|
399
|
+
|
|
400
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
401
|
+
return {
|
|
402
|
+
"parent": path,
|
|
403
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
is_glob = any(char in query for char in ["*", "?", "[", "]"])
|
|
407
|
+
|
|
408
|
+
if is_glob:
|
|
409
|
+
glob_pattern = query
|
|
410
|
+
else:
|
|
411
|
+
glob_pattern = f"*{query}*"
|
|
412
|
+
|
|
413
|
+
cmd = [
|
|
414
|
+
"rg",
|
|
415
|
+
"--files",
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
cmd.extend([
|
|
419
|
+
"--glob",
|
|
420
|
+
glob_pattern,
|
|
421
|
+
str(_path),
|
|
422
|
+
])
|
|
423
|
+
|
|
424
|
+
workspace_root = self.workspace.root
|
|
425
|
+
try:
|
|
426
|
+
result = subprocess.run(
|
|
427
|
+
cmd,
|
|
428
|
+
capture_output=True,
|
|
429
|
+
text=True,
|
|
430
|
+
check=False,
|
|
431
|
+
)
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
return {
|
|
434
|
+
"error": (
|
|
435
|
+
"ripgrep (rg) command not found. "
|
|
436
|
+
"Please install ripgrep."
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if result.returncode == 2:
|
|
441
|
+
return {
|
|
442
|
+
"error": f"rg execution failed: {result.stderr.strip()}"
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
matches: list[str] = []
|
|
446
|
+
|
|
447
|
+
for line in result.stdout.splitlines():
|
|
448
|
+
if len(matches) >= max_results:
|
|
449
|
+
break
|
|
450
|
+
try:
|
|
451
|
+
rel_path = Path(line).resolve().relative_to(workspace_root).as_posix()
|
|
452
|
+
except ValueError:
|
|
453
|
+
rel_path = Path(line).as_posix()
|
|
454
|
+
|
|
455
|
+
matches.append(rel_path)
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
"query": query,
|
|
459
|
+
"count": len(matches),
|
|
460
|
+
"truncated": len(matches) >= max_results,
|
|
461
|
+
"matches": matches,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def ripgrep_search(
|
|
466
|
+
self,
|
|
467
|
+
query: str,
|
|
468
|
+
path: str = ".",
|
|
469
|
+
is_regex: bool = False,
|
|
470
|
+
file_pattern: str | None = None,
|
|
471
|
+
max_matches: int = 50,
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
"""
|
|
474
|
+
Searches for text or regex across file contents using ripgrep (rg).
|
|
475
|
+
|
|
476
|
+
The path may be a file or directory.
|
|
477
|
+
|
|
478
|
+
Returns matching lines with file paths and line numbers.
|
|
479
|
+
"""
|
|
480
|
+
_path = self.workspace.safe_path(path)
|
|
481
|
+
|
|
482
|
+
if _path == Error_codes.PATH_SCOPE_ERROR:
|
|
483
|
+
return {
|
|
484
|
+
"parent": path,
|
|
485
|
+
"error": Error_codes.PATH_SCOPE_ERROR.value,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
cmd = [
|
|
489
|
+
"rg",
|
|
490
|
+
"--json",
|
|
491
|
+
"-n",
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
if not is_regex:
|
|
495
|
+
cmd.extend(["-F", "-i"])
|
|
496
|
+
|
|
497
|
+
if file_pattern:
|
|
498
|
+
cmd.extend(["--glob", file_pattern])
|
|
499
|
+
|
|
500
|
+
cmd.extend([
|
|
501
|
+
"--",
|
|
502
|
+
query,
|
|
503
|
+
str(_path),
|
|
504
|
+
])
|
|
505
|
+
|
|
506
|
+
results: list[dict[str, str]] = []
|
|
507
|
+
workspace_root = self.workspace.root
|
|
508
|
+
try:
|
|
509
|
+
with subprocess.Popen(
|
|
510
|
+
cmd,
|
|
511
|
+
stdout=subprocess.PIPE,
|
|
512
|
+
stderr=subprocess.PIPE,
|
|
513
|
+
text=True,
|
|
514
|
+
) as process:
|
|
515
|
+
|
|
516
|
+
assert process.stdout is not None
|
|
517
|
+
|
|
518
|
+
for line in process.stdout:
|
|
519
|
+
if len(results) >= max_matches:
|
|
520
|
+
process.kill()
|
|
521
|
+
break
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
event = json.loads(line)
|
|
525
|
+
except json.JSONDecodeError:
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
if event.get("type") != "match":
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
data = event.get("data", {})
|
|
532
|
+
|
|
533
|
+
path_data = data.get("path", {})
|
|
534
|
+
line_number = data.get("line_number")
|
|
535
|
+
|
|
536
|
+
file_path = path_data.get("text")
|
|
537
|
+
|
|
538
|
+
if not file_path or line_number is None:
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
rel_path = Path(file_path).resolve().relative_to(workspace_root).as_posix()
|
|
543
|
+
except ValueError:
|
|
544
|
+
rel_path = file_path
|
|
545
|
+
|
|
546
|
+
content_data = data.get("lines", {})
|
|
547
|
+
content = content_data.get("text", "")
|
|
548
|
+
|
|
549
|
+
results.append({
|
|
550
|
+
"file": rel_path,
|
|
551
|
+
"line": line_number,
|
|
552
|
+
"content": content.rstrip()[:250],
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
stderr = process.stderr.read() if process.stderr else ""
|
|
556
|
+
|
|
557
|
+
# Make sure the process has finished.
|
|
558
|
+
process.wait()
|
|
559
|
+
|
|
560
|
+
except FileNotFoundError:
|
|
561
|
+
return {
|
|
562
|
+
"error": (
|
|
563
|
+
"ripgrep (rg) command not found. "
|
|
564
|
+
"Please install ripgrep."
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
except Exception as e:
|
|
568
|
+
return {
|
|
569
|
+
"error": f"Failed processing ripgrep output: {str(e)}"
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if process.returncode == 2:
|
|
573
|
+
return {
|
|
574
|
+
"error": f"rg execution failed: {stderr.strip()}"
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"query": query,
|
|
579
|
+
"total_matches": len(results),
|
|
580
|
+
"truncated": len(results) >= max_matches,
|
|
581
|
+
"matches": results,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _walk_dir(
|
|
586
|
+
root: str,
|
|
587
|
+
ignore: IgnoreMatcher,
|
|
588
|
+
max_depth: int | None = None,
|
|
589
|
+
res: list[tuple[str, str]] | None = None
|
|
590
|
+
) -> list[tuple[str, str]]:
|
|
591
|
+
|
|
592
|
+
if res is None:
|
|
593
|
+
res = []
|
|
594
|
+
|
|
595
|
+
_root = Path(root).resolve()
|
|
596
|
+
stack: list[tuple[str, Path, int, list[str]|None]] = [("entry", _root, 0, None)]
|
|
597
|
+
|
|
598
|
+
while stack:
|
|
599
|
+
event, path, depth, patterns = stack.pop()
|
|
600
|
+
|
|
601
|
+
if event == "exit":
|
|
602
|
+
if patterns is not None:
|
|
603
|
+
ignore.remove(patterns)
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
# entry
|
|
607
|
+
if path.is_symlink():
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
local_patterns = _read_ignore_file(str(path))
|
|
611
|
+
if local_patterns:
|
|
612
|
+
ignore.add(local_patterns)
|
|
613
|
+
|
|
614
|
+
if ignore.should_ignore(str(path)):
|
|
615
|
+
if local_patterns:
|
|
616
|
+
ignore.remove(local_patterns)
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
if str(path) != root:
|
|
620
|
+
res.append((str(path), "directory" if path.is_dir() else "file"))
|
|
621
|
+
|
|
622
|
+
if not path.is_dir() or (max_depth is not None and depth >= max_depth):
|
|
623
|
+
if local_patterns:
|
|
624
|
+
ignore.remove(local_patterns)
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
# schedule exit as after this all children of this 'path' will be pushed and popped before this scheduled exit
|
|
628
|
+
stack.append(("exit", path, depth, local_patterns))
|
|
629
|
+
|
|
630
|
+
children = path.iterdir()
|
|
631
|
+
|
|
632
|
+
for child in children:
|
|
633
|
+
stack.append(("entry", child, depth+1, None))
|
|
634
|
+
|
|
635
|
+
return res
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _read_ignore_file(path: str) -> list[str] | None:
|
|
639
|
+
pattern_list: list[str] = []
|
|
640
|
+
gitignore = Path(path).resolve() / ".gitignore"
|
|
641
|
+
if gitignore.is_file():
|
|
642
|
+
with open(gitignore, "r") as f:
|
|
643
|
+
content = f.readlines()
|
|
644
|
+
for line in content:
|
|
645
|
+
line = line.strip()
|
|
646
|
+
if not line or line.startswith("#"):
|
|
647
|
+
continue
|
|
648
|
+
else:
|
|
649
|
+
pattern_list.append(line)
|
|
650
|
+
return pattern_list
|
|
651
|
+
else:
|
|
652
|
+
return None
|