Private/Paths.ps1

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Path handling, mirroring zoxide 0.10.0 src/util.rs (current_dir, resolve_path, canonicalize) and
# the glob 0.3.3 crate as zoxide uses it: Pattern::new, Pattern::escape and Pattern::matches.
#
# Resolve-ZoPath and the glob functions take -Windows (default: the host platform) so that both
# platform rule sets are pure string functions that can be tested on any OS.

function Get-ZoCurrentDirectory {
    <#
    .SYNOPSIS
        The current FileSystem provider location as a native path.
    .DESCRIPTION
        Mirrors util::current_dir. PowerShell does not keep the process working directory in sync
        with Set-Location, so the FileSystem provider location is the directory the user sees.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    (Get-Location -PSProvider FileSystem).ProviderPath
}

function Split-ZoWindowsPath {
    <#
    .SYNOPSIS
        Splits a Windows path into its prefix, root flag and normal components.
    .DESCRIPTION
        A small model of std::path::Path::components on Windows, enough for resolve_path. Kind is
        Disk (C:), VerbatimDisk (\\?\C:), Other (UNC, device or other verbatim prefixes) or None.
        Letter is the upper-cased drive letter, as Rust's Prefix::Disk reports it. Prefix is the
        prefix text as written. Parts excludes empty and '.' components; '..' is kept.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path
    )
    $result = @{ Kind = 'None'; Prefix = ''; Letter = ''; Rooted = $false; Rest = $Path; Verbatim = $false }
    if ($Path.StartsWith('\\?\')) {
        $result.Verbatim = $true
        $after = $Path.Substring(4)
        if ($after -match '^[A-Za-z]:') {
            $result.Kind = 'VerbatimDisk'
            $result.Letter = $after.Substring(0, 1).ToUpperInvariant()
            $result.Prefix = $Path.Substring(0, 6)
            $result.Rest = $after.Substring(2)
        }
        else {
            $result.Kind = 'Other'
        }
    }
    elseif ($Path -match '^[\\/][\\/]') {
        # UNC (\\server\share) and device (\\.\name) prefixes. The prefix runs through the share.
        $result.Kind = 'Other'
        $m = [regex]::Match($Path, '^[\\/][\\/][^\\/]*(?:[\\/][^\\/]*)?')
        $result.Prefix = $m.Value
        $result.Rest = $Path.Substring($m.Length)
        $result.Rooted = $true
    }
    elseif ($Path -match '^[A-Za-z]:') {
        $result.Kind = 'Disk'
        $result.Letter = $Path.Substring(0, 1).ToUpperInvariant()
        $result.Prefix = $Path.Substring(0, 2)
        $result.Rest = $Path.Substring(2)
    }

    # Verbatim paths only treat the backslash as a separator. The typed variable stops the if
    # expression from unrolling the array.
    [char[]]$separators = if ($result.Verbatim) { '\' } else { '\/' }
    if ($result.Kind -ne 'Other' -and $result.Rest.Length -gt 0 -and $separators -contains $result.Rest[0]) {
        $result.Rooted = $true
    }
    $result.Parts = @($result.Rest.Split($separators) | Where-Object { $_ -ne '' -and $_ -ne '.' })
    $result
}

function Resolve-ZoPath {
    <#
    .SYNOPSIS
        Returns the absolute, lexically normalised form of a path without touching the filesystem.
    .DESCRIPTION
        Mirrors util::resolve_path. The result is absolute against -CurrentDirectory, '.' and '..'
        are collapsed ('..' never climbs above the root), separators become '\' on Windows, a
        trailing separator is dropped, the drive letter keeps the case it was written in, and
        symlinks are not resolved. On Windows, a drive-relative path such as 'D:x' uses the current
        directory when it is on that drive, else the '=D:' environment variable, else 'D:\'; UNC and
        other non-disk prefixes are rejected with 'invalid path: <path>' like the binary.
    .PARAMETER Path
        The path as typed.
    .PARAMETER CurrentDirectory
        The base for relative paths. Defaults to the FileSystem provider location.
    .PARAMETER Windows
        Use the Windows rules. Defaults to the host platform.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [string]$CurrentDirectory = (Get-ZoCurrentDirectory),

        [bool]$Windows = $IsWindows
    )

    $stack = [Collections.Generic.List[string]]::new()
    if (-not $Windows) {
        $parts = @($Path.Split('/') | Where-Object { $_ -ne '' -and $_ -ne '.' })
        if (-not $Path.StartsWith('/')) {
            foreach ($part in $CurrentDirectory.Split('/')) {
                if ($part -ne '') { $stack.Add($part) }
            }
        }
        $prefix = ''
        $separator = '/'
    }
    else {
        $parsed = Split-ZoWindowsPath -Path $Path
        $parts = $parsed.Parts
        $separator = '\'
        switch ($parsed.Kind) {
            'VerbatimDisk' {
                $prefix = $parsed.Letter + ':'
            }
            'Disk' {
                if ($parsed.Rooted) {
                    $prefix = $parsed.Prefix
                }
                else {
                    $base = Split-ZoWindowsPath -Path (Get-ZoDriveDirectory -Letter $parsed.Letter -CurrentDirectory $CurrentDirectory)
                    $prefix = $base.Prefix
                    foreach ($part in $base.Parts) { $stack.Add($part) }
                }
            }
            'Other' {
                throw "invalid path: $Path"
            }
            default {
                $base = Split-ZoWindowsPath -Path $CurrentDirectory
                if ($parsed.Rooted) {
                    if ($base.Kind -notin 'Disk', 'VerbatimDisk') {
                        throw "could not get drive letter: $CurrentDirectory"
                    }
                    $prefix = $base.Letter + ':'
                }
                else {
                    $prefix = $base.Prefix
                    foreach ($part in $base.Parts) { $stack.Add($part) }
                }
            }
        }
    }

    foreach ($part in $parts) {
        if ($part -eq '..') {
            if ($stack.Count -gt 0) { $stack.RemoveAt($stack.Count - 1) }
        }
        else {
            $stack.Add($part)
        }
    }
    $prefix + $separator + ($stack -join $separator)
}

function Get-ZoDriveDirectory {
    <#
    .SYNOPSIS
        The base directory for a drive-relative Windows path such as 'D:x'.
    .DESCRIPTION
        Mirrors get_drive_relative inside util::resolve_path: the current directory when it is on
        that drive, else the '=D:' environment variable that cmd.exe maintains, else 'D:\'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Letter,

        [Parameter(Mandatory)]
        [string]$CurrentDirectory
    )
    $current = Split-ZoWindowsPath -Path $CurrentDirectory
    if ($current.Kind -in 'Disk', 'VerbatimDisk' -and $current.Letter -ceq $Letter) {
        return $CurrentDirectory
    }
    $saved = [Environment]::GetEnvironmentVariable("=${Letter}:")
    if ($saved) { return $saved }
    "${Letter}:\"
}

function Resolve-ZoCanonicalPath {
    <#
    .SYNOPSIS
        Returns the canonical path of an existing file or directory, with symlinks resolved.
    .DESCRIPTION
        Mirrors util::canonicalize (dunce::canonicalize, which is std::fs::canonicalize without the
        \\?\ prefix). Used instead of Resolve-ZoPath when _ZO_RESOLVE_SYMLINKS=1. On Windows '..' is
        collapsed before links are followed (Win32 normalisation) and every component takes its
        on-disk letter case, as GetFinalPathNameByHandle reports it; on Unix '..' is applied after
        links are followed, like realpath. 8.3 short names are not expanded to long names (.NET
        cannot match them by name). Errors read 'could not resolve path: <path>' with the OS error
        as the cause, in the binary's wording. On Windows a component holding a character that no
        file name may contain (the wildcards * ? < > " among them) fails with os error 123, 'The
        filename, directory name, or volume label syntax is incorrect.', as the binary does; it is
        never handed to the directory enumeration as a search pattern.
    .PARAMETER Path
        The path as typed.
    .PARAMETER CurrentDirectory
        The base for relative paths. Defaults to the FileSystem provider location.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [string]$CurrentDirectory = (Get-ZoCurrentDirectory)
    )

    if ($IsWindows) {
        $absolute = if ([IO.Path]::IsPathFullyQualified($Path)) { [IO.Path]::GetFullPath($Path) }
        else { [IO.Path]::GetFullPath((Resolve-ZoPath -Path $Path -CurrentDirectory $CurrentDirectory -Windows $true)) }
        $missing = 'The system cannot find the file specified. (os error 2)'
        $missingParent = 'The system cannot find the path specified. (os error 3)'
        $notDirectory = $missingParent
    }
    else {
        $absolute = if ($Path.StartsWith('/')) { $Path } else { $CurrentDirectory.TrimEnd('/') + '/' + $Path }
        $missing = 'No such file or directory (os error 2)'
        $missingParent = $missing
        $notDirectory = 'Not a directory (os error 20)'
    }

    $root = [IO.Path]::GetPathRoot($absolute)
    if ($IsWindows -and $root -match '^[a-z]:') { $root = $root.Substring(0, 1).ToUpperInvariant() + $root.Substring(1) }
    $queue = [Collections.Generic.List[string]]::new()
    $queue.AddRange([string[]]$absolute.Substring([IO.Path]::GetPathRoot($absolute).Length).Split([IO.Path]::DirectorySeparatorChar))
    $current = $root
    $currentIsFile = $false
    $hops = 0

    while ($queue.Count -gt 0) {
        $part = $queue[0]
        $queue.RemoveAt(0)
        if ($part -eq '' -or $part -eq '.') { continue }
        if ($currentIsFile) {
            throw (New-ZoError -Chain "could not resolve path: $Path", $notDirectory)
        }
        if ($part -eq '..') {
            $parent = [IO.Path]::GetDirectoryName($current)
            if ($parent) { $current = $parent }
            continue
        }

        if ($IsWindows -and $part.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
            throw (New-ZoError -Chain "could not resolve path: $Path", 'The filename, directory name, or volume label syntax is incorrect. (os error 123)')
        }
        $candidate = [IO.Path]::Join($current, $part)
        $entry = $null
        if ($IsWindows -and [IO.Directory]::Exists($current)) {
            $entry = [IO.DirectoryInfo]::new($current).EnumerateFileSystemInfos($part) | Select-Object -First 1
        }
        if (-not $entry) {
            $info = [IO.FileInfo]::new($candidate)
            $entry = if ([IO.Directory]::Exists($candidate)) { [IO.DirectoryInfo]::new($candidate) }
            elseif ($info.Exists -or $info.LinkTarget) { $info }
        }
        if (-not $entry) {
            $remaining = @($queue | Where-Object { $_ -ne '' -and $_ -ne '.' }).Count
            $cause = if ($remaining -gt 0) { $missingParent } else { $missing }
            throw (New-ZoError -Chain "could not resolve path: $Path", $cause)
        }

        if ($entry.LinkTarget) {
            $hops++
            $target = $entry.ResolveLinkTarget($true)
            if ($hops -gt 40 -or -not $target -or -not $target.Exists) {
                throw (New-ZoError -Chain "could not resolve path: $Path", $missing)
            }
            # Restart the walk from the target so its own components are canonicalised too.
            $targetPath = $target.FullName
            $queue.InsertRange(0, [string[]]$targetPath.Substring([IO.Path]::GetPathRoot($targetPath).Length).Split([IO.Path]::DirectorySeparatorChar))
            $current = [IO.Path]::GetPathRoot($targetPath)
            if ($IsWindows -and $current -match '^[a-z]:') { $current = $current.Substring(0, 1).ToUpperInvariant() + $current.Substring(1) }
            $currentIsFile = $false
            continue
        }

        $current = [IO.Path]::Join($current, $entry.Name)
        $currentIsFile = $entry -is [IO.FileInfo]
    }
    $current
}

function ConvertTo-ZoEscapedGlob {
    <#
    .SYNOPSIS
        Escapes glob metacharacters so the pattern matches the literal string only.
    .DESCRIPTION
        Mirrors glob::Pattern::escape: each of ? * [ ] is wrapped in brackets; nothing else changes.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value
    )
    [regex]::Replace($Value, '[?*\[\]]', '[$0]')
}

function ConvertTo-ZoGlobRegex {
    <#
    .SYNOPSIS
        Compiles a glob into an anchored .NET regex with the glob crate's matching semantics.
    .DESCRIPTION
        Mirrors glob::Pattern::new plus Pattern::matches as zoxide calls it (stream.rs line 64,
        `pattern.matches(path)`, and cmd/add.rs `glob.matches(path)`), which use MatchOptions::new():
        case_sensitive = true, require_literal_separator = false, require_literal_leading_dot =
        false (glob 0.3.3 src/lib.rs, `impl MatchOptions { pub fn new() }`). So '*' and '?' match
        separators, matching is case-sensitive, and a leading dot needs no literal match.
        '**' must form a whole path component; it matches nothing, or anything ending in a
        separator, or the rest of the string. '[...]' and '[!...]' take single characters and
        a-z ranges; a reversed range matches nothing. On Windows the backslash is also a separator,
        and any two separators compare equal (glob's chars_eq). Invalid patterns throw
        'Pattern syntax error near position N: <reason>' like glob's PatternError. The regex has a
        2-second match timeout: the glob crate short-circuits failing '*' loops and has no
        catastrophic case, while a backtracking regex does, so a pathological glob times out here.
    .PARAMETER Pattern
        The glob.
    .PARAMETER Windows
        Use the Windows separator rules. Defaults to the host platform.
    #>

    [CmdletBinding()]
    [OutputType([regex])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Pattern,

        [bool]$Windows = $IsWindows
    )

    $separators = if ($Windows) { '\/' } else { '/' }
    $sepRegex = if ($Windows) { '[\\/]' } else { '/' }
    # One Rust char: a surrogate pair or any single UTF-16 unit (Singleline makes '.' match \n).
    # Atomic, so backtracking cannot split a pair into two single-unit matches.
    $anyChar = '(?>[\uD800-\uDBFF][\uDC00-\uDFFF]|.)'
    $chars = $Pattern.ToCharArray()
    $n = $chars.Length
    $out = [Text.StringBuilder]::new('\A')
    $tokenCount = 0
    $lastRecursive = $false
    $i = 0

    while ($i -lt $n) {
        $c = $chars[$i]
        if ($c -eq [char]'?') {
            [void]$out.Append($anyChar)
            $i++
            $tokenCount++
            $lastRecursive = $false
        }
        elseif ($c -eq [char]'*') {
            $old = $i
            while ($i -lt $n -and $chars[$i] -eq [char]'*') { $i++ }
            $count = $i - $old
            if ($count -gt 2) {
                throw ('Pattern syntax error near position {0}: wildcards are either regular `*` or recursive `**`' -f ($old + 2))
            }
            if ($count -eq 1) {
                [void]$out.Append('.*')
                $tokenCount++
                $lastRecursive = $false
                continue
            }
            # '**' must be a whole path component: at the start or after a separator, and at the
            # end or before a separator (which it then consumes).
            if (-not ($i -eq 2 -or $separators.IndexOf($chars[$i - $count - 1]) -ge 0)) {
                throw ('Pattern syntax error near position {0}: recursive wildcards must form a single path component' -f ($old - 1))
            }
            if ($i -lt $n -and $separators.IndexOf($chars[$i]) -ge 0) {
                $i++
            }
            elseif ($i -ne $n) {
                throw ('Pattern syntax error near position {0}: recursive wildcards must form a single path component' -f $i)
            }
            # glob collapses a '**' that directly follows another one, except as the second token.
            if (-not ($tokenCount -gt 1 -and $lastRecursive)) {
                [void]$out.Append("(?:(?:.*$sepRegex)?|.*\z)")
                $tokenCount++
                $lastRecursive = $true
            }
        }
        elseif ($c -eq [char]'[') {
            $class = $null
            if ($i + 4 -le $n -and $chars[$i + 1] -eq [char]'!') {
                $j = [Array]::IndexOf($chars, [char]']', $i + 3)
                if ($j -ge 0) {
                    $body = Get-ZoGlobClassBody -Chars $chars[($i + 2)..($j - 1)] -Windows $Windows
                    $class = if ($body) { "(?:(?![$body])$anyChar)" } else { $anyChar }
                    $i = $j + 1
                }
            }
            elseif ($i + 3 -le $n -and $chars[$i + 1] -ne [char]'!') {
                $j = [Array]::IndexOf($chars, [char]']', $i + 2)
                if ($j -ge 0) {
                    $body = Get-ZoGlobClassBody -Chars $chars[($i + 1)..($j - 1)] -Windows $Windows
                    $class = if ($body) { "[$body]" } else { '(?!)' }
                    $i = $j + 1
                }
            }
            if ($null -eq $class) {
                throw ('Pattern syntax error near position {0}: invalid range pattern' -f $i)
            }
            [void]$out.Append($class)
            $tokenCount++
            $lastRecursive = $false
        }
        else {
            if ($Windows -and $separators.IndexOf($c) -ge 0) {
                [void]$out.Append($sepRegex)
            }
            else {
                [void]$out.Append([regex]::Escape([string]$c))
            }
            $i++
            $tokenCount++
            $lastRecursive = $false
        }
    }

    [void]$out.Append('\z')
    [regex]::new($out.ToString(), [Text.RegularExpressions.RegexOptions]'Singleline, CultureInvariant', [TimeSpan]::FromSeconds(2))
}

function Get-ZoGlobClassBody {
    <#
    .SYNOPSIS
        Renders the inside of a glob [...] as the inside of a regex character class.
    .DESCRIPTION
        Mirrors glob's parse_char_specifiers and in_char_specifiers with case_sensitive = true:
        'a-z' is a range when a '-' sits between two characters, anything else is a single
        character. A reversed range matches nothing, so it is left out. On Windows a separator
        stands for both separators (chars_eq). Returns '' when nothing can match.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [char[]]$Chars,

        [bool]$Windows = $IsWindows
    )
    $body = [Text.StringBuilder]::new()
    $k = 0
    while ($k -lt $Chars.Length) {
        if ($k + 3 -le $Chars.Length -and $Chars[$k + 1] -eq [char]'-') {
            if ([int]$Chars[$k] -le [int]$Chars[$k + 2]) {
                [void]$body.Append((ConvertTo-ZoClassChar $Chars[$k]) + '-' + (ConvertTo-ZoClassChar $Chars[$k + 2]))
            }
            $k += 3
        }
        else {
            if ($Windows -and ($Chars[$k] -eq [char]'\' -or $Chars[$k] -eq [char]'/')) {
                [void]$body.Append('\\/')
            }
            else {
                [void]$body.Append((ConvertTo-ZoClassChar $Chars[$k]))
            }
            $k++
        }
    }
    $body.ToString()
}

function ConvertTo-ZoClassChar {
    <#
    .SYNOPSIS
        Escapes one character for use inside a .NET regex character class.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [char]$Char
    )
    if ('\]^-['.IndexOf($Char) -ge 0) { return '\' + $Char }
    [string]$Char
}

function Test-ZoGlobMatch {
    <#
    .SYNOPSIS
        Tests a path against a glob with the glob crate's semantics.
    .DESCRIPTION
        Mirrors glob::Pattern::matches with MatchOptions::new(). -Pattern is a glob string
        (compiled with ConvertTo-ZoGlobRegex), a PSZoxide.Glob object from Get-ZoExcludeDirs, or a
        regex that ConvertTo-ZoGlobRegex returned. A match that exceeds the regex timeout throws
        'glob match timed out: <glob>'; given only a [regex], the message shows the regex source,
        because the glob text is not available.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [object]$Pattern,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [bool]$Windows = $IsWindows
    )
    if ($Pattern -is [regex]) { $regex = $Pattern; $text = $Pattern.ToString() }
    elseif ($Pattern.PSObject.TypeNames -contains 'PSZoxide.Glob') { $regex = $Pattern.Regex; $text = $Pattern.Glob }
    else { $text = [string]$Pattern; $regex = ConvertTo-ZoGlobRegex -Pattern $text -Windows $Windows }
    try {
        $regex.IsMatch($Path)
    }
    catch [System.Text.RegularExpressions.RegexMatchTimeoutException] {
        throw (New-ZoError -Chain "glob match timed out: $text")
    }
}