/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'fs'; import path from 'path'; import fg from 'fast-glob'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { BaseTool, ToolResult } from './tools.js'; import { shortenPath, makeRelative } from '../utils/paths.js'; /** * Parameters for the GlobTool */ export interface GlobToolParams { /** * The glob pattern to match files against */ pattern: string; /** * The directory to search in (optional, defaults to current directory) */ path?: string; /** * Whether the search should be case-sensitive (optional, defaults to false) */ case_sensitive?: boolean; } /** * Implementation of the Glob tool logic */ export class GlobTool extends BaseTool { static readonly Name = 'glob'; /** * Creates a new instance of the GlobLogic * @param rootDirectory Root directory to ground this tool in. */ constructor(private rootDirectory: string) { super( GlobTool.Name, 'FindFiles', 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.', { properties: { pattern: { description: "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').", type: 'string', }, path: { description: 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.', type: 'string', }, case_sensitive: { description: 'Optional: Whether the search should be case-sensitive. Defaults to false.', type: 'boolean', }, }, required: ['pattern'], type: 'object', }, ); this.rootDirectory = path.resolve(rootDirectory); } /** * Checks if a path is within the root directory. */ private isWithinRoot(pathToCheck: string): boolean { const absolutePathToCheck = path.resolve(pathToCheck); const normalizedPath = path.normalize(absolutePathToCheck); const normalizedRoot = path.normalize(this.rootDirectory); const rootWithSep = normalizedRoot.endsWith(path.sep) ? normalizedRoot : normalizedRoot + path.sep; return ( normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep) ); } /** * Validates the parameters for the tool. */ validateToolParams(params: GlobToolParams): string | null { if ( this.schema.parameters && !SchemaValidator.validate( this.schema.parameters as Record, params, ) ) { return "Parameters failed schema validation. Ensure 'pattern' is a string, 'path' (if provided) is a string, and 'case_sensitive' (if provided) is a boolean."; } const searchDirAbsolute = path.resolve( this.rootDirectory, params.path || '.', ); if (!this.isWithinRoot(searchDirAbsolute)) { return `Search path ("${searchDirAbsolute}") resolves outside the tool's root directory ("${this.rootDirectory}").`; } const targetDir = searchDirAbsolute || this.rootDirectory; try { if (!fs.existsSync(targetDir)) { return `Search path does not exist ${targetDir}`; } if (!fs.statSync(targetDir).isDirectory()) { return `Search path is not a directory: ${targetDir}`; } } catch (e: unknown) { return `Error accessing search path: ${e}`; } if ( !params.pattern || typeof params.pattern !== 'string' || params.pattern.trim() === '' ) { return "The 'pattern' parameter cannot be empty."; } return null; } /** * Gets a description of the glob operation. */ getDescription(params: GlobToolParams): string { let description = `'${params.pattern}'`; if (params.path) { const searchDir = path.resolve(this.rootDirectory, params.path || '.'); const relativePath = makeRelative(searchDir, this.rootDirectory); description += ` within ${shortenPath(relativePath)}`; } return description; } /** * Executes the glob search with the given parameters */ async execute( params: GlobToolParams, _signal: AbortSignal, ): Promise { const validationError = this.validateToolParams(params); if (validationError) { return { llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, returnDisplay: validationError, }; } try { const searchDirAbsolute = path.resolve( this.rootDirectory, params.path || '.', ); const entries = await fg(params.pattern, { cwd: searchDirAbsolute, absolute: true, onlyFiles: true, stats: true, dot: true, caseSensitiveMatch: params.case_sensitive ?? false, ignore: ['**/node_modules/**', '**/.git/**'], followSymbolicLinks: false, suppressErrors: true, }); if (!entries || entries.length === 0) { return { llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`, returnDisplay: `No files found`, }; } entries.sort((a, b) => { const mtimeA = a.stats?.mtime?.getTime() ?? 0; const mtimeB = b.stats?.mtime?.getTime() ?? 0; return mtimeB - mtimeA; }); const sortedAbsolutePaths = entries.map((entry) => entry.path); const fileListDescription = sortedAbsolutePaths.join('\n'); const fileCount = sortedAbsolutePaths.length; return { llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${searchDirAbsolute}, sorted by modification time (newest first):\n${fileListDescription}`, returnDisplay: `Found ${fileCount} matching file(s)`, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`GlobLogic execute Error: ${errorMessage}`, error); return { llmContent: `Error during glob search operation: ${errorMessage}`, returnDisplay: `Error: An unexpected error occurred.`, }; } } }