Find-String.psm1

if (Get-Module -Name Find-String) { return }

<#
    Find-String is a PowerShell script whose purpose is to emulate grep and/or ack.
    PowerShell already has the built-in Select-String cmdlet, but this script wraps
    provides match highlighting on top of the searching capabilities.
 
    It currently highlights matches in a similar style to ack.
#>


#requires -version 2

function Find-String {
    [CmdletBinding(DefaultParameterSetName = "Filter")]
    param (
        [Parameter(Position = 0, Mandatory = $true)]
        [regex] $pattern,

        [Parameter(Position = 1, ParameterSetName = "Filter")]
        [string] $filter = "*.*",

        [Parameter(Position = 1, ParameterSetName = "Include")]
        [string[]] $include = @(),

        [string[]] $excludeFiles = @(),
        [string[]] $excludeDirectories = @(),
        [string[]] $path = @(),
        [switch] $recurse = $true,
        [switch] $caseSensitive = $false,
        [int[]] $context = 0,
        [switch] $passThru = $false,
        [switch] $pipeOutput = $false,
        [switch] $listMatchesOnly = $false
    )

    if ((-not $caseSensitive) -and (-not $pattern.Options -match "IgnoreCase")) {
        $pattern = New-Object -TypeName regex -Property $pattern.ToString(), @($pattern.Options, "IgnoreCase")
    }

    function directoriesToExclude {
        if ($excludeDirectories.Length -gt 0) {
            return $excludeDirectories
        }
        else {
            'bin', 'obj', '.git', '.hg', '.svn', '_ReSharper\.'

            if ($global:FindStringDirectoriesToExclude -ne $null) {
                $global:FindStringDirectoriesToExclude
            }
        }
    }

    function filesToExclude {
        if ($excludeFiles.Length -gt 0) {
            return $excludeFiles
        }
        else {
            '*exe', '*pdb', '*dll', '*.gif', '*.jpg', '*.doc', '*.pdf'

            if ($global:FindStringFileTypesToExclude -ne $null) {
                $global:FindStringFileTypesToExclude
            }
        }
    }

    function shouldFilterDirectory {
        param (
            [Parameter(Mandatory = $true)]
            $item
        )

        $directoriesToExclude = directoriesToExclude | ForEach-Object { "\\$_" }

        if ((Select-String -Pattern $directoriesToExclude -input $item.DirectoryName) -ne $null) {
            Write-Debug -Message "Excluding results from $item"
            return $true
        }
        else {
            return $false
        }
    }

    function filterExcludes {
        param (
            [Parameter(Mandatory = $true)]
            $item
        )

        if (-not ($item -is [System.IO.FileInfo])) { return $false }
        if (shouldFilterDirectory $item) { return $false }

        return $true
    }

    switch ($PsCmdlet.ParameterSetName) {
        'Filter' {
            if ($passThru) {
                Get-ChildItem -recurse:$recurse -filter:$filter -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -AllMatches -context $context
            }
            elseif ($listMatchesOnly) {
                Get-ChildItem -recurse:$recurse -filter:$filter -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -List |
                    Select-Object -ExpandProperty Path
            }
            else {
                Get-ChildItem -recurse:$recurse -filter:$filter -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -AllMatches -context $context |
                    Out-ColorMatchInfo -pipeOutput:$pipeOutput
            }
        }
        'Include' {
            if ($passThru) {
                Get-ChildItem -recurse:$recurse -include:$include -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -AllMatches -context $context
            }
            elseif ($listMatchesOnly) {
                Get-ChildItem -recurse:$recurse -include:$include -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -AllMatches -context $context |
                    Select-Object -ExpandProperty Path
            }
            else {
                Get-ChildItem -recurse:$recurse -include:$include -path $path -exclude (& filesToExclude) |
                    Where-Object { filterExcludes $_ } |
                    Select-String -caseSensitive:$caseSensitive -pattern:$pattern -AllMatches -context $context |
                    Out-ColorMatchInfo -pipeOutput:$pipeOutput
            }
        }
    }

    <#
.Synopsis
    Searches text files by pattern and displays the results.
.Description
    Searches text files by pattern and displays the results.
.Parameter Pattern
    Specifies the text to find. Type a string or regular expression.
.Parameter Filter
    Specifies the file types to search in. The default is all file types (*.*).
.Parameter Include
    Specifies the file types to search in. This allows you to search across
    multiple file types (i.e. *.ps1,*.psm1).
.Parameter ExcludeFiles
    Specifies the file types to exclude from searches. If set, this overrides
    any global defaults or configuration.
.Parameter ExcludeDirectories
    Specifies the directories to exclude from searches. It really only makes
    sense for recursive searches. If set, this overrides any global defaults
    or configuration.
.Parameter Path
    Specifies the path to the files to be searched. Wildcards are permitted.
    The default location is the local directory.
.Parameter Recurse
    Gets the items in the specified path and in all child directies. This is
    the default.
.Parameter CaseSensitive
    Makes matches case-sensitive. By default, matches are not case-sensitive.
.Parameter Context
    Captures the specified number of lines before and after the line with the
    match. This allows you to view the match in context.
.Parameter PassThru
    Passes the literal MatchInfo object representing the found match to the
    pipeline. By default, this cmdlet does not send anything through the
    object pipeline.
.Parameter PipeOutput
    Sends all output along the object pipeline. By default, this command uses
    color to help with readability; however, this prevents the output from being
    piped to another command. If you wish to pipe the output of this command to
    something else, be sure to use this parameter.
.Parameter ListMatchesOnly
    Returns all files that have matches existing in them, but doesn't display
    any of the matches themselves.
#>

}

function Out-ColorMatchInfo {
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [Microsoft.PowerShell.Commands.MatchInfo]
        $match,

        [switch]
        $onlyShowMatches = $false,

        [switch]
        $pipeOutput = $false
    )

    begin {
        $script:priorPath = ''
        $script:hasContext = $false

        $script:buffer = New-Object -TypeName System.Text.StringBuilder
    }
    process {
        function output {
            param (
                [string]
                $str = '',

                $foregroundColor = $host.ui.RawUI.ForegroundColor,
                $backgroundColor = $host.ui.RawUI.BackgroundColor,

                [switch]
                $noNewLine = $false
            )

            if ($pipeOutput) {
                if ($noNewLine) {
                    $script:buffer.Append($str) | out-null
                }
                else {
                    $script:buffer.AppendLine($str) | out-null
                }
            }
            else {
                $args = @{ Object = $str; NoNewLine = $NoNewLine }
                if (-not($foregroundColor -lt 0)) {
                    $args.Add('ForegroundColor', $foregroundColor)
                }
                if (-not($backgroundColor -lt 0)) {
                    $args.Add('BackgroundColor', $backgroundColor)
                }
                Write-Host @args
            }
        }

        function Get-RelativePath([string] $path = '') {
            $path = $path.Replace($pwd.Path, '')
            if ($path.StartsWith('\') -and (-not $path.StartsWith('\\'))) {
                $path = $path.Substring(1)
            }
            $path
        }

        function Write-PathOrSeparator($match) {
            if ($script:priorPath -ne $match.Path) {
                output ''
                output (Get-RelativePath $match.Path) -foregroundColor Green
                $script:priorPath = $match.Path
            }
            else {
                if ($script:hasContext) {
                    output '--'
                }
            }
        }

        function Write-HighlightedMatch($match) {
            if (-not $onlyShowMatches) {
                output "$($match.LineNumber):" -nonewline
            }
            $index = 0
            foreach ($m in $match.Matches) {
                output $match.Line.SubString($index, $m.Index - $index) -nonewline
                output $m.Value -ForegroundColor Black -BackgroundColor Yellow -nonewline
                $index = $m.Index + $m.Length
            }
            if ($index -lt $match.Line.Length) {
                output $match.Line.SubString($index) -nonewline
            }
            output ''
        }

        function Write-Context {
            param (
                $context = '',
                $contextLines = ''
            )

            if ($context.length -eq $null) {return}

            $script:hasContext = $true
            for ($i = 0; $i -lt $context.length; $i++) {
                "$($contextLines[$i])- $($context[$i])"
            }
        }

        if (-not $onlyShowMatches) {
            Write-PathOrSeparator $match
        }

        $lines = ($match.LineNumber - $match.Context.DisplayPreContext.Length)..($match.LineNumber - 1)
        Write-Context $match.Context.DisplayPreContext $lines

        Write-HighlightedMatch $match

        $lines = ($match.LineNumber + 1)..($match.LineNumber + $match.Context.DisplayPostContext.Length)
        Write-Context $match.Context.DisplayPostContext $lines

        if ($script:buffer.Length -gt 0) {
            $script:buffer.ToString()
        }
    }
    end {}

    <#
.Synopsis
    Highlights MatchInfo objects similar to the output from ack.
.Description
    Highlights MatchInfo objects similar to the output from ack.
#>

}

Export-ModuleMember -Function Find-String
Export-ModuleMember -Function Out-ColorMatchInfo