Private/Stream.ps1

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

# The query stream, mirroring zoxide 0.10.0 src/db/stream.rs (Stream, StreamOptions) and the display
# half of src/db/dir.rs (DirDisplay). Get-ZoScore and Get-ZoEpoch live in Database.ps1, beside
# Invoke-ZoSort, which the stream calls first; they are reused here, not redefined.
#
# Stream [pscustomobject] PSTypeName 'PSZoxide.Stream' (built by New-ZoStream)
# Database the Database being walked; entries are swap-removed from it in place
# Index the next index to visit is Index - 1 (the Rust Rev<Range<usize>>)
# Now, Ttl uint64 unix seconds; Ttl is Now minus three 30-day months, saturating at 0
# Keywords lowercased keywords
# Exclude [regex] objects (glob strings are compiled by New-ZoStream)
# Exists check that each directory exists (off for query --all)
# ResolveSymlinks with Exists, a link to a directory counts as missing
# BaseDir only paths under this directory are returned; '' means no filter

$script:ZoMonth = [uint64](30 * 86400)

function Format-ZoDir {
    <#
    .SYNOPSIS
        One output line for an entry: the path, optionally preceded by its score.
    .DESCRIPTION
        Mirrors DirDisplay's Display impl in db/dir.rs. With -Now the line is
        "{score:>6.1}{separator}{path}", the score clamped to 0..9999 and formatted with the
        invariant culture (.NET F1 rounds 0.25 to 0.2, as Rust does). Without -Now it is the path.
    .PARAMETER Separator
        The character between score and path: a space for query -s, a tab for the fzf feed.
    #>

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

        [uint64]$Now,

        [string]$Separator = ' '
    )
    if (-not $PSBoundParameters.ContainsKey('Now')) { return [string]$Dir.Path }
    $score = [Math]::Clamp((Get-ZoScore -Dir $Dir -Now $Now), 0.0, 9999.0)
    [string]::Format([Globalization.CultureInfo]::InvariantCulture, '{0,6:F1}{1}{2}', $score, $Separator, $Dir.Path)
}

function Test-ZoKeywordMatch {
    <#
    .SYNOPSIS
        True when every keyword appears in the path, in order, the last one in the final component.
    .DESCRIPTION
        Mirrors Stream::filter_by_keywords. Keywords and path are lowercased. The last keyword is
        found with a right-to-left search (rfind) and nothing after it may contain a path
        separator; each earlier keyword is then searched right to left in what precedes the match.
        An empty keyword is found at the end, as rfind("") returns the length. With no keywords
        everything matches.

        Lowercasing mirrors util::to_lowercase with .NET's invariant mapping. That mapping is
        one-to-one, so Rust's rare context-sensitive or expanding mappings (final sigma, dotted
        capital I) differ. The work is inlined because the stream calls this once per entry and
        PowerShell function calls are the dominant cost.
    .PARAMETER Windows
        Use the Windows separators ('/' and '\'). Defaults to the host platform; Unix has only '/'.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Keywords,

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

        [bool]$Windows = $IsWindows
    )
    if ($Keywords.Count -eq 0) { return $true }
    [char[]]$separators = if ($Windows) { '/\' } else { '/' }
    $rest = $Path.ToLowerInvariant()

    $last = $Keywords[-1].ToLowerInvariant()
    $index = if ($last.Length -eq 0) { $rest.Length } else { $rest.LastIndexOf($last, [StringComparison]::Ordinal) }
    if ($index -lt 0) { return $false }
    if ($rest.IndexOfAny($separators, $index + $last.Length) -ge 0) { return $false }
    $rest = $rest.Substring(0, $index)

    for ($k = $Keywords.Count - 2; $k -ge 0; $k--) {
        $keyword = $Keywords[$k].ToLowerInvariant()
        $index = if ($keyword.Length -eq 0) { $rest.Length } else { $rest.LastIndexOf($keyword, [StringComparison]::Ordinal) }
        if ($index -lt 0) { return $false }
        $rest = $rest.Substring(0, $index)
    }
    $true
}

function Get-ZoPathComponent {
    <#
    .SYNOPSIS
        The components of a path as Rust's Path::components yields them, one string each.
    .DESCRIPTION
        Mirrors std::path::Components for comparison purposes. A prefix becomes one token: a disk
        prefix ('C:') or verbatim disk prefix ('\\?\C:') with the letter upper-cased, as Rust's
        parse_prefix does; any other prefix (UNC, device, verbatim) is kept as written, with '/'
        read as '\'. Rust's PrefixParser does the same to the start of the path, so '//srv/share'
        and '\\srv\share' are one prefix. A root becomes the token '\' (never a valid component
        name). Empty and '.' components are dropped, except a '.' that starts a relative path.
        '..' is kept as written. After a verbatim prefix ('\\?\') only '\' separates components,
        as in Rust's is_verbatim_sep.
    #>

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

        [bool]$Windows = $IsWindows
    )
    $components = [Collections.Generic.List[string]]::new()
    $rest = $Path
    # UNC and device prefixes imply a root (Prefix::has_implicit_root).
    $implicitRoot = $false
    $verbatim = $false
    if ($Windows) {
        if ($rest -match '^[\\/]{2}\?[\\/]([A-Za-z]):') {
            $components.Add("\\?\$($Matches[1].ToUpperInvariant()):")
            $rest = $rest.Substring($Matches[0].Length)
            $verbatim = $true
        }
        elseif ($rest -match '^[A-Za-z]:') {
            $components.Add("$($rest.Substring(0, 1).ToUpperInvariant()):")
            $rest = $rest.Substring(2)
        }
        elseif ($rest -match '^[\\/]{2}[^\\/]+[\\/][^\\/]+') {
            # \\server\share, \\?\UNC\server\share, \\.\device: compared as written, '/' read as '\'.
            $prefix = $Matches[0].Replace('/', '\')
            $components.Add($prefix)
            $verbatim = $prefix.StartsWith('\\?\', [StringComparison]::Ordinal)
            $rest = $rest.Substring($Matches[0].Length)
            $implicitRoot = $true
        }
    }

    [char[]]$separators = if ($verbatim) { '\' } elseif ($Windows) { '/\' } else { '/' }
    $hasRoot = $rest.Length -gt 0 -and $separators -contains $rest[0]
    if ($hasRoot) { $components.Add('\') }
    # Component::CurDir survives only at the start of a rootless path: '.' alone or '.' + separator.
    if (-not ($hasRoot -or $implicitRoot) -and ($rest -eq '.' -or ($rest.Length -gt 1 -and $rest[0] -eq [char]'.' -and $separators -contains $rest[1]))) {
        $components.Add('.')
    }
    foreach ($part in $rest.Split($separators)) {
        if ($part -ne '' -and $part -ne '.') { $components.Add($part) }
    }
    , [string[]]$components.ToArray()
}

function Test-ZoPathStartsWith {
    <#
    .SYNOPSIS
        True when -Path is -BaseDir or lies under it, comparing whole components.
    .DESCRIPTION
        Mirrors `Path::new(path).starts_with(base_dir)` in Stream::filter_by_base_dir: a
        component-wise prefix test, so 'C:\ab' is not under 'C:\a'. Components compare exactly
        (case-sensitive), except the drive letter, which Rust normalises; separators may be '/'
        or '\' on Windows, repeated separators and '.' components are ignored, '..' is not
        resolved. An empty -BaseDir matches every path.
    #>

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

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

        [bool]$Windows = $IsWindows
    )
    $pathParts = Get-ZoPathComponent -Path $Path -Windows $Windows
    $baseParts = Get-ZoPathComponent -Path $BaseDir -Windows $Windows
    if ($baseParts.Count -gt $pathParts.Count) { return $false }
    for ($i = 0; $i -lt $baseParts.Count; $i++) {
        if (-not [string]::Equals($pathParts[$i], $baseParts[$i], [StringComparison]::Ordinal)) { return $false }
    }
    $true
}

function New-ZoStream {
    <#
    .SYNOPSIS
        Sorts the database by score and returns a stream that walks it from the best entry down.
    .DESCRIPTION
        Mirrors Stream::new and StreamOptions. The sort (Invoke-ZoSort -By Score) sets Dirty, so
        a query always rewrites db.zo, as the binary does. Filters run lazily in
        Get-ZoStreamNext.
    .PARAMETER Keywords
        Keywords to match (lowercased here, as StreamOptions::with_keywords does).
    .PARAMETER Exclude
        Exclude globs, as PSZoxide.Glob objects from Get-ZoExcludeDirs, glob strings, or [regex]
        objects (whose source then stands in for the glob text in the timeout error). Matching
        entries are removed from the database.
    .PARAMETER Exists
        Only return directories that exist; missing ones not accessed in three months are removed.
    .PARAMETER ResolveSymlinks
        With -Exists, treat a symbolic link or junction to a directory as missing, as
        fs::symlink_metadata does: when paths were canonicalised on add, links in the database
        are stale.
    .PARAMETER BaseDir
        Only return paths under this directory. Empty (the default) means no filter, which is
        also what Rust's starts_with gives for an empty base.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory iterator; the sort is the binary behaviour.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Database,

        [Parameter(Mandatory)]
        [uint64]$Now,

        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Keywords = @(),

        [AllowEmptyCollection()]
        [object[]]$Exclude = @(),

        [switch]$Exists,

        [switch]$ResolveSymlinks,

        [AllowEmptyString()]
        [AllowNull()]
        [string]$BaseDir = ''
    )
    Invoke-ZoSort -Database $Database -By Score -Now $Now
    # Parallel arrays: the walk only needs the regexes; the texts name a glob in the timeout error.
    $globs = [Collections.Generic.List[regex]]::new()
    $texts = [Collections.Generic.List[string]]::new()
    foreach ($pattern in $Exclude) {
        if ($pattern -is [regex]) { $globs.Add($pattern); $texts.Add($pattern.ToString()) }
        elseif ($pattern.PSObject.TypeNames -contains 'PSZoxide.Glob') { $globs.Add($pattern.Regex); $texts.Add($pattern.Glob) }
        else { $globs.Add((ConvertTo-ZoGlobRegex -Pattern ([string]$pattern))); $texts.Add([string]$pattern) }
    }
    [pscustomobject]@{
        PSTypeName      = 'PSZoxide.Stream'
        Database        = $Database
        Index           = $Database.Dirs.Count
        Now             = $Now
        Ttl             = if ($Now -gt 3 * $script:ZoMonth) { [uint64]($Now - 3 * $script:ZoMonth) } else { [uint64]0 }
        Keywords        = [string[]]@($Keywords | ForEach-Object { $_.ToLowerInvariant() })
        Exclude         = $globs.ToArray()
        ExcludeGlobs    = $texts.ToArray()
        Exists          = [bool]$Exists
        ResolveSymlinks = [bool]$ResolveSymlinks
        BaseDir         = [string]$BaseDir
    }
}

function Get-ZoStreamNext {
    <#
    .SYNOPSIS
        The next matching entry of a stream, or $null when the stream is exhausted.
    .DESCRIPTION
        Mirrors Stream::next. Walking from the highest index down, an entry is skipped unless it
        matches the keywords and the base directory; an entry matching an exclude glob is
        swap-removed; with Exists, a missing directory is skipped and, when it was last accessed
        before the time-to-live, swap-removed. Removals set the database's Dirty flag. The existence
        check runs last because it touches the filesystem.

        This loop runs once per entry, and a PowerShell function call costs more than the work it
        wraps, so filters that cannot reject (no keywords, no base directory) are skipped, and the
        exclude test (Test-ZoGlobMatch's contract) and the existence test
        (Stream::filter_by_exists: fs::metadata, or fs::symlink_metadata with ResolveSymlinks)
        are written inline.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Stream
    )
    $database = $Stream.Database
    while ($Stream.Index -gt 0) {
        $Stream.Index--
        $index = $Stream.Index
        $dir = $database.Dirs[$index]
        $path = $dir.Path

        if ($Stream.Keywords.Count -and -not (Test-ZoKeywordMatch -Keywords $Stream.Keywords -Path $path)) { continue }
        # An empty base directory has no components, so every path starts with it.
        if ($Stream.BaseDir -and -not (Test-ZoPathStartsWith -Path $path -BaseDir $Stream.BaseDir)) { continue }

        $excluded = $false
        foreach ($glob in $Stream.Exclude) {
            try {
                $excluded = $glob.IsMatch($path)
            }
            catch [Text.RegularExpressions.RegexMatchTimeoutException] {
                throw (New-ZoError -Chain "glob match timed out: $($Stream.ExcludeGlobs[[Array]::IndexOf($Stream.Exclude, $glob)])")
            }
            if ($excluded) { break }
        }
        if ($excluded) {
            Remove-ZoDirAt -Database $database -Index $index
            continue
        }

        if ($Stream.Exists) {
            $isDirectory = [IO.Directory]::Exists($path) -and
                -not ($Stream.ResolveSymlinks -and $null -ne [IO.DirectoryInfo]::new($path).LinkTarget)
            if (-not $isDirectory) {
                if ($dir.LastAccessed -lt $Stream.Ttl) { Remove-ZoDirAt -Database $database -Index $index }
                continue
            }
        }
        return $dir
    }
    $null
}