axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""File operations: read, write, edit, glob search, grep search.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/file_ops.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Limits matching Rust
|
|
17
|
+
MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MiB
|
|
18
|
+
MAX_WRITE_SIZE = 10 * 1024 * 1024 # 10 MiB
|
|
19
|
+
DEFAULT_HEAD_LIMIT = 250
|
|
20
|
+
DEFAULT_GLOB_LIMIT = 100
|
|
21
|
+
BINARY_CHECK_SIZE = 8192 # 8 KiB
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Binary detection
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def is_binary_file(path: Path, check_size: int = BINARY_CHECK_SIZE) -> bool:
|
|
29
|
+
"""Check if a file appears to be binary (contains NUL bytes in first 8 KiB)."""
|
|
30
|
+
try:
|
|
31
|
+
with open(path, "rb") as f:
|
|
32
|
+
chunk = f.read(check_size)
|
|
33
|
+
return b"\x00" in chunk
|
|
34
|
+
except OSError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Workspace boundary validation
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def validate_workspace_boundary(file_path: Path, workspace_root: Path | None = None) -> None:
|
|
43
|
+
"""Validate that a file path doesn't escape the workspace boundary."""
|
|
44
|
+
if workspace_root is None:
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
resolved = file_path.resolve()
|
|
48
|
+
root_resolved = workspace_root.resolve()
|
|
49
|
+
if not str(resolved).startswith(str(root_resolved)):
|
|
50
|
+
raise PermissionError(
|
|
51
|
+
f"Path {file_path} escapes workspace boundary {workspace_root}"
|
|
52
|
+
)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_symlink_escape(path: Path, workspace_root: Path) -> bool:
|
|
58
|
+
"""Check if a path is a symlink that escapes the workspace."""
|
|
59
|
+
try:
|
|
60
|
+
if path.is_symlink():
|
|
61
|
+
target = path.resolve()
|
|
62
|
+
root = workspace_root.resolve()
|
|
63
|
+
return not str(target).startswith(str(root))
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Patch generation
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class StructuredPatchHunk:
|
|
75
|
+
old_start: int
|
|
76
|
+
old_lines: int
|
|
77
|
+
new_start: int
|
|
78
|
+
new_lines: int
|
|
79
|
+
lines: list[str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def make_patch(original: str, modified: str, filename: str = "") -> list[StructuredPatchHunk]:
|
|
83
|
+
"""Generate unified diff hunks between original and modified text."""
|
|
84
|
+
orig_lines = original.splitlines(keepends=True)
|
|
85
|
+
mod_lines = modified.splitlines(keepends=True)
|
|
86
|
+
|
|
87
|
+
matcher = difflib.SequenceMatcher(None, orig_lines, mod_lines)
|
|
88
|
+
hunks: list[StructuredPatchHunk] = []
|
|
89
|
+
|
|
90
|
+
for group in matcher.get_grouped_opcodes(3):
|
|
91
|
+
hunk_lines: list[str] = []
|
|
92
|
+
old_start = group[0][1] + 1
|
|
93
|
+
old_end = group[-1][2]
|
|
94
|
+
new_start = group[0][3] + 1
|
|
95
|
+
new_end = group[-1][4]
|
|
96
|
+
|
|
97
|
+
for tag, i1, i2, j1, j2 in group:
|
|
98
|
+
if tag == "equal":
|
|
99
|
+
for line in orig_lines[i1:i2]:
|
|
100
|
+
hunk_lines.append(" " + line.rstrip("\n"))
|
|
101
|
+
elif tag == "delete":
|
|
102
|
+
for line in orig_lines[i1:i2]:
|
|
103
|
+
hunk_lines.append("-" + line.rstrip("\n"))
|
|
104
|
+
elif tag == "insert":
|
|
105
|
+
for line in mod_lines[j1:j2]:
|
|
106
|
+
hunk_lines.append("+" + line.rstrip("\n"))
|
|
107
|
+
elif tag == "replace":
|
|
108
|
+
for line in orig_lines[i1:i2]:
|
|
109
|
+
hunk_lines.append("-" + line.rstrip("\n"))
|
|
110
|
+
for line in mod_lines[j1:j2]:
|
|
111
|
+
hunk_lines.append("+" + line.rstrip("\n"))
|
|
112
|
+
|
|
113
|
+
hunks.append(StructuredPatchHunk(
|
|
114
|
+
old_start=old_start,
|
|
115
|
+
old_lines=old_end - old_start + 1,
|
|
116
|
+
new_start=new_start,
|
|
117
|
+
new_lines=new_end - new_start + 1,
|
|
118
|
+
lines=hunk_lines,
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
return hunks
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Read file
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class ReadFileOutput:
|
|
130
|
+
file_path: str
|
|
131
|
+
content: str
|
|
132
|
+
num_lines: int
|
|
133
|
+
start_line: int = 1
|
|
134
|
+
total_lines: int = 0
|
|
135
|
+
kind: str = "text"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_file(
|
|
139
|
+
file_path: str,
|
|
140
|
+
start_line: int | None = None,
|
|
141
|
+
end_line: int | None = None,
|
|
142
|
+
max_size: int = MAX_READ_SIZE,
|
|
143
|
+
) -> ReadFileOutput:
|
|
144
|
+
"""Read a file, optionally returning a line range.
|
|
145
|
+
|
|
146
|
+
Lines are 1-indexed. Output includes cat -n style line numbers.
|
|
147
|
+
Rejects binary files and files exceeding size limit.
|
|
148
|
+
"""
|
|
149
|
+
path = Path(file_path)
|
|
150
|
+
if not path.exists():
|
|
151
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
152
|
+
if path.is_dir():
|
|
153
|
+
raise IsADirectoryError(f"Path is a directory: {file_path}")
|
|
154
|
+
|
|
155
|
+
# Size check
|
|
156
|
+
file_size = path.stat().st_size
|
|
157
|
+
if file_size > max_size:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"File too large: {file_size:,} bytes (max: {max_size:,} bytes). "
|
|
160
|
+
f"Use offset/limit to read portions."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Image files — return base64 encoded content description
|
|
164
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico"}
|
|
165
|
+
if path.suffix.lower() in IMAGE_EXTENSIONS:
|
|
166
|
+
import base64
|
|
167
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
168
|
+
content = (
|
|
169
|
+
f"[Image file: {path.name} ({file_size:,} bytes)]\n"
|
|
170
|
+
f"Format: {path.suffix.lower()}\n"
|
|
171
|
+
f"Base64 ({len(encoded)} chars): {encoded[:200]}...\n\n"
|
|
172
|
+
f"This is an image file. The full base64 content is available for the model to interpret."
|
|
173
|
+
)
|
|
174
|
+
return ReadFileOutput(
|
|
175
|
+
file_path=file_path, content=content,
|
|
176
|
+
num_lines=4, total_lines=4, kind="image",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# PDF files — extract text content
|
|
180
|
+
PDF_EXTENSIONS = {".pdf"}
|
|
181
|
+
if path.suffix.lower() in PDF_EXTENSIONS:
|
|
182
|
+
try:
|
|
183
|
+
import subprocess as _sp
|
|
184
|
+
# Try pdftotext if available
|
|
185
|
+
result = _sp.run(
|
|
186
|
+
["pdftotext", "-layout", file_path, "-"],
|
|
187
|
+
capture_output=True, text=True, timeout=10,
|
|
188
|
+
)
|
|
189
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
190
|
+
text = result.stdout
|
|
191
|
+
lines = text.splitlines()
|
|
192
|
+
content = "\n".join(f"{i}\t{line}" for i, line in enumerate(lines, 1))
|
|
193
|
+
return ReadFileOutput(
|
|
194
|
+
file_path=file_path, content=content,
|
|
195
|
+
num_lines=len(lines), total_lines=len(lines), kind="pdf",
|
|
196
|
+
)
|
|
197
|
+
except (FileNotFoundError, Exception):
|
|
198
|
+
pass
|
|
199
|
+
# Fallback: read raw bytes and report metadata
|
|
200
|
+
content = (
|
|
201
|
+
f"[PDF file: {path.name} ({file_size:,} bytes)]\n"
|
|
202
|
+
f"Install 'pdftotext' (poppler-utils) for text extraction.\n"
|
|
203
|
+
f"Alternatively, use WebFetch to fetch an online version."
|
|
204
|
+
)
|
|
205
|
+
return ReadFileOutput(
|
|
206
|
+
file_path=file_path, content=content,
|
|
207
|
+
num_lines=3, total_lines=3, kind="pdf",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Binary check
|
|
211
|
+
if is_binary_file(path):
|
|
212
|
+
raise ValueError(f"Binary file detected: {file_path}. Cannot read binary files as text.")
|
|
213
|
+
|
|
214
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
215
|
+
all_lines = text.splitlines(keepends=True)
|
|
216
|
+
total = len(all_lines)
|
|
217
|
+
|
|
218
|
+
start = (start_line or 1) - 1 # Convert to 0-indexed
|
|
219
|
+
end = end_line or total
|
|
220
|
+
|
|
221
|
+
start = max(0, start)
|
|
222
|
+
end = min(total, end)
|
|
223
|
+
|
|
224
|
+
selected = all_lines[start:end]
|
|
225
|
+
|
|
226
|
+
# Format with line numbers (cat -n style)
|
|
227
|
+
numbered_lines = []
|
|
228
|
+
for i, line in enumerate(selected, start=start + 1):
|
|
229
|
+
numbered_lines.append(f"{i}\t{line}")
|
|
230
|
+
|
|
231
|
+
content = "".join(numbered_lines)
|
|
232
|
+
|
|
233
|
+
return ReadFileOutput(
|
|
234
|
+
file_path=file_path,
|
|
235
|
+
content=content,
|
|
236
|
+
num_lines=len(selected),
|
|
237
|
+
start_line=start + 1,
|
|
238
|
+
total_lines=total,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Write file
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class WriteFileOutput:
|
|
248
|
+
file_path: str
|
|
249
|
+
content: str
|
|
250
|
+
kind: str = "create"
|
|
251
|
+
structured_patch: list[StructuredPatchHunk] = field(default_factory=list)
|
|
252
|
+
original_file: str | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def write_file(
|
|
256
|
+
file_path: str,
|
|
257
|
+
content: str,
|
|
258
|
+
max_size: int = MAX_WRITE_SIZE,
|
|
259
|
+
) -> WriteFileOutput:
|
|
260
|
+
"""Write content to a file, creating parent directories as needed.
|
|
261
|
+
|
|
262
|
+
Validates content size and generates patch if updating existing file.
|
|
263
|
+
"""
|
|
264
|
+
if len(content.encode("utf-8")) > max_size:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Content too large: {len(content.encode('utf-8')):,} bytes "
|
|
267
|
+
f"(max: {max_size:,} bytes)"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
path = Path(file_path)
|
|
271
|
+
original = None
|
|
272
|
+
kind = "create"
|
|
273
|
+
|
|
274
|
+
if path.exists():
|
|
275
|
+
kind = "update"
|
|
276
|
+
original = path.read_text(encoding="utf-8", errors="replace")
|
|
277
|
+
|
|
278
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
path.write_text(content, encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
# Generate patch
|
|
282
|
+
patch = []
|
|
283
|
+
if original is not None:
|
|
284
|
+
patch = make_patch(original, content, file_path)
|
|
285
|
+
|
|
286
|
+
return WriteFileOutput(
|
|
287
|
+
file_path=file_path,
|
|
288
|
+
content=content,
|
|
289
|
+
kind=kind,
|
|
290
|
+
structured_patch=patch,
|
|
291
|
+
original_file=original,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Edit file
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
@dataclass
|
|
300
|
+
class EditFileOutput:
|
|
301
|
+
file_path: str
|
|
302
|
+
old_string: str
|
|
303
|
+
new_string: str
|
|
304
|
+
replacements: int = 0
|
|
305
|
+
structured_patch: list[StructuredPatchHunk] = field(default_factory=list)
|
|
306
|
+
original_file: str = ""
|
|
307
|
+
replace_all: bool = False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def edit_file(
|
|
311
|
+
file_path: str,
|
|
312
|
+
old_string: str,
|
|
313
|
+
new_string: str,
|
|
314
|
+
replace_all: bool = False,
|
|
315
|
+
) -> EditFileOutput:
|
|
316
|
+
"""Replace text in a file.
|
|
317
|
+
|
|
318
|
+
If replace_all is False, old_string must be unique in the file.
|
|
319
|
+
Validates old_string != new_string and generates patch.
|
|
320
|
+
"""
|
|
321
|
+
path = Path(file_path)
|
|
322
|
+
if not path.exists():
|
|
323
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
324
|
+
|
|
325
|
+
if old_string == new_string:
|
|
326
|
+
raise ValueError("old_string and new_string are identical — no change needed")
|
|
327
|
+
|
|
328
|
+
content = path.read_text(encoding="utf-8")
|
|
329
|
+
count = content.count(old_string)
|
|
330
|
+
|
|
331
|
+
if count == 0:
|
|
332
|
+
raise ValueError(f"old_string not found in {file_path}")
|
|
333
|
+
|
|
334
|
+
if not replace_all and count > 1:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"old_string appears {count} times in {file_path}. "
|
|
337
|
+
f"Use replace_all=True or provide more context to make it unique."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if replace_all:
|
|
341
|
+
new_content = content.replace(old_string, new_string)
|
|
342
|
+
replacements = count
|
|
343
|
+
else:
|
|
344
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
345
|
+
replacements = 1
|
|
346
|
+
|
|
347
|
+
path.write_text(new_content, encoding="utf-8")
|
|
348
|
+
|
|
349
|
+
# Generate patch
|
|
350
|
+
patch = make_patch(content, new_content, file_path)
|
|
351
|
+
|
|
352
|
+
return EditFileOutput(
|
|
353
|
+
file_path=file_path,
|
|
354
|
+
old_string=old_string,
|
|
355
|
+
new_string=new_string,
|
|
356
|
+
replacements=replacements,
|
|
357
|
+
structured_patch=patch,
|
|
358
|
+
original_file=content,
|
|
359
|
+
replace_all=replace_all,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Glob search
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class GlobSearchOutput:
|
|
369
|
+
pattern: str
|
|
370
|
+
filenames: list[str]
|
|
371
|
+
num_files: int
|
|
372
|
+
duration_ms: float
|
|
373
|
+
truncated: bool = False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def glob_search(
|
|
377
|
+
pattern: str,
|
|
378
|
+
path: str | None = None,
|
|
379
|
+
max_results: int = DEFAULT_GLOB_LIMIT,
|
|
380
|
+
) -> GlobSearchOutput:
|
|
381
|
+
"""Search for files matching a glob pattern.
|
|
382
|
+
|
|
383
|
+
Results sorted by modification time (newest first). Truncates at max_results.
|
|
384
|
+
"""
|
|
385
|
+
start = time.monotonic()
|
|
386
|
+
search_root = Path(path) if path else Path.cwd()
|
|
387
|
+
|
|
388
|
+
matches: list[str] = []
|
|
389
|
+
truncated = False
|
|
390
|
+
|
|
391
|
+
# Normalize pattern for rglob
|
|
392
|
+
glob_pattern = pattern
|
|
393
|
+
if glob_pattern.startswith("**/"):
|
|
394
|
+
glob_pattern = glob_pattern[3:]
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
for p in search_root.rglob(glob_pattern):
|
|
398
|
+
if p.is_file():
|
|
399
|
+
# Skip hidden dirs and common non-code dirs
|
|
400
|
+
parts = p.relative_to(search_root).parts
|
|
401
|
+
if any(
|
|
402
|
+
part.startswith(".") or part in (
|
|
403
|
+
"node_modules", "__pycache__", "target", "dist",
|
|
404
|
+
"build", "venv", ".venv",
|
|
405
|
+
)
|
|
406
|
+
for part in parts
|
|
407
|
+
):
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
matches.append(str(p))
|
|
411
|
+
if len(matches) >= max_results:
|
|
412
|
+
truncated = True
|
|
413
|
+
break
|
|
414
|
+
except (OSError, ValueError):
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
418
|
+
|
|
419
|
+
# Sort by modification time (most recent first)
|
|
420
|
+
try:
|
|
421
|
+
matches.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
|
422
|
+
except OSError:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
return GlobSearchOutput(
|
|
426
|
+
pattern=pattern,
|
|
427
|
+
filenames=matches,
|
|
428
|
+
num_files=len(matches),
|
|
429
|
+
duration_ms=duration_ms,
|
|
430
|
+
truncated=truncated,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# Grep search
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
@dataclass
|
|
439
|
+
class GrepMatch:
|
|
440
|
+
file: str
|
|
441
|
+
line_number: int
|
|
442
|
+
content: str
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass
|
|
446
|
+
class GrepSearchOutput:
|
|
447
|
+
pattern: str
|
|
448
|
+
matches: list[GrepMatch] = field(default_factory=list)
|
|
449
|
+
filenames: list[str] = field(default_factory=list)
|
|
450
|
+
duration_ms: float = 0.0
|
|
451
|
+
truncated: bool = False
|
|
452
|
+
num_matches: int = 0
|
|
453
|
+
mode: str = "files_with_matches"
|
|
454
|
+
content: str | None = None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def grep_search(
|
|
458
|
+
pattern: str,
|
|
459
|
+
path: str | None = None,
|
|
460
|
+
glob_filter: str | None = None,
|
|
461
|
+
output_mode: str = "files_with_matches",
|
|
462
|
+
max_results: int = DEFAULT_HEAD_LIMIT,
|
|
463
|
+
offset: int = 0,
|
|
464
|
+
case_insensitive: bool = False,
|
|
465
|
+
context_lines: int = 0,
|
|
466
|
+
before_context: int = 0,
|
|
467
|
+
after_context: int = 0,
|
|
468
|
+
multiline: bool = False,
|
|
469
|
+
file_type: str | None = None,
|
|
470
|
+
line_numbers: bool = True,
|
|
471
|
+
) -> GrepSearchOutput:
|
|
472
|
+
"""Search file contents with regex pattern.
|
|
473
|
+
|
|
474
|
+
Supports three output modes: files_with_matches, content, count.
|
|
475
|
+
Filters by glob pattern and file type.
|
|
476
|
+
"""
|
|
477
|
+
start = time.monotonic()
|
|
478
|
+
search_root = Path(path) if path else Path.cwd()
|
|
479
|
+
|
|
480
|
+
# Determine context
|
|
481
|
+
ctx_before = before_context or context_lines
|
|
482
|
+
ctx_after = after_context or context_lines
|
|
483
|
+
|
|
484
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
485
|
+
if multiline:
|
|
486
|
+
flags |= re.DOTALL | re.MULTILINE
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
compiled = re.compile(pattern, flags)
|
|
490
|
+
except re.error as exc:
|
|
491
|
+
raise ValueError(f"Invalid regex pattern: {exc}") from exc
|
|
492
|
+
|
|
493
|
+
# Build file type filter
|
|
494
|
+
type_globs: list[str] = []
|
|
495
|
+
if file_type:
|
|
496
|
+
type_map = {
|
|
497
|
+
"py": "*.py", "python": "*.py",
|
|
498
|
+
"js": "*.js", "javascript": "*.js",
|
|
499
|
+
"ts": "*.ts", "typescript": "*.ts",
|
|
500
|
+
"tsx": "*.tsx",
|
|
501
|
+
"rs": "*.rs", "rust": "*.rs",
|
|
502
|
+
"go": "*.go",
|
|
503
|
+
"java": "*.java",
|
|
504
|
+
"c": "*.c", "cpp": "*.cpp", "cc": "*.cc",
|
|
505
|
+
"h": "*.h", "hpp": "*.hpp",
|
|
506
|
+
"rb": "*.rb", "ruby": "*.rb",
|
|
507
|
+
"md": "*.md", "markdown": "*.md",
|
|
508
|
+
"json": "*.json",
|
|
509
|
+
"yaml": "*.yaml", "yml": "*.yml",
|
|
510
|
+
"toml": "*.toml",
|
|
511
|
+
"html": "*.html",
|
|
512
|
+
"css": "*.css",
|
|
513
|
+
"sql": "*.sql",
|
|
514
|
+
"sh": "*.sh", "bash": "*.sh",
|
|
515
|
+
}
|
|
516
|
+
if file_type in type_map:
|
|
517
|
+
type_globs = [type_map[file_type]]
|
|
518
|
+
|
|
519
|
+
matches: list[GrepMatch] = []
|
|
520
|
+
matched_files: set[str] = set()
|
|
521
|
+
match_count = 0
|
|
522
|
+
truncated = False
|
|
523
|
+
content_lines: list[str] = []
|
|
524
|
+
skipped = 0
|
|
525
|
+
|
|
526
|
+
# Skip directories
|
|
527
|
+
skip_dirs = {
|
|
528
|
+
".git", "node_modules", "__pycache__", "target", "dist",
|
|
529
|
+
"build", "venv", ".venv", "env", ".tox", ".mypy_cache",
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for root, dirs, files in os.walk(search_root):
|
|
533
|
+
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
|
|
534
|
+
|
|
535
|
+
for filename in files:
|
|
536
|
+
# Filter by glob/type
|
|
537
|
+
if glob_filter and not fnmatch.fnmatch(filename, glob_filter):
|
|
538
|
+
continue
|
|
539
|
+
if type_globs and not any(fnmatch.fnmatch(filename, g) for g in type_globs):
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
filepath = os.path.join(root, filename)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
546
|
+
file_lines = f.readlines()
|
|
547
|
+
except (OSError, PermissionError):
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
file_matched = False
|
|
551
|
+
for line_num, line in enumerate(file_lines, 1):
|
|
552
|
+
if compiled.search(line):
|
|
553
|
+
file_matched = True
|
|
554
|
+
match_count += 1
|
|
555
|
+
|
|
556
|
+
# Apply offset
|
|
557
|
+
if skipped < offset:
|
|
558
|
+
skipped += 1
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
if output_mode == "content":
|
|
562
|
+
# Add context lines
|
|
563
|
+
if ctx_before > 0:
|
|
564
|
+
start_ctx = max(0, line_num - 1 - ctx_before)
|
|
565
|
+
for ctx_i in range(start_ctx, line_num - 1):
|
|
566
|
+
ctx_line = file_lines[ctx_i].rstrip("\n")
|
|
567
|
+
prefix = f"{filepath}:{ctx_i + 1}:" if line_numbers else ""
|
|
568
|
+
content_lines.append(f"{prefix}{ctx_line}")
|
|
569
|
+
|
|
570
|
+
prefix = f"{filepath}:{line_num}:" if line_numbers else ""
|
|
571
|
+
content_lines.append(f"{prefix}{line.rstrip(chr(10))}")
|
|
572
|
+
|
|
573
|
+
if ctx_after > 0:
|
|
574
|
+
end_ctx = min(len(file_lines), line_num + ctx_after)
|
|
575
|
+
for ctx_i in range(line_num, end_ctx):
|
|
576
|
+
ctx_line = file_lines[ctx_i].rstrip("\n")
|
|
577
|
+
prefix = f"{filepath}:{ctx_i + 1}:" if line_numbers else ""
|
|
578
|
+
content_lines.append(f"{prefix}{ctx_line}")
|
|
579
|
+
|
|
580
|
+
matches.append(GrepMatch(
|
|
581
|
+
file=filepath,
|
|
582
|
+
line_number=line_num,
|
|
583
|
+
content=line.rstrip("\n"),
|
|
584
|
+
))
|
|
585
|
+
|
|
586
|
+
if len(matches) >= max_results:
|
|
587
|
+
truncated = True
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
if file_matched:
|
|
591
|
+
matched_files.add(filepath)
|
|
592
|
+
|
|
593
|
+
if truncated:
|
|
594
|
+
break
|
|
595
|
+
if truncated:
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
599
|
+
|
|
600
|
+
result = GrepSearchOutput(
|
|
601
|
+
pattern=pattern,
|
|
602
|
+
matches=matches,
|
|
603
|
+
filenames=sorted(matched_files),
|
|
604
|
+
duration_ms=duration_ms,
|
|
605
|
+
truncated=truncated,
|
|
606
|
+
num_matches=match_count,
|
|
607
|
+
mode=output_mode,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if output_mode == "content" and content_lines:
|
|
611
|
+
result.content = "\n".join(content_lines[:max_results])
|
|
612
|
+
|
|
613
|
+
return result
|