""" File glob/pattern matching tool. """ import os from pathlib import Path from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.gitignore import filter_gitignored from src.shared.logging import logged, get_logger logger = get_logger(__name__) class GlobFilesTool(BaseTool): """ Find files matching a glob pattern. Returns files sorted by modification time (newest first). """ name = "glob_files" description = """Find files matching a glob pattern. Args: pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md") path: Directory to search in (default: working directory) limit: Maximum number of files to return (default: 100) honor_gitignore: Filter out gitignored files (default: True) Returns: List of matching absolute file paths, sorted by modification time (newest first). Returns error if path not found or not allowed. By default, excludes files matching .gitignore patterns and common ignored directories like .venv/, node_modules/, __pycache__/, etc. Examples: - "**/*.py" - All Python files recursively - "src/**/*.ts" - TypeScript files in src - "*.md" - Markdown files in current directory only - "**/test_*.py" - All test files IMPORTANT: - Use this tool to find files before reading them - Never guess file locations - use glob to discover """ def __init__( self, allowed_paths: list[str] | None = None, max_results: int = 100, honor_gitignore: bool = True ): """ Initialize GlobFilesTool. Args: allowed_paths: List of allowed directory prefixes max_results: Maximum files to return honor_gitignore: Whether to filter out gitignored files by default """ self.allowed_paths = allowed_paths or [] self.max_results = max_results self.honor_gitignore = honor_gitignore @logged() async def execute( self, pattern: str, path: str | None = None, limit: int | None = None, honor_gitignore: bool | None = None ) -> ToolResult: """ Find files matching glob pattern. Args: pattern: Glob pattern to match path: Directory to search (default: current directory) limit: Maximum results to return honor_gitignore: Filter out gitignored files (default: instance setting) Returns: ToolResult with list of matching file paths """ limit = limit or self.max_results should_filter_gitignore = honor_gitignore if honor_gitignore is not None else self.honor_gitignore search_path = Path(path) if path else Path.cwd() # Validate search path is allowed if not self._validate_path(search_path, self.allowed_paths): return self._error(f"Path not in allowed paths: {search_path}") if not search_path.exists(): return self._error(f"Directory not found: {search_path}") if not search_path.is_dir(): return self._error(f"Not a directory: {search_path}") try: # Find matching files matches = list(search_path.glob(pattern)) # Filter to files only (exclude directories) files = [f for f in matches if f.is_file()] # Validate each result is in allowed paths if self.allowed_paths: files = [f for f in files if self._validate_path(f, self.allowed_paths)] # Filter out gitignored files if should_filter_gitignore: files = filter_gitignored(files, search_path) # Sort by modification time (newest first) files_with_mtime = [] for f in files: try: mtime = os.path.getmtime(f) files_with_mtime.append((f, mtime)) except OSError: # Skip files we can't stat continue files_with_mtime.sort(key=lambda x: x[1], reverse=True) sorted_files = [f for f, _ in files_with_mtime] # Apply limit truncated = len(sorted_files) > limit result_files = sorted_files[:limit] # Format output as absolute paths output_lines = [str(f.resolve()) for f in result_files] result = "\n".join(output_lines) if not output_lines: result = f"No files found matching '{pattern}' in {search_path}" return self._success( data=result, truncated=truncated, total_matches=len(sorted_files), returned=len(result_files) ) except PermissionError: return self._error(f"Permission denied: {search_path}") except Exception as e: logger.exception(f"Error globbing: {pattern} in {search_path}") return self._error(f"Error searching files: {e}")