Skip to content

Instantly share code, notes, and snippets.

@Hashbrown777
Last active October 23, 2022 12:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Hashbrown777/c4737f0f118e9baa20eac805565c9925 to your computer and use it in GitHub Desktop.
Save Hashbrown777/c4737f0f118e9baa20eac805565c9925 to your computer and use it in GitHub Desktop.
SilverSearcher on Windows. Asynchronous simultaneous file searches and pretty match highlights cropped to window (by default)
#https://gist.github.com/Hashbrown777/a5a02e2fd3eeed4485d4ba073ef3b143
. "$PSScriptRoot/async.ps1"
. "$PSScriptRoot/files.ps1"
. "$PSScriptRoot/style.ps1"
#default switches of $True emulate grep/ag behaviour, use `-flag:$False` to disable
Function Grep { [CmdletBinding(PositionalBinding=$False)]Param([Parameter(ValueFromPipeline)]$Input,
#display params
[switch]$OnlyMatching, [switch]$Files, $Max=-1,
#async params
[switch]$Immediate=$True, $Collate=64,
#gci params
$Path='.', $Filter, [switch]$Force, [switch]$Recurse=$True,
#sls params
[Parameter(Position=0,Mandatory)]$Pattern, [switch]$CaseSensitive=$True, [switch]$SimpleMatch=$False, [switch]$NoEmphasis=$False, [switch]$Raw=$True, [switch]$AllMatches=$True, $Context=0, [switch]$List=$False
)
Begin {
if (
#if we are piping to someone else
$PSCmdlet.MyInvocation.PipelinePosition -lt $PSCmdlet.MyInvocation.PipelineLength -and
#and the user didn't explicitly set Raw
!$PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Raw')
) {
#give the consuming pipe MatchInfo
$Raw = $False
}
if ($Files) {
$List = $True
$AllMatches = $False
$Context = 0
}
#`sls -SimpleMatch` destroys `$_.Matches`; emulate
#this has the added benefit of -AllMatches compatibility, which standard sls lacks
if ($SimpleMatch) {
$Pattern = [regex]::Escape($pattern)
}
#this is so hacky, I wish powershell supported deferred pipes
#whilst a function forwarding $Input would work, it halts execution until End is triggered
#not good; this is our only way of having a dynamic pipeline
$processFile = [Asynclet]::new((Get-Command Async).ScriptBlock, @{
#cannot dot-source due to Execution_Policies inside async
Asynclet=[Asynclet]
Collate = $Collate
Immediate = $Immediate
Name = 'Searching files'
Func = 'Select-String'
Parameters = @{
Pattern = $Pattern
CaseSensitive = $CaseSensitive
AllMatches = $AllMatches
Context = $Context
List = $List
}
})
$processInput = [Asynclet]::new('Select-String', @{
Pattern = $Pattern
CaseSensitive = $CaseSensitive
AllMatches = $AllMatches
Context = $Context
})
Function _Process { Param([switch]$End)
Process {
$processInput.readAll()
$processFile.readAll()
if ($_ -is [System.IO.DirectoryInfo]) {
$_ `
| Get-ChildItem `
-File `
-Recurse:$Recurse `
-Force:$Force `
-Filter:$Filter `
| _Process
}
elseif ($_ -is [System.IO.FileInfo]) {
$processFile.input($_)
}
else {
$processInput.input($_)
}
$processInput.readAll()
$processFile.readAll()
}
End {
if ($End) {
$processInput.done()
$processFile.done()
}
}
}
$postProcess = $NULL
if ($Raw) {
#call to `Async` is inside an Asynclet so this doesnt matter
#unfortunately you wont ever get a progressbar now
# if (!$NoEmphasis) {
# #progress bar breaks highlighting
# $ProgressPreference = 'SilentlyContinue' #only affects current function
# }
#likewise Asynclet's dont inherite environments, so we have to do this manually beforehand
if ($Max -lt 0) {
$Max = $HOST.UI.RawUI.WindowSize.Width
}
if ($Path) {
$Path = $Path | AbsolutePath
}
$postProcess = [Asynclet]::new((Get-Command GrepDisplay).ScriptBlock, @{
_style=$style
Ancestors=(Get-Command Ancestors).ScriptBlock
RelativePath=(Get-Command RelativePath).ScriptBlock
Max = $Max
Path = $Path
NoEmphasis = $NoEmphasis
OnlyMatching = $OnlyMatching
Files = $Files
})
}
Function _PostProcess { Param([switch]$End)
Process {
if (!$postProcess) {
$_
return
}
$postProcess.readAll()
$postProcess.input($_)
$postProcess.readAll()
}
End {
if ($postProcess -and $End) {
$postProcess.done()
}
}
}
}
Process {
$_ | _Process | _PostProcess
}
End {
if ($PSCmdlet.MyInvocation.ExpectingInput) {
_Process -End | _PostProcess -End
}
else {
$Path | Get-Item | _Process -End | _PostProcess -End
}
}
}
#displays MatchInfo items from sls as text like grep would with highlighting plus cropping!
Function GrepDisplay { Param(
$_style,
$Ancestors,
$RelativePath,
#max width of output lines. <0 means use terminal width, 0 means do not truncate
$Max=-1,
#path at which to make all output filenames relative to
$Path='.',
#display the matched text instead of the whole line in the output, only takes effect if Raw=$True
[switch]$OnlyMatching,
#display filenames without any line information in the output, only takes effect if Raw=$True
[switch]$Files,
#grep params
[switch]$NoEmphasis
)
Begin {
if (!$_style) {
$_style = $style
}
if ($Max -lt 0) {
$Max = $HOST.UI.RawUI.WindowSize.Width
}
if ($Path) {
if ($Ancestors) {
#if I dont do this I get incredibly bizarre exceptions..
$Ancestors = [scriptblock]::Create($Ancestors)
$RelativePath = [scriptblock]::Create($RelativePath)
$Path = @($Path | & $Ancestors)
}
else {
$Path = @($Path | Ancestors)
}
}
$last = [PSCustomObject]@{
#the last output'd match's file
file = ''
#the last output'd match's line
line = 0
#text following the last match yet to be output
text = @()
}
#output lines so long as we aren't duplicating them
Filter _Print { Param($file='', $line=0, [switch]$greater)
#only actually output-
if (
#-if these are different files
$last.file -ne $file -or
#-or we haven't already printed this line/about to print this line soon
(($last.line -ge $line),($last.line -lt $line))[!$greater]
) {
$last.file,$last.line++,$_ -join "`t"
}
#even under failure, we need to increment for lines already printed
elseif ($greater) {
++$last.line
}
}
#output a match taking care of context lines
Function Print { Param($file, $line, $prev, $text, $next)
#starting from the start of the context for this new match
$start = $line - $prev.Count
#print the context from the last match UP TO but not including this point
$last.text | _Print -file $file -line $start
#record where it got up to
$from = $last.file,$last.line
#add a nice spacer for different files/gap in line count
if ($noEmphasis -or !$last.file) {
}
elseif ($last.file -ne $file) {
''
}
elseif (!$last.text.Count) {
}
elseif ($last.line -lt $start) {
$file + "`t" + [char]0x2026
}
#now print the context from the current match STARTING FROM where we left off
$last.file = $file
$last.line = $start
$prev | _Print -greater -file $from[0] -line $from[1]
#finally print the current line
$text | _Print
$last.text = $next
}
}
Process {
$file = `
if ($_.Path -eq 'InputStream') {
'-'
}
elseif ($Path) {
if ($RelativePath) {
,($_.Path | & $Ancestors -File:$True) | & $RelativePath -From $Path
}
else {
#`$_.RelativePath()` doesn't work if the results are above the current directory
$_.Path | RelativePath -From $Path
}
}
else {
$_.Path
}
$line = $_.LineNumber
$text = $_.Line
$match = @(
$_.Matches `
| %{
,@($_.Index, $_.Length)
}
)
if ($OnlyMatching) {
$match `
| %{
$file,$line,$_[0],$text.SubString($_[0], $_[1]) -join "`t"
}
return
}
if (!$NoEmphasis) {
#how far outside the bounds is the text? (assume tabs/margins etc take up 20characters)
$cutdown = $file.Length + $text.Length + 20 - $Max
#if we need to truncate
if ($Max -and $cutdown -gt 0) {
#get the size of the text outside which encompass all matches for this line
$left = $match[0][0]
$right = $text.Length - ($match[-1][0] + $match[-1][1])
#if there is enough space after all the matches to satisfy the width constraint
if ($right -gt $cutdown) {
#truncate the right hand side
$text = $text.Substring(0, $text.Length - $cutdown) + [char]0x2026
$cutdown = 0
}
#if there is enough space before all the matches to satisfy the width constraint
elseif ($left -gt $cutdown) {
#truncate the left
$text = [char]0x2026 + $text.Substring($cutdown)
$cutdown = 1 - $cutdown
}
#otherwise we're going to have to truncate both sides to try and make the text fit
else {
#how many characters either side of the matches are there;
$cutdown = $left + $right - $cutdown
#-is this greater than the number of characters we are over-budget by?
if ($cutdown -gt 0) {
#if so, evenly space these spare characters either side
$cutdown = [int]($cutdown / 2)
$left -= $cutdown
$right -= $cutdown
#otherwise truncate all the way up to the first and last match
}
$text = [char]0x2026 + $text.Substring($left, $text.Length - $right - $left) + [char]0x2026
$cutdown = 1 - $left
}
}
#otherwise dont shift the highlight index
else {
$cutdown = 0
}
#insert highlighting escape sequences in reverse as it changes the length of the text, breaking indices
[array]::Reverse($match)
$match `
| %{
$text = $text.Insert($cutdown + $_[0] + $_[1], $_style.nonegative).Insert($cutdown + $_[0], $_style.negative)
}
}
#non-matching context lines are much more simply truncated
Filter Max {
$cutdown = $file.Length + $_.Length + 20 - $Max
#if we arent outside the bounds, simply return
if ($cutdown -le 0 -or !$Max) {
$_
}
#otherwise truncate from the right to keep any contextual indentation
elseif ($_.Length -gt $cutdown) {
$_.Substring(0, $_.Length - $cutdown) + [char]0x2026
}
#if however truncation isn't the issue (id est the length of the filename etc alone surpasses the width!)
else {
#omit the text entirely
[char]0x2026
}
}
$pre = @(switch ($_.Context.PreContext) {
{ $_.Count -gt 0 } { $_ | Max }
})
$post = @(switch ($_.Context.PostContext) {
{ $_.Count -gt 0 } { $_ | Max }
})
if (!$Files) {
Print `
-file $file `
-line $line `
-prev $pre `
-text $text `
-next $post
}
#if we're only printing filenames:
elseif ($file -ne $last.file) {
($last.file = $file)
}
}
End {
#dont forget the last match's context!
$last.text | _Print
}
}
@Hashbrown777
Copy link
Author

More than twice as fast as the equivalent sls, more sensible api for interactive shell, and much prettier.
Whitespace between matched files, nice separator between disparate lines of the same files, match highlighting (this is PowerShell5 compatible, sls gained this in 7), and truncation on lines longer than the console width (only truncates so far as to not obfuscate any actual matches).

All of these are configurable via flags, on top of the changed defaults/added behaviour of:

  • directories automatically pull files from for searching, recursively, and filterable
  • if no input is received the Path parameter is used, which defaults to the current directory
  • files can still be individually input directly, along with strings (streams) and MatchInfo (from previous grep or sls calls)
  • -AllMatches is on by default and -SimpleMatch is not mutually exclusive with it like in sls

Like sls piping to another application has grep output MatchInfo objects, which you can otherwise force with -Raw:$False (or conversely force pretty formatted text to another cmdlet with -Raw), and if you already have a MatchInfo collection and only want to use the pretty print functionality of this script you can pipe it into GrepDisplay.

pwshgrep

Eg

  • gci -recurse -file | sls -context 3 -allmatches 'regularExpression' is just
    grep -context 3 'regularExpression'
  • './folder1','../folder2','./folder/3' | gci -filter '*.xml' | sls 'regularExpression' is equivalent to
    './folder1','../folder2','./folder/3' | gi | grep -recurse:$False -filter '*.xml' 'regularExpression'
  • git history | sls -SimpleMatch 'someText' | ?{ $_.LineNumber -gt 100 } unconfusingly is still
    git history | grep -SimpleMatch 'someText' | ?{ $_.LineNumber -gt 100 }
  • $bob = gi bigFile | SomeOtherSearchProvider 'regularExpression'; $bob | sls 'someFilter'; $bob | Out-String
    $bob = gi bigFile | SomeOtherSearchProvider 'regularExpression'; $bob | grep 'someFilter'; $bob | GrepDisplay

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment