|
| 1 | +import fnmatch |
1 | 2 | import os |
2 | 3 | import shutil |
3 | | -from collections.abc import Callable |
| 4 | +from collections.abc import Callable, Generator |
4 | 5 | from dataclasses import dataclass |
5 | 6 | from pathlib import Path |
6 | 7 | from typing import Any |
|
12 | 13 | ChangeResult, |
13 | 14 | DirectoryEntry, |
14 | 15 | DirectoryListing, |
| 16 | + DirectorySearchResult, |
15 | 17 | FileOverview, |
16 | 18 | LongLineStats, |
17 | 19 | SearchResult, |
@@ -834,3 +836,188 @@ def list_directory( |
834 | 836 | "truncated": listing.truncated, |
835 | 837 | "truncated_at": listing.truncated_at, |
836 | 838 | } |
| 839 | + |
| 840 | + |
| 841 | +# --------------------------------------------------------------------------- |
| 842 | +# Directory search helpers |
| 843 | +# --------------------------------------------------------------------------- |
| 844 | + |
| 845 | + |
| 846 | +def _iter_searchable_files( |
| 847 | + dir_path: str, |
| 848 | + include_pattern: str, |
| 849 | + include_hidden: bool, |
| 850 | + ignored_patterns: list[str], |
| 851 | +) -> Generator[str, None, None]: |
| 852 | + """Yield absolute paths of files matching include_pattern under dir_path. |
| 853 | +
|
| 854 | + Args: |
| 855 | + dir_path: Root directory to walk. |
| 856 | + include_pattern: fnmatch pattern matched against file names. |
| 857 | + include_hidden: Whether to yield files/dirs starting with '.'. |
| 858 | + ignored_patterns: Directory names to skip entirely. |
| 859 | +
|
| 860 | + Yields: |
| 861 | + Absolute file paths in sorted, deterministic order. |
| 862 | + """ |
| 863 | + for root, dirs, files in os.walk(dir_path): |
| 864 | + dirs[:] = sorted( |
| 865 | + d |
| 866 | + for d in dirs |
| 867 | + if (include_hidden or not d.startswith(".")) and d not in ignored_patterns |
| 868 | + ) |
| 869 | + for fname in sorted(files): |
| 870 | + if not include_hidden and fname.startswith("."): |
| 871 | + continue |
| 872 | + if not fnmatch.fnmatch(fname, include_pattern): |
| 873 | + continue |
| 874 | + yield os.path.join(root, fname) |
| 875 | + |
| 876 | + |
| 877 | +@handle_tool_errors |
| 878 | +def search_directory( |
| 879 | + absolute_dir_path: str, |
| 880 | + pattern: str, |
| 881 | + include_pattern: str = "*", |
| 882 | + max_results: int | None = None, |
| 883 | + context_lines: int = 2, |
| 884 | + fuzzy: bool = False, |
| 885 | + regex: bool = False, |
| 886 | + case_sensitive: bool = False, |
| 887 | + invert: bool = False, |
| 888 | + include_hidden: bool = False, |
| 889 | +) -> dict: |
| 890 | + """Search for a pattern across all files in a directory. |
| 891 | +
|
| 892 | + Walks the directory recursively, applies include_pattern filtering on |
| 893 | + filenames, and runs search_file() on each candidate. Results are grouped |
| 894 | + by file with relative paths from the search root. Binary and unreadable |
| 895 | + files are skipped silently. |
| 896 | +
|
| 897 | + CRITICAL: You must use an absolute directory path. |
| 898 | +
|
| 899 | + Args: |
| 900 | + absolute_dir_path: Absolute path to the directory to search. |
| 901 | + pattern: Search pattern (exact text, fuzzy, or regex). |
| 902 | + include_pattern: fnmatch glob matched against file names (default "*"). |
| 903 | + Examples: "*.py", "*.md", "*.ts". |
| 904 | + max_results: Total match cap across all files. Defaults to server |
| 905 | + config (LARGEFILE_MAX_DIR_SEARCH_RESULTS = 100). |
| 906 | + context_lines: Lines of context before/after each match (default 2). |
| 907 | + fuzzy: Enable fuzzy matching (default False — expensive for many files). |
| 908 | + regex: Enable Python regex matching (default False). |
| 909 | + case_sensitive: Case-sensitive search (default False for multi-file). |
| 910 | + invert: Return non-matching lines, like grep -v (default False). |
| 911 | + include_hidden: Include dot-files and dot-dirs (default False). |
| 912 | +
|
| 913 | + Returns: |
| 914 | + Dict with results list grouped by file, match totals, and truncation status. |
| 915 | + """ |
| 916 | + dir_path = normalize_path(absolute_dir_path) |
| 917 | + |
| 918 | + if not os.path.isdir(dir_path): |
| 919 | + raise FileAccessError(f"Not a directory or does not exist: {dir_path}") |
| 920 | + |
| 921 | + effective_max = ( |
| 922 | + max_results if max_results is not None else config.max_dir_search_results |
| 923 | + ) |
| 924 | + if effective_max < 1: |
| 925 | + raise FileAccessError(f"Invalid max_results {effective_max}: must be >= 1") |
| 926 | + |
| 927 | + total_matches = 0 |
| 928 | + files_searched = 0 |
| 929 | + files_with_matches = 0 |
| 930 | + truncated = False |
| 931 | + truncated_at: str | None = None |
| 932 | + results: list[dict] = [] |
| 933 | + |
| 934 | + for abs_path in _iter_searchable_files( |
| 935 | + dir_path, include_pattern, include_hidden, config.ignored_dir_patterns |
| 936 | + ): |
| 937 | + try: |
| 938 | + matches = search_file( |
| 939 | + abs_path, pattern, fuzzy, regex, case_sensitive, invert |
| 940 | + ) |
| 941 | + except Exception: |
| 942 | + continue # skip unreadable / binary files silently |
| 943 | + |
| 944 | + files_searched += 1 |
| 945 | + |
| 946 | + if not matches: |
| 947 | + continue |
| 948 | + |
| 949 | + remaining = effective_max - total_matches |
| 950 | + if remaining <= 0: |
| 951 | + truncated = True |
| 952 | + truncated_at = os.path.relpath(abs_path, dir_path).replace("\\", "/") |
| 953 | + break |
| 954 | + |
| 955 | + clipped = matches[:remaining] |
| 956 | + |
| 957 | + try: |
| 958 | + lines = read_file_lines(abs_path) |
| 959 | + except Exception: |
| 960 | + lines = [] |
| 961 | + |
| 962 | + match_dicts: list[dict] = [] |
| 963 | + for m in clipped: |
| 964 | + line_num = m.line_number |
| 965 | + |
| 966 | + context_before = [ |
| 967 | + lines[i - 1].rstrip("\n\r") |
| 968 | + for i in range(max(1, line_num - context_lines), line_num) |
| 969 | + if i <= len(lines) |
| 970 | + ] |
| 971 | + context_after = [ |
| 972 | + lines[i - 1].rstrip("\n\r") |
| 973 | + for i in range( |
| 974 | + line_num + 1, |
| 975 | + min(len(lines) + 1, line_num + context_lines + 1), |
| 976 | + ) |
| 977 | + if i <= len(lines) |
| 978 | + ] |
| 979 | + match_content, is_truncated = truncate_line(m.content) |
| 980 | + match_dicts.append( |
| 981 | + { |
| 982 | + "line_number": line_num, |
| 983 | + "match": match_content, |
| 984 | + "context_before": context_before, |
| 985 | + "context_after": context_after, |
| 986 | + "similarity_score": m.similarity_score, |
| 987 | + "match_type": m.match_type, |
| 988 | + "truncated": is_truncated, |
| 989 | + } |
| 990 | + ) |
| 991 | + |
| 992 | + total_matches += len(clipped) |
| 993 | + files_with_matches += 1 |
| 994 | + rel_path = os.path.relpath(abs_path, dir_path).replace("\\", "/") |
| 995 | + results.append({"file": rel_path, "matches": match_dicts}) |
| 996 | + |
| 997 | + if len(clipped) < len(matches): |
| 998 | + truncated = True |
| 999 | + truncated_at = rel_path |
| 1000 | + break |
| 1001 | + |
| 1002 | + summary = DirectorySearchResult( |
| 1003 | + path=dir_path, |
| 1004 | + pattern=pattern, |
| 1005 | + include_pattern=include_pattern, |
| 1006 | + total_matches=total_matches, |
| 1007 | + files_searched=files_searched, |
| 1008 | + files_with_matches=files_with_matches, |
| 1009 | + truncated=truncated, |
| 1010 | + truncated_at=truncated_at, |
| 1011 | + ) |
| 1012 | + |
| 1013 | + return { |
| 1014 | + "path": summary.path, |
| 1015 | + "pattern": summary.pattern, |
| 1016 | + "include_pattern": summary.include_pattern, |
| 1017 | + "total_matches": summary.total_matches, |
| 1018 | + "files_searched": summary.files_searched, |
| 1019 | + "files_with_matches": summary.files_with_matches, |
| 1020 | + "truncated": summary.truncated, |
| 1021 | + "truncated_at": summary.truncated_at, |
| 1022 | + "results": results, |
| 1023 | + } |
0 commit comments