2025-04-19 19:45:42 +01:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs' ;
import path from 'path' ;
2025-06-12 19:46:00 -07:00
import { glob } from 'glob' ;
2025-04-19 19:45:42 +01:00
import { SchemaValidator } from '../utils/schemaValidator.js' ;
import { BaseTool , ToolResult } from './tools.js' ;
import { shortenPath , makeRelative } from '../utils/paths.js' ;
2025-06-03 21:40:46 -07:00
import { Config } from '../config/config.js' ;
2025-04-19 19:45:42 +01:00
2025-06-12 19:46:00 -07:00
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
fullpath ( ) : string ;
mtimeMs? : number ;
2025-06-09 08:07:24 -07:00
}
/**
* Sorts file entries based on recency and then alphabetically.
* Recent files (modified within recencyThresholdMs) are listed first, newest to oldest.
* Older files are listed after recent ones, sorted alphabetically by path.
*/
export function sortFileEntries (
2025-06-12 19:46:00 -07:00
entries : GlobPath [ ] ,
2025-06-09 08:07:24 -07:00
nowTimestamp : number ,
recencyThresholdMs : number ,
2025-06-12 19:46:00 -07:00
) : GlobPath [ ] {
2025-06-09 08:07:24 -07:00
const sortedEntries = [ . . . entries ] ;
sortedEntries . sort ( ( a , b ) = > {
2025-06-12 19:46:00 -07:00
const mtimeA = a . mtimeMs ? ? 0 ;
const mtimeB = b . mtimeMs ? ? 0 ;
2025-06-09 08:07:24 -07:00
const aIsRecent = nowTimestamp - mtimeA < recencyThresholdMs ;
const bIsRecent = nowTimestamp - mtimeB < recencyThresholdMs ;
if ( aIsRecent && bIsRecent ) {
return mtimeB - mtimeA ;
} else if ( aIsRecent ) {
return - 1 ;
} else if ( bIsRecent ) {
return 1 ;
} else {
2025-06-12 19:46:00 -07:00
return a . fullpath ( ) . localeCompare ( b . fullpath ( ) ) ;
2025-06-09 08:07:24 -07:00
}
} ) ;
return sortedEntries ;
}
2025-04-19 19:45:42 +01:00
/**
* 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 ;
2025-05-18 00:04:32 -07:00
/**
* Whether the search should be case-sensitive (optional, defaults to false)
*/
case_sensitive? : boolean ;
2025-06-03 21:40:46 -07:00
/**
* Whether to respect .gitignore patterns (optional, defaults to true)
*/
respect_git_ignore? : boolean ;
2025-04-19 19:45:42 +01:00
}
/**
2025-05-02 14:39:39 -07:00
* Implementation of the Glob tool logic
2025-04-19 19:45:42 +01:00
*/
2025-04-21 10:53:11 -04:00
export class GlobTool extends BaseTool < GlobToolParams , ToolResult > {
2025-05-02 14:39:39 -07:00
static readonly Name = 'glob' ;
2025-04-19 19:45:42 +01:00
/**
* Creates a new instance of the GlobLogic
* @param rootDirectory Root directory to ground this tool in.
*/
2025-06-03 21:40:46 -07:00
constructor (
private rootDirectory : string ,
private config : Config ,
) {
2025-04-19 19:45:42 +01:00
super (
2025-04-21 10:53:11 -04:00
GlobTool . Name ,
2025-05-02 14:39:39 -07:00
'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.' ,
2025-04-19 19:45:42 +01:00
{
properties : {
pattern : {
description :
2025-05-18 00:04:32 -07:00
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md')." ,
2025-04-19 19:45:42 +01:00
type : 'string' ,
} ,
path : {
description :
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.' ,
type : 'string' ,
} ,
2025-05-18 00:04:32 -07:00
case_sensitive : {
description :
'Optional: Whether the search should be case-sensitive. Defaults to false.' ,
type : 'boolean' ,
} ,
2025-06-03 21:40:46 -07:00
respect_git_ignore : {
description :
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.' ,
type : 'boolean' ,
} ,
2025-04-19 19:45:42 +01:00
} ,
required : [ 'pattern' ] ,
type : 'object' ,
} ,
) ;
this . rootDirectory = path . resolve ( rootDirectory ) ;
}
/**
2025-07-04 09:13:02 +09:00
* Checks if a given path is within the root directory bounds.
* This security check prevents accessing files outside the designated root directory.
*
* @param pathToCheck The absolute path to validate
* @returns True if the path is within the root directory, false otherwise
2025-04-19 19:45:42 +01:00
*/
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 < string , unknown > ,
params ,
)
) {
2025-05-18 00:04:32 -07:00
return "Parameters failed schema validation. Ensure 'pattern' is a string, 'path' (if provided) is a string, and 'case_sensitive' (if provided) is a boolean." ;
2025-04-19 19:45:42 +01:00
}
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 } "). ` ;
}
2025-05-18 00:04:32 -07:00
const targetDir = searchDirAbsolute || this . rootDirectory ;
2025-04-19 19:45:42 +01:00
try {
2025-05-18 00:04:32 -07:00
if ( ! fs . existsSync ( targetDir ) ) {
return ` Search path does not exist ${ targetDir } ` ;
2025-04-19 19:45:42 +01:00
}
2025-05-18 00:04:32 -07:00
if ( ! fs . statSync ( targetDir ) . isDirectory ( ) ) {
return ` Search path is not a directory: ${ targetDir } ` ;
2025-04-19 19:45:42 +01:00
}
} 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
*/
2025-05-09 23:29:02 -07:00
async execute (
params : GlobToolParams ,
2025-06-12 19:46:00 -07:00
signal : AbortSignal ,
2025-05-09 23:29:02 -07:00
) : Promise < ToolResult > {
2025-04-19 19:45:42 +01:00
const validationError = this . validateToolParams ( params ) ;
if ( validationError ) {
return {
llmContent : ` Error: Invalid parameters provided. Reason: ${ validationError } ` ,
2025-04-20 22:10:23 -04:00
returnDisplay : validationError ,
2025-04-19 19:45:42 +01:00
} ;
}
try {
const searchDirAbsolute = path . resolve (
this . rootDirectory ,
params . path || '.' ,
) ;
2025-06-03 21:40:46 -07:00
// Get centralized file discovery service
const respectGitIgnore =
params . respect_git_ignore ? ?
this . config . getFileFilteringRespectGitIgnore ( ) ;
2025-06-14 10:25:34 -04:00
const fileDiscovery = this . config . getFileService ( ) ;
2025-06-03 21:40:46 -07:00
2025-06-12 19:46:00 -07:00
const entries = ( await glob ( params . pattern , {
2025-04-19 19:45:42 +01:00
cwd : searchDirAbsolute ,
2025-06-12 19:46:00 -07:00
withFileTypes : true ,
nodir : true ,
stat : true ,
nocase : ! params . case_sensitive ,
2025-04-19 19:45:42 +01:00
dot : true ,
ignore : [ '**/node_modules/**' , '**/.git/**' ] ,
2025-06-12 19:46:00 -07:00
follow : false ,
signal ,
} ) ) as GlobPath [ ] ;
2025-04-19 19:45:42 +01:00
2025-06-03 21:40:46 -07:00
// Apply git-aware filtering if enabled and in git repository
let filteredEntries = entries ;
let gitIgnoredCount = 0 ;
2025-06-14 10:25:34 -04:00
if ( respectGitIgnore ) {
2025-06-12 19:46:00 -07:00
const relativePaths = entries . map ( ( p ) = >
path . relative ( this . rootDirectory , p . fullpath ( ) ) ,
2025-06-03 21:40:46 -07:00
) ;
const filteredRelativePaths = fileDiscovery . filterFiles ( relativePaths , {
respectGitIgnore ,
} ) ;
const filteredAbsolutePaths = new Set (
filteredRelativePaths . map ( ( p ) = > path . resolve ( this . rootDirectory , p ) ) ,
) ;
filteredEntries = entries . filter ( ( entry ) = >
2025-06-12 19:46:00 -07:00
filteredAbsolutePaths . has ( entry . fullpath ( ) ) ,
2025-06-03 21:40:46 -07:00
) ;
gitIgnoredCount = entries . length - filteredEntries . length ;
}
if ( ! filteredEntries || filteredEntries . length === 0 ) {
let message = ` No files found matching pattern " ${ params . pattern } " within ${ searchDirAbsolute } . ` ;
if ( gitIgnoredCount > 0 ) {
message += ` ( ${ gitIgnoredCount } files were git-ignored) ` ;
}
2025-04-19 19:45:42 +01:00
return {
2025-06-03 21:40:46 -07:00
llmContent : message ,
2025-04-19 19:45:42 +01:00
returnDisplay : ` No files found ` ,
} ;
}
2025-06-09 08:07:24 -07:00
// Set filtering such that we first show the most recent files
const oneDayInMs = 24 * 60 * 60 * 1000 ;
const nowTimestamp = new Date ( ) . getTime ( ) ;
// Sort the filtered entries using the new helper function
const sortedEntries = sortFileEntries (
2025-06-12 19:46:00 -07:00
filteredEntries ,
2025-06-09 08:07:24 -07:00
nowTimestamp ,
oneDayInMs ,
) ;
2025-04-19 19:45:42 +01:00
2025-06-12 19:46:00 -07:00
const sortedAbsolutePaths = sortedEntries . map ( ( entry ) = >
entry . fullpath ( ) ,
) ;
2025-05-18 00:04:32 -07:00
const fileListDescription = sortedAbsolutePaths . join ( '\n' ) ;
const fileCount = sortedAbsolutePaths . length ;
2025-04-19 19:45:42 +01:00
2025-06-03 21:40:46 -07:00
let resultMessage = ` Found ${ fileCount } file(s) matching " ${ params . pattern } " within ${ searchDirAbsolute } ` ;
if ( gitIgnoredCount > 0 ) {
resultMessage += ` ( ${ gitIgnoredCount } additional files were git-ignored) ` ;
}
resultMessage += ` , sorted by modification time (newest first): \ n ${ fileListDescription } ` ;
2025-04-19 19:45:42 +01:00
return {
2025-06-03 21:40:46 -07:00
llmContent : resultMessage ,
2025-04-19 19:45:42 +01:00
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. ` ,
} ;
}
}
}