Search.ps1

Set-StrictMode -Version Latest

if ((Get-Variable IsWindows -ErrorAction Ignore) -eq $null) { $IsWindows = $true }

function Find-Matches {
    param (
        [Parameter(Mandatory=$true)] [hashtable]$hash, 
        [string[]]$query
    )
    $hash = $hash.Clone()
    foreach ($key in ($hash.GetEnumerator() | %{$_.Name}))
    {
        if (-not (Test-FuzzyMatch $key $query))
        {
            $hash.Remove($key)
        }
    }

    if ($query -ne $null -and $query.Length -gt 0) {
        $lowerPrefix = $query[-1].ToLower()
        # we should prefer paths that start with the query over paths with bigger weight
        # that don't.
        # i.e. if we have
        # /foo = 1.0
        # /afoo = 2.0
        # and query is "fo", we should prefer /foo
        # Similarly, with the same query `fo`, the full match `/fo` should win over `/fo2`
        $res = $hash.GetEnumerator() | % {
            New-Object -TypeName PSCustomObject -Property @{
                Name=$_.Name
                Value=$_.Value
                Starts=[int](Start-WithPrefix -Path $_.Name -lowerPrefix $lowerPrefix)
                IsExactMatch=[int](IsExactMatch -Path $_.Name -lowerPrefix $lowerPrefix)
            }
        } | Sort-Object -Property IsExactMatch, Starts, Value -Descending
    } else {
        $res = $hash.GetEnumerator() | Sort-Object -Property Value -Descending
    }

    if ($res) {
        $res | %{$_.Name}
    }
}

function Start-WithPrefix {
    param (
        [Parameter(Mandatory=$true)] [string]$Path, 
        [Parameter(Mandatory=$true)] [string]$lowerPrefix
    )
    $lowerLeaf = (Split-Path -Leaf $Path).ToLower()
    return $lowerLeaf.StartsWith($lowerPrefix)
}

function IsExactMatch {
    param (
        [Parameter(Mandatory=$true)] [string]$Path, 
        [Parameter(Mandatory=$true)] [string]$lowerPrefix
    )
    $lowerLeaf = (Split-Path -Leaf $Path).ToLower()
    return $lowerLeaf -eq $lowerPrefix
}

function Test-FuzzyMatch {
    param (
        [Parameter(Mandatory=$true)] [string]$path,
        [string[]]$query
    )
    function contains([string]$left, [string]$right) {
        return [bool]($left.IndexOf($right, [System.StringComparison]::OrdinalIgnoreCase) -ge 0)
    }

    if ($query -eq $null) {
        return $true
    }
    $n = $query.Length
    if ($n -eq 0)
    {
        # empty query match to everything
        return $true
    }

    for ($i=0; $i -lt $n-1; $i++)
    {
        if (-not (contains -left $path -right $query[$i]))
        {
            return $false
        }
    }

    # after tab expansion, we get desired full path as a last query element.
    # tab expansion can come from our code, then it will represent the full path.
    # It also can come from the standard tab expansion (when our doesn't return anything), which is file system based.
    # It can produce relative paths.

    $rootQuery = $query[$n-1]

    if (-not [System.IO.Path]::IsPathRooted($rootQuery)) {
        # handle '..\foo' case
        $rootQueryCandidate = Join-Path $pwd $query[$n-1]
        if (Test-Path $rootQueryCandidate) {
            $rootQuery = (Resolve-Path $rootQueryCandidate).Path
        }
    }

    if ([System.IO.Path]::IsPathRooted($rootQuery))
    {
        # doing a tweak to handle 'C:' and 'C:\' cases correctly.
        if ($IsWindows -and ($rootQuery.Length) -eq 2 -and ($rootQuery[-1] -eq ':'))
        {
            $rootQuery = $rootQuery + "\"
        }
        # doing a tweaks to handle 'C:\foo' and 'C:\foo\' cases correctly.
        elseif ($rootQuery -ne '/' -and $rootQuery[-1] -eq [IO.Path]::DirectorySeparatorChar)
        {
            $rootQuery = $rootQuery.Substring(0, $rootQuery.Length-1)
        }
        return $path -eq $rootQuery
    }

    $leaf = Split-Path -Leaf $path
    return (contains -left $leaf -right $query[$n-1])
}