PowerShell で grep もどき（パイプ入力版）

# 目的
PowerShellのコンソールで使う grep のようなテキスト検索用のツール（関数）を作りました。マッチした部分をハイライト表示できるのが特長です。以前に[投稿](https://qiita.com/earthdiver1/items/5eecd7016bb2ee24c507)したものは複数のファイルを検索対象としていましたが、こちらは主にパイプ経由で入力されたテキストを検索対象としています。

# 使用方法
① `PS> コマンド | hgrep 検索文字列`
あるいは
② `PS> hgrep 検索文字列 -io (gc ファイルパス)`

② の代わりに ① でコマンドを Get-Content にすることでもファイルの中身の検索ができます。
例) 二重引用符で囲まれた文字列を検索
　行単位検索： `PS> gc ファイルパス　　　 | hgrep '".*?"'`
　複数行検索： `PS> gc ファイルパス -raw | hgrep '"[\S\s]*?"'` 又は
　　　　　　　 `PS> gc ファイルパス -raw | hgrep '(?s)".*?"'`

下記のコマンドライン引数が利用可能です。
<dl>
  <dt>[-Pattern]</dt><dd>検索文字列（正規表現）を指定します（必須）。</dd>
  <dt>-IgnoreCase, -i</dt><dd>大文字・小文字を区別しません。</dd>
  <dt>-InputObject</dt><dd>入力オブジェクトを指定します。既定値は Null（パイプ入力）。</dd>
  <dt>-Group, -g</dt><dd>キャプチャグループの番号または名前を指定します。既定値は "0"。</dd>
  <dt>-Narrow, -w</dt><dd>検索前に全角数字、全角カナ等を半角に変換します。</dd>
  <dt>-Number, -n</dt><dd>行番号を表示します。</dd>
  <dt>-Passthru, -p</dt><dd>マッチしない行も含めて全ての行を出力します。</dd>
  <dt>-SimpleMatch, -s</dt><dd>正規表現を使わず単純文字列検索を行います。</dd>
  <dt>-BackgroundColor, -bc</dt><dd>マッチ箇所の背景色を指定します。既定値は "White"。</dd>
  <dt>-CapturegroupColor, -cc</dt><dd>マッチ箇所（キャプチャグループ）の文字色を指定します。既定値は "Red"。</dd>
  <dt>-ForegroundColor, -fc</dt><dd>マッチ箇所の背景色を指定します。既定値は "Blue"。</dd>
</dl>

# 実行例
i で始まるエリアスの一覧を表示する例
![ScreenShot.png](https://qiita-image-store.s3.amazonaws.com/0/189363/1ecc7e3c-bc4e-c406-2140-d0c41c013a50.png)


# コード
プロファイル設定（\\$profile のパスが示すファイル）に下記の関数定義を加えるか、拡張子 .psm1 のファイルに保存して \\$PSModulePath に含まれるいずれかのパスのいずれかの下に

```
hGrep
 └hGrep.psm1
```

のフォルダ構造で保存した後、PowerShellコンソールを起動して hgrep とタイプして呼び出すことができます[^1]（あるいはもっと簡単に `Install-Module hGrep` を実行することでもインストール可能）。

[^1]: PowerShell 7系ではエイリアスに対する暗黙的なモジュールのインポートを有効にするために別途モジュールマニフェストが必要です。`@{ RootModule = 'hGrep.psm1'; ModuleVersion = '1.03'; AliasesToExport = 'hGrep' }` の内容のテキストファイルを hGrep.psd1 のファイル名で同じフォルダ内に格納してください。「実行ポリシー」の変更を求められる場合があります。実行ポリシーについての説明はこちらの[記事](https://qiita.com/earthdiver1/items/b3f529c81f5892972a72)をご参照ください。


``` powershell: hGrep.psm1
<#
    .SYNOPSIS
        A grep-like tool with a color highlighting feature.

    .DESCRIPTION
        This function searches for text patterns in input strings.
        If input is an object, it is converted to a string prior to processing.
        Matched characters are highlighted.
        
        '(some command) | Select-MatchedString -Pattern <regex>'
        is similar to
        '(some command) | Output-String -Stream | Select-String -Pattern <regex> -AllMatch -CaseSensitive'
        with PowerShell 7.X, in which Select-String cmdlet has a highlighting feature.

    .PARAMETER Pattern
        Specifies the text patterns to find. Type a string or regular expression. 
        If you type a string, use the SimpleMatch parameter.

    .PARAMETER BackgroundColor
        Specifies the background color for matches. (Alias: -bc)
        The default value is "Blue".

    .PARAMETER CapturegroupColor
        Specifies the foreground color for capture-group matches. (Alias: -cc)
        The default value is "Red".

    .PARAMETER ForegroundColor
        Specifies the foreground color for matches. (Alias: -fc)
        The default value is "White".

    .PARAMETER Group
        Specifies the name or number of capture group. (Alias: -g)
        The default value is "0".

    .PARAMETER ECMAScript
        Enables ECMAScript-compliant behavior. (Alias: -e)

    .PARAMETER IgnoreCase
        Makes matches case-insensitive. By default, matches are case-sensitive. (Alias: -i)

    .PARAMETER InputObject
        Specifies the text to be searched. (Alias: -io)

    .PARAMETER PassThru
        Outputs all lines, including ones that do not match. (Alias: -p)

    .PARAMETER Narrow
        Converts wide characters into narrow ones internally. (Alias: -n)
        Useful when you don't want to distinguish between narrow and wide characters.

    .PARAMETER SimpleMatch
        Uses a simple match rather than a regular expression match. (Alias: -s)

    .NOTES
        Author:   earthdiver1
        Version:  V1.03
        Licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

#>
Function Select-MatchedString {
    [Alias('hGrep')]
    Param(
        [Parameter(Mandatory=$True)][String]$Pattern,
        [Alias("g")][String]$Group                    = "0",
        [Alias("e")][Switch]$ECMAScript,
        [Alias("i")][Switch]$IgnoreCase,
        [Alias("w")][Switch]$Narrow,
        [Alias("n")][Switch]$Number,
        [Alias("p")][Switch]$PassThru,
        [Alias("s")][Switch]$SimpleMatch,
        [Alias("bc")][ConsoleColor]$BackgroundColor   = "Blue",
        [Alias("cc")][ConsoleColor]$CapturegroupColor = "Red",
        [Alias("fc")][ConsoleColor]$ForegroundColor   = "White",

        [Parameter(ValueFromPipeline=$True)][Alias("io")][PSObject]$InputObject
    )
    Begin {
        try {
            if ( -not $Pattern ) { break }
            if ( $Narrow ) {
                Add-Type -AssemblyName "Microsoft.VisualBasic"
                $Pattern = [Microsoft.VisualBasic.Strings]::StrConv($Pattern,[Microsoft.VisualBasic.VbStrConv]::Narrow)
            }
            if ( $SimpleMatch ) { $Pattern = [regex]::Escape( $Pattern ) }
            if ( $Number ) {
                $line = 0
                $width  = $host.UI.RawUI.BufferSize.Width - 7
            } else {
                $width  = $host.UI.RawUI.BufferSize.Width - 1
            }
            $regexOptions = "Compiled"
            if ( $ECMAScript ) { $regexOptions += ", ECMAScript" }
            if ( $IgnoreCase ) { $regexOptions += ", IgnoreCase" }
            $Regex = New-Object Text.RegularExpressions.Regex $Pattern, $regexOptions
            $process_block = {
                Process {
                    $line++
                    $i = 0
                    if ( $Narrow ) { $_ = [Microsoft.VisualBasic.Strings]::StrConv($_,[Microsoft.VisualBasic.VbStrConv]::Narrow) }
                    $match = $Regex.Match($_,$i)
                    $m = $match.Groups[$Group]
                    if (-not $Passhru -and -not $m.Success) { return }
                    if ( $Number ) { Write-Host $("{0,5}:" -F $line) -NoNewline }
                    if ( $Group -eq "0" ) {
                        while ($m.Success) {
                            if ( $m.Index -ge $_.Length ) { break }
                            if ( $m.Length -gt 0 ) {
                                Write-Host $_.SubString($i, $m.Index - $i) -NoNewline
                                Write-Host $m.Value -BackgroundColor $BackgroundColor -ForegroundColor $ForegroundColor -NoNewline 
                                $i = $m.Index + $m.Length
                            } else {
                                Write-Host $_.SubString($i, $m.Index - $i + 1) -NoNewline
                                $i = $m.Index + 1
                            }
                            $m = $Regex.Match($_,$i).Groups[0]
                        }
                    } else {
                        while ( $m.Success ) {
                            if ( $m.Index -ge $_.Length ) { break }
                            $m0 = $match.Groups[0]
                            if ( $m0.Length -gt 0 ) {
                                Write-Host $_.SubString($i, $m0.Index - $i) -NoNewline
                                Write-Host $_.SubString($m0.Index, $m.Index - $m0.Index) `
                                           -BackgroundColor $BackgroundColor -ForegroundColor $ForegroundColor -NoNewline
                                Write-Host $m.Value -BackgroundColor $BackgroundColor -ForegroundColor $CapturegroupColor -NoNewline 
                                $i  = $m0.Index + $m0.Length
                                $ii = $m.Index  + $m.Length
                                Write-Host $_.SubString($ii, $i - $ii) `
                                           -BackgroundColor $BackgroundColor -ForegroundColor $ForegroundColor -NoNewline
                            } else {
                                Write-Host $_.SubString($i, $m0.Index - $i + 1) -NoNewline
                                $i = $m0.Index + 1
                            }
                            $match = $Regex.Match($_,$i)
                            $m  = $match.Groups[$Group]
                        }
                    }
                    Write-Host $_.SubString($i)
                }
            }
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Out-String',[System.Management.Automation.CommandTypes]::Cmdlet)
            $wrappedCmdParameters = @{}
            if ( $PSBoundParameters.ContainsKey("InputObject") ) { $wrappedCmdParameters.Add("InputObject",$InputObject) }
            $wrappedCmdParameters.Add("Stream", $True)
            $wrappedCmdParameters.Add("Width", $width)
            $scriptCmd = {& $wrappedCmd @wrappedCmdParameters | & $process_block }
            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        } catch {
            throw
        }
    }
    Process {
        try {
            $steppablePipeline.Process($_)
        } catch {
            throw
        }
    }
    End {
        try {
            $steppablePipeline.End()
            Remove-Variable Regex
        } catch {
            throw
        }
    }
}
Export-ModuleMember -Function Select-MatchedString -Alias hGrep
```

# 備考
- オブジェクト型の入力データは検索前に文字列型に変換されます。
- マッチ箇所の背景色は、PowerShellのコンソールの背景色と同じ DarkMagenta を指定（`-bc DarkMagenta`）すると目立たなくなります。キャプチャグループを指定する場合に、マッチした箇所がより確認しやすくなります。例）`PS> gc ファイルパス | grep '"(.*?)"' -g 1 -bc DarkMagenta`
- 6番目のストリームをリダイレクトして grep 関数の出力をファイルに保存した場合、マッチ箇所のハイライト処理の影響で体裁が崩れます。ファイルに出力したい場合は、標準のコマンドレットを利用して以下のようにしてください。（Select-String コマンドレットは既定では大文字・小文字の区別をしません。区別したい場合は、sls に `-CaseSensitive` を指定します。）

　`PS> コマンド | oss | sls 検索文字列 > 出力ファイル名`
 
- あるいは最初から grep にハイライトなんて要らないという方は、以下の関数を定義するとよいかも知れません。

　`Function grep{ $Input | oss | sls $args }`