Functions/GenXdev.FileSystem/Find-Item.ps1
################################################################################ <# .SYNOPSIS Performs advanced file and directory searches with content filtering capabilities. .DESCRIPTION A powerful search utility that combines file/directory pattern matching with content filtering. Supports recursive searches, multi-drive operations, and flexible output formats. Can search by name patterns and content patterns simultaneously. .PARAMETER SearchMask File or directory pattern to match against. Supports wildcards (*,?). Default is "*" to match everything. .PARAMETER Pattern Regular expression to search within file contents. Only applies to files. Default is ".*" to match any content. .PARAMETER RelativeBasePath Base directory for generating relative paths in output. Only used when -PassThru is not specified. .PARAMETER AllDrives When specified, searches across all available filesystem drives. .PARAMETER Directory Limits search to directories only, ignoring files. .PARAMETER FilesAndDirectories Includes both files and directories in search results. .PARAMETER PassThru Returns FileInfo/DirectoryInfo objects instead of paths. .PARAMETER NoRecurse Prevents recursive searching into subdirectories. .EXAMPLE # Find all files with that have the word "translation" in their content Find-Item -Pattern "translation" # or in short l -mc translation .EXAMPLE # Find any javascript file that tests a version string in it's code Find-Item -SearchMask *.js -Pattern "Version == `"\d\d?\.\d\d?\.\d\d?`"" # or in short l *.js "Version == `"\d\d?\.\d\d?\.\d\d?`"" .EXAMPLE # Find any node_modules\react-dom folder on all drives Find-Item -SearchMask "node_modules\react-dom" -Pattern "Version == `"\d\d?\.\d\d?\.\d\d?`"" # or in short l *.js "Version == `"\d\d?\.\d\d?\.\d\d?`"" .EXAMPLE # Find all directories in the current directory and its subdirectories Find-Item -Directory # or in short l -dir .EXAMPLE # Find all files with the .log extension in all drives Find-Item -SearchMask "*.log" -AllDrives # or in short l *.log -all .EXAMPLE # Find all files with the .config extension and search for the pattern "connectionString" within the files Find-Item -SearchMask "*.config" -Pattern "connectionString" # or in short l *.config connectionString .EXAMPLE # Find all files with the .xml extension and pass the objects through the pipeline Find-Item -SearchMask "*.xml" -PassThru # or in short l *.xml -PassThru #> function Find-Item { [CmdletBinding(DefaultParameterSetName = "Default")] [Alias("l")] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] param( ######################################################################## [Parameter( Mandatory = $false, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = "File name or pattern to search for. Default is '*'" )] [Alias("like", "l", "Path", "Name", "file", "Query", "FullName")] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]] $SearchMask = "*", ######################################################################## [Parameter( Mandatory = $false, Position = 1, ParameterSetName = 'WithPattern', HelpMessage = "Regular expression pattern to search within content" )] [Alias("mc", "matchcontent")] [ValidateNotNull()] [SupportsWildcards()] [string] $Pattern = ".*", ######################################################################## [Parameter( Mandatory = $false, HelpMessage = "Base path for resolving relative paths in output" )] [Alias("base")] [ValidateNotNullOrEmpty()] [string] $RelativeBasePath = ".\", ######################################################################## [Parameter( Mandatory = $false, HelpMessage = "Search across all available drives" )] [Alias("all")] [switch] $AllDrives, ######################################################################## [Parameter( Mandatory = $false, ParameterSetName = 'DirectoriesOnly', HelpMessage = "Search for directories only" )] [Alias("dir")] [switch] $Directory, ######################################################################## [Parameter( Mandatory = $false, ParameterSetName = 'DirectoriesOnly', HelpMessage = "Include both files and directories" )] [Alias("both")] [switch] $FilesAndDirectories, ######################################################################## [Parameter( Mandatory = $false, HelpMessage = "Output matched items as objects" )] [switch] $PassThru, ######################################################################## [Parameter( Mandatory = $false, HelpMessage = "Do not recurse into subdirectories" )] [switch] $NoRecurse ######################################################################## ) begin { # helper function to search file contents using regex function Search-FileContent { param ( [string] $filePath, [string] $pattern ) return (Select-String -Path $filePath -Pattern $pattern) } # helper function to recursively search directories function Search-DirectoryContent { param ( [string] $searchPhrase ) # handle empty search phrase by defaulting to current directory if ([string]::IsNullOrWhiteSpace($searchPhrase)) { $searchPhrase = ".\*" } # clean up and normalize the search path $searchPhrase = $searchPhrase.Trim() # ensure proper path termination for directories $endedWithPathSeparator = $searchPhrase.EndsWith( [System.IO.Path]::DirectorySeparatorChar) if ($endedWithPathSeparator) { $searchPhrase += "*" } # convert to absolute path $searchPhrase = GenXdev.FileSystem\Expand-Path $searchPhrase $remainingPath = $searchPhrase # initialize stack for directory traversal [System.Collections.Generic.Stack[System.Collections.Hashtable]] ` $directories = @() # find the next separator $index = $remainingPath.IndexOf([System.IO.Path]::DirectorySeparatorChar) $indexOriginal = $index $indexWildcard = $remainingPath.IndexOf("*") $indexQuestionMark = $remainingPath.IndexOf("?") if ($indexQuestionMark -ge 0 -and ($indexWildcard -lt 0 -or $indexQuestionMark -lt $indexWildcard)) { $indexWildcard = $indexQuestionMark } $last = $index -eq -1 # be more effecient by skipping directories that don't require a match # have no wildcard or a wildcard in the path that comes after next directory separator? if ((-not $last) -and (($indexWildCard -lt 0) -or ($indexWildcard -gt $index))) { # determine start position for searching wildcard preceeding directory separator character $index = $indexWildcard -lt 0 ? $index : $indexWildcard # determine if there is a wildcard after the wildcard character $index2 = $indexWildcard -lt 0 ? -1 : $remainingPath.IndexOf([System.IO.Path]::DirectorySeparatorChar, $indexWildcard) # if wildcard was found, search for the preceeding directory separator character if ($indexWildCard -ge 0) { while ($index -ge 1 -and ($remainingPath[$index] -ne [System.IO.Path]::DirectorySeparatorChar)) { $index-- } } # wildcard was found and did not have a preceeding directory separator character? if ($index2 -lt 0) { # set last flag to true, for later processing $last = $true # if wildcard was present, make sure to adjust the position so we don't include # the directory where the wildcard was found in our next directory scan if ($indexWildcard -ge 0) { # we move the cursor to one character before directory separator.. $index--; } else { # if no wildcard was found, we move the cursor to the last directory separator.. $index = $remainingPath.LastIndexOf([System.IO.Path]::DirectorySeparatorChar) - 1; } # ..so we can now exlude the directory holding the wildcard from our next directory scan while ($index -ge 1 -and ($remainingPath[$index] -ne [System.IO.Path]::DirectorySeparatorChar)) { $index-- } } } # prepare the invocation arguments for the first directory scan $invocationArgs = @{ Path = "$($currentPath)*" Force = $true ErrorAction = "SilentlyContinue" } # have no wildcard or a wildcard in the path that comes after next directory separator? if (($index -ge 0) -and (($indexWildcard -lt 0) -or ($indexWildcard -gt $index))) { # wildcard was found and did not have a preceeding directory separator character? if ($last) { # find the last directory separator character $i = $remainingPath.LastIndexOf([System.IO.Path]::DirectorySeparatorChar) # set the appropiate path to search $invocationArgs.Path = "$($currentPath)$($remainingPath.Substring(0, $i))\*" } else { # set the appropiate path to search $invocationArgs.Path = "$($currentPath)$($remainingPath.Substring(0, $indexWildcard))*" } } # push the first directory scan $null = $directories.Push( @{ currentPath = $remainingPath.Substring(0, $index + 1) remainingPath = $remainingPath.Substring($index + 1) currentDepth = 0 } ) # process directories [hashtable]$folder = $null while ($directories.TryPop([ref]$folder)) { # find the next directory separator in the remaining path $index = $folder.remainingPath.IndexOf([System.IO.Path]::DirectorySeparatorChar) # save the original index for later use $indexOriginal = $index # find the first wildcard in the remaining path $indexWildcard = $folder.remainingPath.IndexOf("*") $indexQuestionMark = $folder.remainingPath.IndexOf("?") if ($indexQuestionMark -ge 0 -and ($indexWildcard -lt 0 -or $indexQuestionMark -lt $indexWildcard)) { $indexWildcard = $indexQuestionMark } # determine if this is the last directory in the path $last = $index -eq -1 # be more effecient by skipping directories that don't require a match # have no wildcard or a wildcard in the path that comes after next directory separator? if ((-not $last) -and (($indexWildCard -lt 0) -or ($indexWildcard -gt $index))) { # determine start position for searching wildcard preceeding directory separator character $index = $indexWildcard -lt 0 ? $index - 1 : $indexWildcard # determine if there is a wildcard after the wildcard character $index2 = $indexWildcard -lt 0 ? -1 : $folder.remainingPath.IndexOf([System.IO.Path]::DirectorySeparatorChar, $indexWildcard); # if wildcard was found, search for the preceeding directory separator character if ($indexWildCard -ge 0) { while ($index -ge 1 -and ($folder.remainingPath[$index] -ne [System.IO.Path]::DirectorySeparatorChar)) { $index-- } } # wildcard was found and did not have a preceeding directory separator character? if ($index2 -lt 0) { # set last flag to true, for later processing $last = $true # if wildcard was present, make sure to adjust the position so we don't include # the directory where the wildcard was found in our next directory scan if ($indexWildcard -ge 0) { # we move the cursor to one character before directory separator.. $index--; } else { # if no wildcard was found, we move the cursor to the last directory separator.. $index = $remainingPath.LastIndexOf([System.IO.Path]::DirectorySeparatorChar) - 1; } # ..so we can now exlude the directory holding the wildcard from our next directory scan while ($index -ge 1 -and ($folder.remainingPath[$index] -ne [System.IO.Path]::DirectorySeparatorChar)) { $index-- } } } # prepare the invocation arguments for the next directory scan $invocationArgs = @{ Path = "$($folder.currentPath)*" Force = $true ErrorAction = "SilentlyContinue" } # have no wildcard or a wildcard in the path that comes after next directory separator? if (($index -ge 0) -and (($indexWildcard -lt 0) -or ($indexWildcard -gt $index))) { # wildcard was found and did not have a preceeding directory separator character? if ($last) { # find the last directory separator character $i = $folder.remainingPath.LastIndexOf([System.IO.Path]::DirectorySeparatorChar) # set the appropiate path to search $invocationArgs.Path = "$($folder.currentPath)$($folder.remainingPath.Substring(0, $i))\*" # set the name to match for the next directory scan $nameToMatch = $folder.remainingPath.Substring($i + 1) } else { # set the appropiate path to search $invocationArgs.Path = "$($folder.currentPath)$($folder.remainingPath.Substring(0, $indexWildcard))*" # set the name to match for the next directory scan $nameToMatch = $folder.remainingPath } } else { # are we following a /**/ pattern but haven't found the first matching directory yet? if ($folder.forwardSearch) { # set the name to match for the next directory scan $nameToMatch = $folder.nameToMatch # force the last flag to true, so we can keep following /**/ pattern without # losing the informatino what directory to match next $last = $folder.remainingPath.Substring(3).IndexOf( [System.IO.Path]::DirectorySeparatorChar) -lt 0; } else { # set the name to match for the next directory scan $nameToMatch = $folder.remainingPath } } # if we are not at the last directory in the path if (-not $last) { # and we are not following a /**/ pattern if (-not $folder.forwardSearch) { # then set the next directory to match to be the next directory in the path $nameToMatch = $folder.remainingPath.Substring(0, $indexOriginal) } # only find directories, since there are more directories to match $invocationArgs.Directory = $true # invoke the next directory scan Get-ChildItem @invocationArgs | ForEach-Object { # are we following a /**/ pattern? if ($folder.forwardSearch) { # is this the next directory to match if ($_.Name -like $nameToMatch) { $remainingPath = $folder.remainingPath.Substring(3); $i = $remainingPath.IndexOf([System.IO.Path]::DirectorySeparatorChar) if ($i -ge 0) { $remainingPath = $remainingPath.Substring($i + 1) } # schedule directory scan that stop the following of the /**/ pattern # and will continue the normal directory matching pattern again # where remainingPath will get shorter again $null = $directories.Push( @{ remainingPath = $remainingPath currentPath = "$($folder.currentPath)$($_.Name)\" currentDepth = $folder.currentDepth + 1 } ) Write-Verbose "Ending /**/ search for $nameToMatch in $($directories.Peek().currentPath)" } else { # schedule directory scan that will keep following the /**/ pattern # where remainingPath will remain the same until we find the next directory to match $null = $directories.Push( @{ forwardSearch = $true remainingPath = $folder.remainingPath currentPath = "$($folder.currentPath)$($_.Name)\" currentDepth = $folder.currentDepth + 1 nameToMatch = $folder.remainingPath.Substring(3).Split([System.IO.Path]::DirectorySeparatorChar)[0] } ) Write-Verbose "Continuing following /**/ search for $($directories.Peek().$nameToMatch) in $($directories.Peek().currentPath)" } } # not following a /**/ pattern, so we are looking for the next directory to match # but first we check if we are entering a /**/ pattern elseif ($nameToMatch -eq "**") { # schedule directory scan that will start following the /**/ pattern # we do this for every other directory we no found during this scan $null = $directories.Push( @{ forwardSearch = $true remainingPath = $folder.remainingPath currentPath = "$($folder.currentPath)$($_.Name)\" currentDepth = $folder.currentDepth + 1 nameToMatch = $folder.remainingPath.Substring(3).Split([System.IO.Path]::DirectorySeparatorChar)[0] } ) Write-Verbose "Starting /**/ search for $($directories.Peek().$nameToMatch) in $($directories.Peek().currentPath)" } # we are not starting or following a /**/ pattern, # so we only schedule directories that match the name to match elseif ($_.Name -like $nameToMatch) { $null = $directories.Push( @{ remainingPath = $folder.remainingPath.Substring($index + 1) currentPath = "$($folder.currentPath)$($_.Name)\" currentDepth = $folder.currentDepth + 1 } ) Write-Verbose "Matched next directory for $($nameToMatch) in $($directories.Peek().currentPath)" } } continue; } # we now at the last directory of the SearchPhrase supplied # invoke the directory scan # we scan for files and directories, since this is the last directory to match Get-ChildItem @invocationArgs | ForEach-Object { # determine if the found item is a directory $isDirectory = $_ -is [System.IO.DirectoryInfo] # if we find directories after the last directory of the SearchPhrase supplied # we only want to scan them too if the user has specified to do so # by default we recurse directories if ($isDirectory -and (-not $NoRecurse)) { # schedule directory scan for this additionaly found directory $null = $directories.Push( @{ remainingPath = ($Last) ? "$nameToMatch" : "*" currentPath = "$($_.FullName)\" currentDepth = $folder.currentDepth + 1 } ) Write-Verbose "Recursing after last matched directory in $($directories.Peek().currentPath)" } # if the item does not match the name pattern supplied # we skip it and continue with the next item if (-not ($_.Name -like $nameToMatch)) { return } # determine if the item being a directory or file # matches the type of item the user wants to search for $typeOk = ($isDirectory -and ($Directory -or $FilesAndDirectories)) -or ((-not $Directory) -and (-not $isDirectory)) if ($typeOk) { # if this is a file and a regular expression pattern was supplied # we search the file content for the pattern to see if the user # wants to include this file in the search results if ($isDirectory -or [string]::IsNullOrWhiteSpace($Pattern) -or ($Pattern -eq ".*") -or ( # match the file content with the regular expression pattern Search-FileContent -FilePath $_.FullName -Pattern $Pattern )) { Write-Verbose "Found $($_.FullName)" # output FileInfo/DirectoryInfo objects if -PassThru is specified if ($PassThru) { Write-Output $_ return; } # or output the relative path of the found item Resolve-Path -Path $_ -Relative -RelativeBasePath:$RelativeBasePath } } } } } } process { Write-Verbose ("Starting search with patterns: " + ($SearchMask -join ", ")) if ($AllDrives) { Write-Verbose "Searching across all available drives" # parallel search across all filesystem drives Get-PSDrive -ErrorAction SilentlyContinue | Where-Object Provider -EQ FileSystem | ForEach-Object -ThrottleLimit 8 -Parallel { foreach ($currentSearchPhrase in $using:SearchMask) { Write-Verbose "Processing search pattern: $currentSearchPhrase" $expandedPath = GenXdev.FileSystem\Expand-Path $currentSearchPhrase ` -ForceDrive $PSItem.Name Search-DirectoryContent -SearchPhrase $expandedPath } } return } # sequential search for each pattern foreach ($currentSearchPhrase in $SearchMask) { Write-Verbose "Processing search pattern: $currentSearchPhrase" Search-DirectoryContent -SearchPhrase $currentSearchPhrase } } end { } } ################################################################################ |