Skip to content

Commit 736d3bf

Browse files
committed
feat: add GET /files/search endpoint for structured grep-like search
Returns JSON matches with file paths, line numbers, and content. Supports regex, case-insensitive search, multiple glob filters, match_per_line toggle (lines vs filenames), and result capping. Skips binary files automatically.
1 parent ab212aa commit 736d3bf

1 file changed

Lines changed: 88 additions & 0 deletions

File tree

open_terminal/main.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
22
import base64
3+
import fnmatch
34
import os
45
import platform
6+
import re
57
import signal
68
import socket
79
import sys
@@ -368,6 +370,92 @@ async def replace_file_content(request: ReplaceRequest):
368370
return {"path": target, "size": len(content.encode())}
369371

370372

373+
@app.get(
374+
"/files/search",
375+
operation_id="search_files",
376+
summary="Search file contents",
377+
description="Search for a text pattern across files in a directory. Returns structured matches with file paths, line numbers, and matching lines. Skips binary files.",
378+
dependencies=[Depends(verify_api_key)],
379+
responses={
380+
404: {"description": "Search path not found."},
381+
400: {"description": "Invalid regex pattern."},
382+
401: {"description": "Invalid or missing API key."},
383+
},
384+
)
385+
async def search_files(
386+
query: str = Query(..., description="Text or regex pattern to search for."),
387+
path: str = Query(".", description="Directory or file to search in."),
388+
regex: bool = Query(False, description="Treat query as a regex pattern."),
389+
case_insensitive: bool = Query(False, description="Perform case-insensitive matching."),
390+
include: Optional[list[str]] = Query(None, description="Glob patterns to filter files (e.g. '*.py'). Files must match at least one pattern."),
391+
match_per_line: bool = Query(True, description="If true, return each matching line with line numbers. If false, return only the names of matching files."),
392+
max_results: int = Query(50, description="Maximum number of matches to return.", ge=1, le=500),
393+
):
394+
target = os.path.abspath(path)
395+
if not os.path.exists(target):
396+
raise HTTPException(status_code=404, detail="Search path not found")
397+
398+
flags = re.IGNORECASE if case_insensitive else 0
399+
if regex:
400+
try:
401+
pattern = re.compile(query, flags)
402+
except re.error as exc:
403+
raise HTTPException(status_code=400, detail=f"Invalid regex: {exc}")
404+
else:
405+
pattern = re.compile(re.escape(query), flags)
406+
407+
def _matches_include(filename: str) -> bool:
408+
if not include:
409+
return True
410+
return any(fnmatch.fnmatch(filename, glob) for glob in include)
411+
412+
matches = []
413+
truncated = False
414+
415+
def _search_file(file_path: str):
416+
nonlocal truncated
417+
if truncated:
418+
return
419+
try:
420+
with open(file_path, "r", errors="strict") as f:
421+
for line_number, line in enumerate(f, 1):
422+
if pattern.search(line):
423+
if match_per_line:
424+
matches.append({
425+
"file": file_path,
426+
"line": line_number,
427+
"content": line.rstrip("\n\r"),
428+
})
429+
if len(matches) >= max_results:
430+
truncated = True
431+
return
432+
else:
433+
matches.append({"file": file_path})
434+
if len(matches) >= max_results:
435+
truncated = True
436+
return # one match per file is enough
437+
except (UnicodeDecodeError, ValueError, OSError):
438+
pass # skip binary or unreadable files
439+
440+
if os.path.isfile(target):
441+
_search_file(target)
442+
else:
443+
for dirpath, _, filenames in os.walk(target):
444+
if truncated:
445+
break
446+
for filename in sorted(filenames):
447+
if not _matches_include(filename):
448+
continue
449+
_search_file(os.path.join(dirpath, filename))
450+
451+
return {
452+
"query": query,
453+
"path": target,
454+
"matches": matches,
455+
"truncated": truncated,
456+
}
457+
458+
371459
# Temporary links: {token: (path, expiry_timestamp)}
372460
_download_links: dict[str, tuple[str, float]] = {}
373461
_upload_links: dict[str, tuple[str, float]] = {}

0 commit comments

Comments
 (0)