Utils/grep.ps1
|
Register-GnuUtil @{ Name = 'grep' Category = 'Searching and comparing' Synopsis = 'Print lines that match patterns.' Usage = @('grep [OPTION]... PATTERNS [FILE]...') Description = @( 'Search for PATTERNS in each FILE.', 'Example: grep -i ''hello world'' menu.h main.c', 'PATTERNS can contain multiple patterns separated by newlines.' ) UsageExitCode = 2 NumericOption = 'C' Options = @( @{ Short = 'E'; Long = 'extended-regexp'; Key = 'syntax'; Value = 'extended'; Help = 'PATTERNS are extended regular expressions' } @{ Short = 'F'; Long = 'fixed-strings'; Key = 'syntax'; Value = 'fixed'; Help = 'PATTERNS are strings' } @{ Short = 'G'; Long = 'basic-regexp'; Key = 'syntax'; Value = 'basic'; Help = 'PATTERNS are basic regular expressions' } @{ Short = 'P'; Long = 'perl-regexp'; Key = 'syntax'; Value = 'perl'; Help = 'PATTERNS are Perl regular expressions' } @{ Short = 'e'; Long = 'regexp'; Arg = 'PATTERNS'; Multi = $true; Help = 'use PATTERNS for matching' } @{ Short = 'f'; Long = 'file'; Arg = 'FILE'; Multi = $true; Help = 'take PATTERNS from FILE' } @{ Short = 'i'; Long = 'ignore-case'; Key = 'case'; Value = 'ignore'; Help = 'ignore case distinctions in patterns and data' } @{ Long = 'no-ignore-case'; Key = 'case'; Value = 'match'; Help = 'do not ignore case distinctions (default)' } @{ Short = 'w'; Long = 'word-regexp'; Help = 'match only whole words' } @{ Short = 'x'; Long = 'line-regexp'; Help = 'match only whole lines' } @{ Short = 'z'; Long = 'null-data'; Help = 'a data line ends in 0 byte, not newline' } @{ Short = 's'; Long = 'no-messages'; Help = 'suppress error messages' } @{ Short = 'v'; Long = 'invert-match'; Help = 'select non-matching lines' } @{ Short = 'm'; Long = 'max-count'; Arg = 'NUM'; Help = 'stop after NUM selected lines' } @{ Short = 'b'; Long = 'byte-offset'; Help = 'print the byte offset with output lines' } @{ Short = 'n'; Long = 'line-number'; Help = 'print line number with output lines' } @{ Short = 'H'; Long = 'with-filename'; Key = 'filename'; Value = 'show'; Help = 'print file name with output lines' } @{ Short = 'h'; Long = 'no-filename'; Key = 'filename'; Value = 'hide'; Help = 'suppress the file name prefix on output' } @{ Long = 'label'; Arg = 'LABEL'; Help = 'use LABEL as the standard input file name prefix' } @{ Short = 'o'; Long = 'only-matching'; Help = 'show only nonempty parts of lines that match' } @{ Short = 'q'; Long = 'quiet'; Key = 'quiet'; Help = 'suppress all normal output' } @{ Long = 'silent'; Key = 'quiet'; Help = 'same as --quiet' } @{ Long = 'binary-files'; Arg = 'TYPE'; Help = 'assume that binary files are TYPE; TYPE is ''binary'', ''text'', or ''without-match''' } @{ Short = 'a'; Long = 'text'; Key = 'binary-files'; Value = 'text'; Help = 'equivalent to --binary-files=text' } @{ Short = 'I'; Key = 'binary-files'; Value = 'without-match'; Help = 'equivalent to --binary-files=without-match' } @{ Short = 'r'; Long = 'recursive'; Help = 'like --directories=recurse' } @{ Short = 'R'; Long = 'dereference-recursive'; Key = 'recursive'; Help = 'likewise, but follow all symlinks' } @{ Long = 'include'; Arg = 'GLOB'; Multi = $true; Help = 'search only files that match GLOB (a file pattern)' } @{ Long = 'exclude'; Arg = 'GLOB'; Multi = $true; Help = 'skip files that match GLOB' } @{ Long = 'exclude-dir'; Arg = 'GLOB'; Multi = $true; Help = 'skip directories that match GLOB' } @{ Short = 'L'; Long = 'files-without-match'; Key = 'list'; Value = 'without'; Help = 'print only names of FILEs with no selected lines' } @{ Short = 'l'; Long = 'files-with-matches'; Key = 'list'; Value = 'with'; Help = 'print only names of FILEs with selected lines' } @{ Short = 'c'; Long = 'count'; Help = 'print only a count of selected lines per FILE' } @{ Short = 'Z'; Long = 'null'; Help = 'print 0 byte after FILE name' } @{ Short = 'B'; Long = 'before-context'; Arg = 'NUM'; Help = 'print NUM lines of leading context' } @{ Short = 'A'; Long = 'after-context'; Arg = 'NUM'; Help = 'print NUM lines of trailing context' } @{ Short = 'C'; Long = 'context'; Arg = 'NUM'; Help = 'print NUM lines of output context' } @{ Long = 'color'; Arg = 'WHEN'; OptionalArg = $true; Help = 'use markers to highlight the matching strings; WHEN is ''always'', ''never'', or ''auto''' } @{ Long = 'colour'; Key = 'color'; Arg = 'WHEN'; OptionalArg = $true; Hidden = $true; Help = 'same as --color' } ) Notes = @( 'When FILE is ''-'', read standard input. With no FILE, read ''.'' if', 'recursive, ''-'' otherwise. With fewer than two FILEs, assume -h.', 'Exit status is 0 if any line is selected, 1 otherwise;', 'if any error occurs and -q is not given, the exit status is 2.', '', 'PowerShell notes: basic and extended expressions are translated to .NET regular expressions', '(POSIX classes, \< \>, backreferences); -P uses .NET syntax directly. Pipeline objects are matched', 'against the text the console would show, so "Get-Process | grep pwsh" works. Pipeline input is', 'searched as it arrives (Get-Content -Wait | grep ... works).' ) } function script:New-GnuGrepState { <# .SYNOPSIS Validates options, compiles the patterns and returns the search state (or marks the context Done). #> param([Parameter(Mandatory)][hashtable]$Context, [bool]$ToConsole) # Patterns: -e / -f, or the first operand. $patterns = New-Object 'System.Collections.Generic.List[string]' $explicit = (Test-GnuOption -Context $Context -Key 'regexp') -or (Test-GnuOption -Context $Context -Key 'file') foreach ($value in @(Get-GnuOptionValue -Context $Context -Key 'regexp' -Default @())) { foreach ($part in $value.Split("`n")) { $patterns.Add($part) } } foreach ($name in @(Get-GnuOptionValue -Context $Context -Key 'file' -Default @())) { $text = Get-GnuInputText -Context $Context -Operand $name if ($null -eq $text) { $Context.ExitCode = 2; $Context.Done = $true; return } foreach ($part in @(Split-GnuRecord -Text $text.Replace("`r`n", "`n"))) { $patterns.Add($part) } } $operands = New-Object 'System.Collections.Generic.List[string]' foreach ($operand in $Context.Operands) { $operands.Add($operand) } if (-not $explicit) { if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context return } foreach ($part in $operands[0].Split("`n")) { $patterns.Add($part) } $operands.RemoveAt(0) } $syntax = [string](Get-GnuOptionValue -Context $Context -Key 'syntax' -Default 'basic') $converted = New-Object 'System.Collections.Generic.List[string]' foreach ($pattern in $patterns) { switch ($syntax) { 'fixed' { $converted.Add([regex]::Escape($pattern)) } 'perl' { $converted.Add($pattern) } default { $result = ConvertTo-GnuDotNetRegex -Pattern $pattern -Extended:($syntax -eq 'extended') if ($result.ContainsKey('Error')) { Write-GnuError -Context $Context -Message $result.Error -ExitCode 2 $Context.Done = $true return } $converted.Add($result.Regex) } } } $regex = $null if ($converted.Count -gt 0) { $joined = (@($converted | ForEach-Object { "(?:$_)" })) -join '|' if (Test-GnuOption -Context $Context -Key 'word-regexp') { $joined = "(?<!\w)(?:$joined)(?!\w)" } if (Test-GnuOption -Context $Context -Key 'line-regexp') { $joined = "^(?:$joined)$" } $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant if ((Get-GnuOptionValue -Context $Context -Key 'case' -Default 'match') -eq 'ignore') { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } try { $regex = New-Object System.Text.RegularExpressions.Regex -ArgumentList $joined, $options } catch { # .NET messages are localized, so classify the error ourselves. $message = 'Invalid regular expression' switch (Get-GnuParenthesisBalance -Regex $joined) { 'open' { $message = 'Unmatched ( or \(' } 'close' { $message = 'Unmatched ) or \)' } } Write-GnuError -Context $Context -Message $message -ExitCode 2 $Context.Done = $true return } } $numbers = @{} foreach ($key in @('max-count', 'after-context', 'before-context', 'context')) { $numbers[$key] = -1 if (-not (Test-GnuOption -Context $Context -Key $key)) { continue } $text = [string](Get-GnuOptionValue -Context $Context -Key $key) if ($text -notmatch '^\d+$') { $what = 'context length argument' if ($key -eq 'max-count') { $what = 'max count' } Write-GnuError -Context $Context -Message "invalid $($what): '$text'" -ExitCode 2 $Context.Done = $true return } $numbers[$key] = ConvertTo-GnuInt64 -Value (ConvertFrom-GnuSize -Text $text) } $after = $numbers['after-context'] $before = $numbers['before-context'] if ($after -lt 0) { $after = [Math]::Max([long]0, $numbers['context']) } if ($before -lt 0) { $before = [Math]::Max([long]0, $numbers['context']) } $binary = [string](Get-GnuOptionValue -Context $Context -Key 'binary-files' -Default 'binary') if (@('binary', 'text', 'without-match') -notcontains $binary) { Write-GnuUsageError -Context $Context -Message "invalid argument '$binary' for '--binary-files'`nValid arguments are: 'binary', 'text', 'without-match'" return } $color = $false if (Test-GnuOption -Context $Context -Key 'color') { $when = [string](Get-GnuOptionValue -Context $Context -Key 'color') switch -Regex ($when) { '^$|^(always|yes|force)$' { $color = $true } '^(never|no|none)$' { $color = $false } '^(auto|tty|if-tty)$' { $color = $ToConsole } default { Write-GnuUsageError -Context $Context -Message "invalid argument '$when' for '--color'`nValid arguments are: 'always', 'yes', 'force', 'never', 'no', 'none', 'auto', 'tty', 'if-tty'" return } } } $recursive = Test-GnuOption -Context $Context -Key 'recursive' if ($operands.Count -eq 0) { if ($recursive) { $operands.Add('.') } else { $operands.Add('-') } } $Context.Operands = [string[]]$operands.ToArray() $label = '(standard input)' if (Test-GnuOption -Context $Context -Key 'label') { $label = [string](Get-GnuOptionValue -Context $Context -Key 'label') } $filenameMode = [string](Get-GnuOptionValue -Context $Context -Key 'filename' -Default 'auto') $showNames = $filenameMode -eq 'show' -or ($filenameMode -eq 'auto' -and ($operands.Count -gt 1 -or $recursive)) @{ Regex = $regex Invert = Test-GnuOption -Context $Context -Key 'invert-match' OnlyMatching = Test-GnuOption -Context $Context -Key 'only-matching' Count = Test-GnuOption -Context $Context -Key 'count' List = [string](Get-GnuOptionValue -Context $Context -Key 'list' -Default '') Quiet = Test-GnuOption -Context $Context -Key 'quiet' LineNumbers = Test-GnuOption -Context $Context -Key 'line-number' ByteOffset = Test-GnuOption -Context $Context -Key 'byte-offset' Null = Test-GnuOption -Context $Context -Key 'null' NullData = Test-GnuOption -Context $Context -Key 'null-data' NoMessages = Test-GnuOption -Context $Context -Key 'no-messages' MaxCount = $numbers['max-count'] After = $after Before = $before Binary = $binary Color = $color ShowNames = $showNames Label = $label Recursive = $recursive Include = @(Get-GnuOptionValue -Context $Context -Key 'include' -Default @()) Exclude = @(Get-GnuOptionValue -Context $Context -Key 'exclude' -Default @()) ExcludeDir = @(Get-GnuOptionValue -Context $Context -Key 'exclude-dir' -Default @()) Matched = $false Finished = $false # -q found a match: nothing else needs to be read PrintedGroups = $false # for the '--' separator between context groups Context = $Context } } function script:New-GnuGrepFile { <# .SYNOPSIS Per-input state of the line engine. #> param([Parameter(Mandatory)][string]$Name) @{ Name = $Name LineNumber = [long]0 Offset = [long]0 Selected = [long]0 Before = New-Object 'System.Collections.Generic.Queue[object]' AfterRemaining = [long]0 LastPrinted = [long]0 Stopped = $false Binary = $false } } function script:Format-GnuGrepColor { param([string]$Text, [string]$Code, [bool]$Color) if (-not $Color -or $Text.Length -eq 0) { return $Text } [char]27 + "[$($Code)m" + [char]27 + '[K' + $Text + [char]27 + '[m' + [char]27 + '[K' } function script:Format-GnuGrepPrefix { <# .SYNOPSIS 'file:12:' style prefix; Separator is ':' for selected lines and '-' for context lines. #> param([hashtable]$State, [hashtable]$File, [long]$LineNumber, [long]$Offset, [string]$Separator) $prefix = '' $sep = Format-GnuGrepColor -Text $Separator -Code '36' -Color $State.Color if ($State.ShowNames) { $prefix += Format-GnuGrepColor -Text $File.Name -Code '35' -Color $State.Color if ($State.Null) { $prefix += [string][char]0 } else { $prefix += $sep } } if ($State.LineNumbers) { $prefix += (Format-GnuGrepColor -Text ([string]$LineNumber) -Code '32' -Color $State.Color) + $sep } if ($State.ByteOffset) { $prefix += (Format-GnuGrepColor -Text ([string]$Offset) -Code '32' -Color $State.Color) + $sep } $prefix } function script:Step-GnuGrepLine { <# .SYNOPSIS Processes one input line: emits selected lines, context lines and separators as needed. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$File, [Parameter(Mandatory)][AllowEmptyString()][string]$Line) if ($State.Finished -or ($File.Stopped -and $File.AfterRemaining -le 0)) { return } $File.LineNumber++ $offset = $File.Offset $File.Offset += $script:GnuUtf8.GetByteCount($Line) + 1 $isMatch = $false if ($null -ne $State.Regex) { $isMatch = $State.Regex.IsMatch($Line) } $selected = $isMatch -ne $State.Invert if ($File.Stopped) { # After -m NUM lines GNU still prints the trailing context, but nothing selected. if ($selected) { $File.AfterRemaining = 0; return } } $printLines = -not ($State.Quiet -or $State.Count -or $State.List -or $File.Binary) if ($selected) { $File.Selected++ $State.Matched = $true if ($State.Quiet) { $State.Finished = $true; return } if ($printLines) { $start = $File.LineNumber - $File.Before.Count if (($State.Before -gt 0 -or $State.After -gt 0) -and $State.PrintedGroups -and $start -gt $File.LastPrinted + 1) { Format-GnuGrepColor -Text '--' -Code '36' -Color $State.Color } while ($File.Before.Count -gt 0) { $previous = $File.Before.Dequeue() (Format-GnuGrepPrefix -State $State -File $File -LineNumber $previous.Number -Offset $previous.Offset -Separator '-') + $previous.Line } $prefix = Format-GnuGrepPrefix -State $State -File $File -LineNumber $File.LineNumber -Offset $offset -Separator ':' if ($State.OnlyMatching) { if (-not $State.Invert) { foreach ($match in $State.Regex.Matches($Line)) { if ($match.Length -eq 0) { continue } $matchPrefix = $prefix if ($State.ByteOffset) { $matchPrefix = Format-GnuGrepPrefix -State $State -File $File -LineNumber $File.LineNumber -Offset ($offset + $script:GnuUtf8.GetByteCount($Line.Substring(0, $match.Index))) -Separator ':' } $matchPrefix + (Format-GnuGrepColor -Text $match.Value -Code '01;31' -Color $State.Color) } } } else { $text = $Line if ($State.Color -and -not $State.Invert -and $null -ne $State.Regex) { $text = $State.Regex.Replace($Line, [System.Text.RegularExpressions.MatchEvaluator] { param($m) Format-GnuGrepColor -Text $m.Value -Code '01;31' -Color $true }) } $prefix + $text } $File.LastPrinted = $File.LineNumber $State.PrintedGroups = $true $File.AfterRemaining = $State.After } if ($State.MaxCount -ge 0 -and $File.Selected -ge $State.MaxCount) { $File.Stopped = $true } return } if (-not $printLines) { return } if ($File.AfterRemaining -gt 0) { $File.AfterRemaining-- (Format-GnuGrepPrefix -State $State -File $File -LineNumber $File.LineNumber -Offset $offset -Separator '-') + $Line $File.LastPrinted = $File.LineNumber return } if ($State.Before -gt 0) { $File.Before.Enqueue(@{ Line = $Line; Number = $File.LineNumber; Offset = $offset }) while ($File.Before.Count -gt $State.Before) { [void]$File.Before.Dequeue() } } } function script:Complete-GnuGrepFile { <# .SYNOPSIS Emits the per-file results of -c, -l, -L and binary files after all lines were processed. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$File) $terminator = "`n" if ($State.Null) { $terminator = [string][char]0 } $name = Format-GnuGrepColor -Text $File.Name -Code '35' -Color $State.Color if ($State.Quiet) { return } if ($State.List -eq 'with') { if ($File.Selected -gt 0) { if ($State.Null) { $name + $terminator } else { $name } } return } if ($State.List -eq 'without') { if ($File.Selected -eq 0) { if ($State.Null) { $name + $terminator } else { $name } } return } if ($State.Count) { $text = [string]$File.Selected if ($State.ShowNames) { $separator = ':' if ($State.Null) { $separator = [string][char]0 } $text = $name + $separator + $text } $text return } if ($File.Binary -and $File.Selected -gt 0) { "grep: $($File.Name): binary file matches" } } function script:Test-GnuGrepGlob { param([string]$Name, [AllowEmptyCollection()][object[]]$Patterns) foreach ($pattern in $Patterns) { if ((New-Object System.Management.Automation.WildcardPattern -ArgumentList ([string]$pattern)).IsMatch($Name)) { return $true } } $false } function script:Search-GnuGrepFile { <# .SYNOPSIS Searches one file (or stdin for '-') with the line engine. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Operand, [Parameter(Mandatory)][string]$Display) $context = $State.Context $file = New-GnuGrepFile -Name $Display if ($Operand -eq '-') { $file.Name = $State.Label $lines = @(Read-GnuStdinLine -Context $context) if ($State.NullData) { $lines = @(Split-GnuRecord -Text (@($lines) -join "`n") -Separator "`0") } } else { $path = Resolve-GnuPath -Path $Operand if ([System.IO.Directory]::Exists($path)) { if (-not $State.NoMessages) { Write-GnuError -Context $context -Message "$($Display): Is a directory" -ExitCode 2 } else { $context.ErrorCount++ } return } if (-not [System.IO.File]::Exists($path)) { if (-not $State.NoMessages) { Write-GnuError -Context $context -Message "$($Display): No such file or directory" -ExitCode 2 } else { $context.ErrorCount++ } return } try { $bytes = [System.IO.File]::ReadAllBytes($path) } catch { if (-not $State.NoMessages) { Write-GnuError -Context $context -Message "$($Display): $(Get-GnuErrorReason -Exception $_.Exception)" -ExitCode 2 } else { $context.ErrorCount++ } return } $isBinary = -not $State.NullData -and [Array]::IndexOf($bytes, [byte]0) -ge 0 if ($isBinary -and $State.Binary -eq 'without-match') { return } $file.Binary = $isBinary -and $State.Binary -eq 'binary' $reader = New-Object System.IO.StreamReader -ArgumentList (New-Object System.IO.MemoryStream -ArgumentList (, $bytes)), $script:GnuUtf8, $true $text = $reader.ReadToEnd() $reader.Dispose() if ($State.NullData) { $lines = @(Split-GnuRecord -Text $text -Separator "`0") } else { $lines = @(ConvertTo-GnuOutputLine -Text $text) } } $output = foreach ($line in $lines) { Step-GnuGrepLine -State $State -File $file -Line $line if ($State.Finished -or ($file.Stopped -and $file.AfterRemaining -le 0)) { break } } if ($State.NullData) { $output = @(foreach ($line in @($output)) { $line + [string][char]0 }) } $output Complete-GnuGrepFile -State $State -File $file } function script:Invoke-GnuGrepSearch { <# .SYNOPSIS Walks the operands (recursively with -r) and searches every file. #> param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][hashtable]$State) foreach ($operand in $Context.Operands) { if ($State.Finished) { return } $path = '' if ($operand -ne '-') { $path = Resolve-GnuPath -Path $operand } if ($State.Recursive -and $operand -ne '-' -and [System.IO.Directory]::Exists($path)) { $stack = New-Object 'System.Collections.Generic.Stack[object]' $stack.Push(@{ Path = $path; Display = $operand }) while ($stack.Count -gt 0 -and -not $State.Finished) { $directory = $stack.Pop() $entries = @([System.IO.Directory]::GetFileSystemEntries($directory.Path)) [System.Array]::Sort($entries, [System.StringComparer]::Ordinal) $subdirectories = New-Object 'System.Collections.Generic.List[object]' foreach ($entry in $entries) { $name = [System.IO.Path]::GetFileName($entry) $display = Join-GnuDisplayPath -Directory $directory.Display -Leaf $name if ([System.IO.Directory]::Exists($entry)) { if (Test-GnuGrepGlob -Name $name -Patterns $State.ExcludeDir) { continue } $subdirectories.Add(@{ Path = $entry; Display = $display }) continue } if ($State.Include.Count -gt 0 -and -not (Test-GnuGrepGlob -Name $name -Patterns $State.Include)) { continue } if (Test-GnuGrepGlob -Name $name -Patterns $State.Exclude) { continue } Search-GnuGrepFile -State $State -Operand $entry -Display $display if ($State.Finished) { return } } for ($i = $subdirectories.Count - 1; $i -ge 0; $i--) { $stack.Push($subdirectories[$i]) } } continue } if ($operand -ne '-' -and (Test-GnuGrepGlob -Name ([System.IO.Path]::GetFileName($operand)) -Patterns $State.Exclude)) { continue } Search-GnuGrepFile -State $State -Operand $operand -Display $operand } } |