|
1 | 1 | import asyncio |
2 | 2 | import base64 |
| 3 | +import fnmatch |
3 | 4 | import os |
4 | 5 | import platform |
| 6 | +import re |
5 | 7 | import signal |
6 | 8 | import socket |
7 | 9 | import sys |
@@ -368,6 +370,92 @@ async def replace_file_content(request: ReplaceRequest): |
368 | 370 | return {"path": target, "size": len(content.encode())} |
369 | 371 |
|
370 | 372 |
|
| 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 | + |
371 | 459 | # Temporary links: {token: (path, expiry_timestamp)} |
372 | 460 | _download_links: dict[str, tuple[str, float]] = {} |
373 | 461 | _upload_links: dict[str, tuple[str, float]] = {} |
|
0 commit comments