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 } |