Last active
October 23, 2022 12:28
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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 | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
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.Eg
gci -recurse -file | sls -context 3 -allmatches 'regularExpression'
is justgrep -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 stillgit 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