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' ;
2025-08-07 10:05:37 -07:00
import {
BaseDeclarativeTool ,
BaseToolInvocation ,
2025-08-13 12:58:26 -03:00
Kind ,
2025-08-07 10:05:37 -07:00
ToolInvocation ,
ToolResult ,
} from './tools.js' ;
2025-04-19 19:45:42 +01:00
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-08-07 10:05:37 -07:00
class GlobToolInvocation extends BaseToolInvocation <
GlobToolParams ,
ToolResult
> {
constructor (
private config : Config ,
params : GlobToolParams ,
) {
super ( params ) ;
2025-04-19 19:45:42 +01:00
}
2025-08-07 10:05:37 -07:00
getDescription ( ) : string {
let description = ` ' ${ this . params . pattern } ' ` ;
if ( this . params . path ) {
2025-07-14 22:55:49 -07:00
const searchDir = path . resolve (
this . config . getTargetDir ( ) ,
2025-08-07 10:05:37 -07:00
this . params . path || '.' ,
2025-07-14 22:55:49 -07:00
) ;
const relativePath = makeRelative ( searchDir , this . config . getTargetDir ( ) ) ;
2025-04-19 19:45:42 +01:00
description += ` within ${ shortenPath ( relativePath ) } ` ;
}
return description ;
}
2025-08-07 10:05:37 -07:00
async execute ( signal : AbortSignal ) : Promise < ToolResult > {
2025-04-19 19:45:42 +01:00
try {
2025-07-31 05:38:20 +09:00
const workspaceContext = this . config . getWorkspaceContext ( ) ;
const workspaceDirectories = workspaceContext . getDirectories ( ) ;
// If a specific path is provided, resolve it and check if it's within workspace
let searchDirectories : readonly string [ ] ;
2025-08-07 10:05:37 -07:00
if ( this . params . path ) {
2025-07-31 05:38:20 +09:00
const searchDirAbsolute = path . resolve (
this . config . getTargetDir ( ) ,
2025-08-07 10:05:37 -07:00
this . params . path ,
2025-07-31 05:38:20 +09:00
) ;
if ( ! workspaceContext . isPathWithinWorkspace ( searchDirAbsolute ) ) {
return {
2025-08-07 10:05:37 -07:00
llmContent : ` Error: Path " ${ this . params . path } " is not within any workspace directory ` ,
2025-07-31 05:38:20 +09:00
returnDisplay : ` Path is not within workspace ` ,
} ;
}
searchDirectories = [ searchDirAbsolute ] ;
} else {
// Search across all workspace directories
searchDirectories = workspaceDirectories ;
}
2025-04-19 19:45:42 +01:00
2025-06-03 21:40:46 -07:00
// Get centralized file discovery service
const respectGitIgnore =
2025-08-07 10:05:37 -07:00
this . params . respect_git_ignore ? ?
2025-06-03 21:40:46 -07:00
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-07-31 05:38:20 +09:00
// Collect entries from all search directories
let allEntries : GlobPath [ ] = [ ] ;
for ( const searchDir of searchDirectories ) {
2025-08-07 10:05:37 -07:00
const entries = ( await glob ( this . params . pattern , {
2025-07-31 05:38:20 +09:00
cwd : searchDir ,
withFileTypes : true ,
nodir : true ,
stat : true ,
2025-08-07 10:05:37 -07:00
nocase : ! this . params . case_sensitive ,
2025-07-31 05:38:20 +09:00
dot : true ,
ignore : [ '**/node_modules/**' , '**/.git/**' ] ,
follow : false ,
signal ,
} ) ) as GlobPath [ ] ;
allEntries = allEntries . concat ( entries ) ;
}
const entries = allEntries ;
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 ) = >
2025-07-14 22:55:49 -07:00
path . relative ( this . config . getTargetDir ( ) , p . fullpath ( ) ) ,
2025-06-03 21:40:46 -07:00
) ;
const filteredRelativePaths = fileDiscovery . filterFiles ( relativePaths , {
respectGitIgnore ,
} ) ;
const filteredAbsolutePaths = new Set (
2025-07-14 22:55:49 -07:00
filteredRelativePaths . map ( ( p ) = >
path . resolve ( this . config . getTargetDir ( ) , p ) ,
) ,
2025-06-03 21:40:46 -07:00
) ;
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 ) {
2025-08-07 10:05:37 -07:00
let message = ` No files found matching pattern " ${ this . params . pattern } " ` ;
2025-07-31 05:38:20 +09:00
if ( searchDirectories . length === 1 ) {
message += ` within ${ searchDirectories [ 0 ] } ` ;
} else {
message += ` within ${ searchDirectories . length } workspace directories ` ;
}
2025-06-03 21:40:46 -07:00
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-08-07 10:05:37 -07:00
let resultMessage = ` Found ${ fileCount } file(s) matching " ${ this . params . pattern } " ` ;
2025-07-31 05:38:20 +09:00
if ( searchDirectories . length === 1 ) {
resultMessage += ` within ${ searchDirectories [ 0 ] } ` ;
} else {
resultMessage += ` across ${ searchDirectories . length } workspace directories ` ;
}
2025-06-03 21:40:46 -07:00
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. ` ,
} ;
}
}
}
2025-08-07 10:05:37 -07:00
/**
* Implementation of the Glob tool logic
*/
export class GlobTool extends BaseDeclarativeTool < GlobToolParams , ToolResult > {
static readonly Name = 'glob' ;
constructor ( private config : Config ) {
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.' ,
2025-08-13 12:58:26 -03:00
Kind . Search ,
2025-08-07 10:05:37 -07:00
{
properties : {
pattern : {
description :
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md')." ,
2025-08-11 16:12:41 -07:00
type : 'string' ,
2025-08-07 10:05:37 -07:00
} ,
path : {
description :
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.' ,
2025-08-11 16:12:41 -07:00
type : 'string' ,
2025-08-07 10:05:37 -07:00
} ,
case_sensitive : {
description :
'Optional: Whether the search should be case-sensitive. Defaults to false.' ,
2025-08-11 16:12:41 -07:00
type : 'boolean' ,
2025-08-07 10:05:37 -07:00
} ,
respect_git_ignore : {
description :
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.' ,
2025-08-11 16:12:41 -07:00
type : 'boolean' ,
2025-08-07 10:05:37 -07:00
} ,
} ,
required : [ 'pattern' ] ,
2025-08-11 16:12:41 -07:00
type : 'object' ,
2025-08-07 10:05:37 -07:00
} ,
) ;
}
/**
* Validates the parameters for the tool.
*/
2025-08-13 16:17:38 -04:00
override validateToolParams ( params : GlobToolParams ) : string | null {
2025-08-11 16:12:41 -07:00
const errors = SchemaValidator . validate (
this . schema . parametersJsonSchema ,
params ,
) ;
2025-08-07 10:05:37 -07:00
if ( errors ) {
return errors ;
}
const searchDirAbsolute = path . resolve (
this . config . getTargetDir ( ) ,
params . path || '.' ,
) ;
const workspaceContext = this . config . getWorkspaceContext ( ) ;
if ( ! workspaceContext . isPathWithinWorkspace ( searchDirAbsolute ) ) {
const directories = workspaceContext . getDirectories ( ) ;
return ` Search path (" ${ searchDirAbsolute } ") resolves outside the allowed workspace directories: ${ directories . join ( ', ' ) } ` ;
}
const targetDir = searchDirAbsolute || this . config . getTargetDir ( ) ;
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 ;
}
protected createInvocation (
params : GlobToolParams ,
) : ToolInvocation < GlobToolParams , ToolResult > {
return new GlobToolInvocation ( this . config , params ) ;
}
}