Private/Database.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # The zoxide database, mirroring zoxide 0.10.0 src/db/mod.rs, src/db/dir.rs and util::write. # # Data structures (Phase 2 builds on these): # # Database [pscustomobject] PSTypeName 'PSZoxide.Database' # DataDir [string] the data directory (Get-ZoDataDir by default) # Path [string] DataDir joined with 'db.zo' # Dirs [System.Collections.Generic.List[object]] of Dir, in file order # Dirty [bool] true once anything changed; Save-ZoDatabase writes only when true # # Dir [pscustomobject] PSTypeName 'PSZoxide.Dir' (built by New-ZoDir) # Path [string] exact-case; compared with StringComparison.Ordinal # Rank [double] # LastAccessed [uint64] unix seconds # # Every mutating function takes -Database and changes it in place, setting Dirty the way the Rust # method sets `dirty`. Note that both sorts set Dirty (sort_by_path and sort_by_score do), so a # query, which sorts by score, always rewrites db.zo in the binary. # # db.zo is bincode 1.3 with fixint encoding, little-endian: u32 version (3), u64 entry count, then # per entry a u64 byte length plus the UTF-8 path, an f64 rank and a u64 last_accessed. BitConverter # reads use the host byte order; every platform .NET runs PowerShell 7 on is little-endian. $script:ZoDbVersion = [uint32]3 # A UTF-8 decoder that throws on invalid input instead of substituting U+FFFD. $script:ZoStrictUtf8 = [Text.UTF8Encoding]::new($false, $true) function New-ZoDir { <# .SYNOPSIS Creates one database entry. .DESCRIPTION Mirrors db::dir::Dir { path, rank, last_accessed }. ConvertFrom-ZoBytes builds the same object inline for speed; keep the two in sync. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')] [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path, [Parameter(Mandatory)] [double]$Rank, [Parameter(Mandatory)] [uint64]$LastAccessed ) [pscustomobject]@{ PSTypeName = 'PSZoxide.Dir' Path = $Path Rank = $Rank LastAccessed = $LastAccessed } } function Get-ZoEpoch { <# .SYNOPSIS The current time in unix seconds. .DESCRIPTION Mirrors util::current_time. #> [CmdletBinding()] [OutputType([uint64])] param() [uint64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } function Get-ZoScore { <# .SYNOPSIS The frecency score of an entry at a given time. .DESCRIPTION Mirrors Dir::score in db/dir.rs: rank x4 when accessed within the hour, x2 within the day, x0.5 within the week, x0.25 otherwise. A last_accessed in the future counts as now (saturating_sub). Invoke-ZoSort inlines the same arithmetic for speed; keep the two in sync with Dir::score. #> [CmdletBinding()] [OutputType([double])] param( [Parameter(Mandatory)] [object]$Dir, [Parameter(Mandatory)] [uint64]$Now ) $last = [uint64]$Dir.LastAccessed $duration = if ($Now -gt $last) { [uint64]($Now - $last) } else { [uint64]0 } if ($duration -lt 3600) { $Dir.Rank * 4.0 } elseif ($duration -lt 86400) { $Dir.Rank * 2.0 } elseif ($duration -lt 604800) { $Dir.Rank * 0.5 } else { $Dir.Rank * 0.25 } } function ConvertTo-ZoBytes { <# .SYNOPSIS Serialises entries to the db.zo byte layout. .DESCRIPTION Mirrors Database::serialize: bincode fixint little-endian, version 3. An empty list gives exactly 12 bytes, 03000000 then eight zero bytes. Returns a single [byte[]]. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; the output is a byte array.')] [CmdletBinding()] [OutputType([byte[]])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Dirs ) $stream = [IO.MemoryStream]::new() # BinaryWriter is little-endian on every platform. $writer = [IO.BinaryWriter]::new($stream) $writer.Write($script:ZoDbVersion) $writer.Write([uint64]$Dirs.Count) foreach ($dir in $Dirs) { $pathBytes = [Text.Encoding]::UTF8.GetBytes([string]$dir.Path) $writer.Write([uint64]$pathBytes.Length) $writer.Write($pathBytes) $writer.Write([double]$dir.Rank) $writer.Write([uint64]$dir.LastAccessed) } $writer.Flush() , $stream.ToArray() } function Get-ZoUtf8Error { <# .SYNOPSIS Describes the first UTF-8 error in a byte range the way Rust's Utf8Error displays it. .DESCRIPTION Mirrors core::str::from_utf8 validation: returns 'invalid utf-8 sequence of N bytes from index I' or 'incomplete utf-8 byte sequence from index I', or $null when the range is valid. Only called after the strict .NET decoder has already rejected the range. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [byte[]]$Bytes, [Parameter(Mandatory)] [int]$Offset, [Parameter(Mandatory)] [int]$Count ) $i = 0 while ($i -lt $Count) { $first = $Bytes[$Offset + $i] if ($first -lt 0x80) { $i++; continue } $width = if ($first -ge 0xC2 -and $first -le 0xDF) { 2 } elseif ($first -ge 0xE0 -and $first -le 0xEF) { 3 } elseif ($first -ge 0xF0 -and $first -le 0xF4) { 4 } else { 0 } if ($width -eq 0) { return "invalid utf-8 sequence of 1 bytes from index $i" } # The second byte's range excludes overlong forms, surrogates and code points past U+10FFFF. $low = 0x80 $high = 0xBF if ($first -eq 0xE0) { $low = 0xA0 } elseif ($first -eq 0xED) { $high = 0x9F } elseif ($first -eq 0xF0) { $low = 0x90 } elseif ($first -eq 0xF4) { $high = 0x8F } for ($k = 1; $k -lt $width; $k++) { if ($i + $k -ge $Count) { return "incomplete utf-8 byte sequence from index $i" } $next = $Bytes[$Offset + $i + $k] $ok = if ($k -eq 1) { $next -ge $low -and $next -le $high } else { $next -ge 0x80 -and $next -le 0xBF } if (-not $ok) { return "invalid utf-8 sequence of $k bytes from index $i" } } $i += $width } $null } function ConvertFrom-ZoBytes { <# .SYNOPSIS Parses db.zo bytes into a list of entries. .DESCRIPTION Mirrors Database::deserialize with bincode::options().with_fixint_encoding(). Errors use the binary's wording: fewer than 4 bytes is 'could not deserialize database: corrupted data'; another version is 'unsupported version (got N, supports 3)'; anything else wrong in the body is 'could not deserialize database' with bincode's error as the cause. bincode replaces the 32 MiB limit with no limit when reading from a slice, so there is no size check here. Returns a single [System.Collections.Generic.List[object]]. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; the input is a byte array.')] [CmdletBinding()] [OutputType([Collections.Generic.List[object]])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Bytes ) if ($Bytes.Length -lt 4) { throw 'could not deserialize database: corrupted data' } $version = [BitConverter]::ToUInt32($Bytes, 0) if ($version -ne $script:ZoDbVersion) { throw "unsupported version (got $version, supports $($script:ZoDbVersion))" } $context = 'could not deserialize database' # A fixed-size read past the end fails in Read::read_exact; a string past the end fails in # bincode's SliceReader with an empty io::Error message. $eof = 'io error: unexpected end of file' $length = $Bytes.Length $pos = 4 if ($length - $pos -lt 8) { throw (New-ZoError -Chain $context, $eof) } $count = [BitConverter]::ToUInt64($Bytes, $pos) $pos += 8 $dirs = [Collections.Generic.List[object]]::new() for ([uint64]$n = 0; $n -lt $count; $n++) { if ($length - $pos -lt 8) { throw (New-ZoError -Chain $context, $eof) } $pathLength = [BitConverter]::ToUInt64($Bytes, $pos) $pos += 8 if ($pathLength -gt [uint64]($length - $pos)) { throw (New-ZoError -Chain $context, 'io error: ') } try { $path = $script:ZoStrictUtf8.GetString($Bytes, $pos, [int]$pathLength) } catch { $detail = Get-ZoUtf8Error -Bytes $Bytes -Offset $pos -Count ([int]$pathLength) throw (New-ZoError -Chain $context, "string is not valid utf8: $detail") } $pos += [int]$pathLength if ($length - $pos -lt 16) { throw (New-ZoError -Chain $context, $eof) } $rank = [BitConverter]::ToDouble($Bytes, $pos) $lastAccessed = [BitConverter]::ToUInt64($Bytes, $pos + 8) $pos += 16 # New-ZoDir's object, written inline (keep in sync): one function call per entry would cost # several times the decoding itself. $dirs.Add([pscustomobject]@{ PSTypeName = 'PSZoxide.Dir'; Path = $path; Rank = $rank; LastAccessed = $lastAccessed }) } if ($pos -ne $length) { throw (New-ZoError -Chain $context, 'Slice had bytes remaining after deserialization') } , $dirs } function Open-ZoDatabase { <# .SYNOPSIS Reads <DataDir>/db.zo, or returns an empty database when the file does not exist. .DESCRIPTION Mirrors Database::open_dir. When db.zo is missing the data directory is created (but no file is written) and the database starts empty and clean. Read failures throw 'could not read from database: <path>'; directory creation failures throw 'unable to create data directory: <dir>'; both carry the .NET error as the cause. .PARAMETER DataDir The data directory. Defaults to Get-ZoDataDir. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$DataDir = (Get-ZoDataDir) ) $path = [IO.Path]::Join($DataDir, 'db.zo') $bytes = $null try { $bytes = [IO.File]::ReadAllBytes($path) } catch [IO.FileNotFoundException], [IO.DirectoryNotFoundException] { $bytes = $null } catch { throw (New-ZoError -Chain "could not read from database: $path" -ErrorRecord $_) } if ($null -eq $bytes) { Initialize-ZoDataDir -DataDir $DataDir $dirs = [Collections.Generic.List[object]]::new() } else { $dirs = ConvertFrom-ZoBytes -Bytes $bytes } [pscustomobject]@{ PSTypeName = 'PSZoxide.Database' DataDir = $DataDir Path = $path Dirs = $dirs Dirty = $false } } function Initialize-ZoDataDir { <# .SYNOPSIS Creates the data directory if it is missing. .DESCRIPTION Mirrors the fs::create_dir_all call in Database::open_dir, including its error context. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$DataDir ) try { [void][IO.Directory]::CreateDirectory($DataDir) } catch { throw (New-ZoError -Chain "unable to create data directory: $DataDir" -ErrorRecord $_) } } function Save-ZoDatabase { <# .SYNOPSIS Writes the database to db.zo atomically, only when it is dirty. .DESCRIPTION Mirrors Database::save and util::write: the bytes go to a new tmp_<12 random alphanumerics> file beside db.zo, are flushed to disk, and the file is renamed over db.zo (retried on Windows when access is denied). On any failure the tmp file is deleted and the error reads 'could not write to database' with the step that failed as the cause. Dirty is cleared only after a successful write. Unlike the binary, a data directory that vanished after Open-ZoDatabase is created again first. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database ) if (-not $Database.Dirty) { return } $bytes = ConvertTo-ZoBytes -Dirs $Database.Dirs Initialize-ZoDataDir -DataDir $Database.DataDir try { Write-ZoFileAtomic -Path $Database.Path -Bytes $bytes } catch { throw (New-ZoError -Chain 'could not write to database' -ErrorRecord $_) } $Database.Dirty = $false } function New-ZoTmpName { <# .SYNOPSIS A tmp file name: 'tmp_' plus 12 random ASCII letters and digits (16 characters). .DESCRIPTION Mirrors the name built in util::tmpfile with fastrand::alphanumeric. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a string only.')] [CmdletBinding()] [OutputType([string])] param() $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' $name = [Text.StringBuilder]::new('tmp_') while ($name.Length -lt 16) { [void]$name.Append($alphabet[[Security.Cryptography.RandomNumberGenerator]::GetInt32($alphabet.Length)]) } $name.ToString() } function Write-ZoFileAtomic { <# .SYNOPSIS Writes bytes to a file through a tmp file and a rename. .DESCRIPTION Mirrors util::write, util::tmpfile and util::rename: create a new tmp file in the target's directory (5 attempts on a name clash), write, flush to disk, close, rename over the target (up to 5 retries when access is denied, Windows only). The tmp file is deleted on any failure after it was created. The Unix fchown of the tmp file to the old db.zo owner is not mirrored. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Bytes ) $dir = [IO.Path]::GetDirectoryName($Path) $stream = $null for ($attempt = 1; $null -eq $stream; $attempt++) { $tmp = [IO.Path]::Join($dir, (New-ZoTmpName)) try { $stream = [IO.FileStream]::new($tmp, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) } catch { if ($attempt -lt 5 -and [IO.File]::Exists($tmp)) { continue } throw (New-ZoError -Chain "could not create file: $tmp" -ErrorRecord $_) } } try { try { $stream.SetLength($Bytes.Length) } catch { Write-Debug "set_len failed: $_" } try { $stream.Write($Bytes, 0, $Bytes.Length) } catch { throw (New-ZoError -Chain "could not write to file: $tmp" -ErrorRecord $_) } try { $stream.Flush($true) } catch { throw (New-ZoError -Chain "could not sync writes to file: $tmp" -ErrorRecord $_) } $stream.Dispose() $retries = if ($IsWindows) { 5 } else { 0 } for ($attempt = 0; ; $attempt++) { try { [IO.File]::Move($tmp, $Path, $true) break } catch [UnauthorizedAccessException] { if ($attempt -lt $retries) { continue } throw (New-ZoError -Chain "could not rename file: $tmp -> $Path" -ErrorRecord $_) } catch { throw (New-ZoError -Chain "could not rename file: $tmp -> $Path" -ErrorRecord $_) } } } catch { $stream.Dispose() Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue throw } } function Find-ZoDirIndex { <# .SYNOPSIS The index of the entry whose path equals -Path exactly (ordinal), or -1. .DESCRIPTION Mirrors `dirs.iter().position(|dir| dir.path == path)`. #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path ) $dirs = $Database.Dirs for ($i = 0; $i -lt $dirs.Count; $i++) { if ([string]::Equals($dirs[$i].Path, $Path, [StringComparison]::Ordinal)) { return $i } } -1 } function Get-ZoNonNegative { <# .SYNOPSIS Returns the value, or 0.0 when it is negative or NaN, like Rust's f64::max(x, 0.0). #> [CmdletBinding()] [OutputType([double])] param( [Parameter(Mandatory)] [double]$Value ) if ($Value -gt 0) { $Value } else { 0.0 } } function Add-ZoDir { <# .SYNOPSIS Adds -By to an entry's rank, or creates the entry; last_accessed is kept for an existing entry. .DESCRIPTION Mirrors Database::add. The rank never drops below 0. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path, [Parameter(Mandatory)] [double]$By, [Parameter(Mandatory)] [uint64]$Now ) $index = Find-ZoDirIndex -Database $Database -Path $Path if ($index -ge 0) { $dir = $Database.Dirs[$index] $dir.Rank = Get-ZoNonNegative -Value ($dir.Rank + $By) } else { $Database.Dirs.Add((New-ZoDir -Path $Path -Rank (Get-ZoNonNegative -Value $By) -LastAccessed $Now)) } $Database.Dirty = $true } function Add-ZoDirUpdate { <# .SYNOPSIS Adds -By to an entry's rank and sets its last_accessed to -Now, or creates the entry. .DESCRIPTION Mirrors Database::add_update, which `zoxide add` uses. The rank never drops below 0. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path, [Parameter(Mandatory)] [double]$By, [Parameter(Mandatory)] [uint64]$Now ) $index = Find-ZoDirIndex -Database $Database -Path $Path if ($index -ge 0) { $dir = $Database.Dirs[$index] $dir.Rank = Get-ZoNonNegative -Value ($dir.Rank + $By) $dir.LastAccessed = $Now } else { $Database.Dirs.Add((New-ZoDir -Path $Path -Rank (Get-ZoNonNegative -Value $By) -LastAccessed $Now)) } $Database.Dirty = $true } function Add-ZoDirUnchecked { <# .SYNOPSIS Appends an entry without looking for an existing one and without clamping the rank. .DESCRIPTION Mirrors Database::add_unchecked, which import uses before Invoke-ZoDedup. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path, [Parameter(Mandatory)] [double]$Rank, [Parameter(Mandatory)] [uint64]$Now ) $Database.Dirs.Add((New-ZoDir -Path $Path -Rank $Rank -LastAccessed $Now)) $Database.Dirty = $true } function Remove-ZoDirAt { <# .SYNOPSIS Removes the entry at -Index by moving the last entry into its place. .DESCRIPTION Mirrors Database::swap_remove (Vec::swap_remove): O(1), does not keep order. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'In-memory list mutation inside a zoxide clone; confirmation would diverge from the binary.')] [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [int]$Index ) $dirs = $Database.Dirs $last = $dirs.Count - 1 $dirs[$Index] = $dirs[$last] $dirs.RemoveAt($last) $Database.Dirty = $true } function Remove-ZoDir { <# .SYNOPSIS Removes the entry with exactly -Path (ordinal); returns $true when one was removed. .DESCRIPTION Mirrors Database::remove, which swap-removes. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'In-memory list mutation inside a zoxide clone; confirmation would diverge from the binary.')] [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path ) $index = Find-ZoDirIndex -Database $Database -Path $Path if ($index -lt 0) { return $false } Remove-ZoDirAt -Database $Database -Index $index $true } function Invoke-ZoAge { <# .SYNOPSIS Scales every rank down when their sum exceeds -MaxAge, dropping entries that fall below 1. .DESCRIPTION Mirrors Database::age: when the rank total is greater than MaxAge, each rank is multiplied by 0.9 * MaxAge / total, walking from the last entry to the first, and an entry whose rank is then below 1.0 is swap-removed. Dirty is set only when aging happened. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [double]$MaxAge ) $dirs = $Database.Dirs $total = 0.0 foreach ($dir in $dirs) { $total += $dir.Rank } if (-not ($total -gt $MaxAge)) { return } $factor = 0.9 * $MaxAge / $total for ($index = $dirs.Count - 1; $index -ge 0; $index--) { $dir = $dirs[$index] $dir.Rank = $dir.Rank * $factor if ($dir.Rank -lt 1.0) { Remove-ZoDirAt -Database $Database -Index $index } } $Database.Dirty = $true } function Invoke-ZoDedup { <# .SYNOPSIS Merges entries with equal paths: ranks are summed and the newest last_accessed is kept. .DESCRIPTION Mirrors Database::dedup: sort by path, then walk from the end, merging each entry into the one before it when the paths are equal and swap-removing it. Always sets Dirty, because the sort does. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database ) Invoke-ZoSort -Database $Database -By Path $dirs = $Database.Dirs for ($index = $dirs.Count - 1; $index -ge 1; $index--) { $current = $dirs[$index] $previous = $dirs[$index - 1] if (-not [string]::Equals($previous.Path, $current.Path, [StringComparison]::Ordinal)) { continue } if ($current.LastAccessed -gt $previous.LastAccessed) { $previous.LastAccessed = $current.LastAccessed } $previous.Rank = $previous.Rank + $current.Rank Remove-ZoDirAt -Database $Database -Index $index } } function Invoke-ZoSort { <# .SYNOPSIS Sorts the entries in place by path or by ascending score, and sets Dirty. .DESCRIPTION -By Path mirrors Database::sort_by_path: Rust's str ordering, which is UTF-8 byte order (not UTF-16 ordinal order). -By Score mirrors Database::sort_by_score: ascending f64::total_cmp of Get-ZoScore at -Now. This sort is stable. Rust's sort_unstable_by is stable only for tie groups of up to 16 entries (the insertion-sort threshold), so the order inside larger equal-score groups can differ from the binary. .PARAMETER By Path or Score. .PARAMETER Now The time for scoring. Required with -By Score. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [ValidateSet('Path', 'Score')] [string]$By, [uint64]$Now ) $dirs = $Database.Dirs $count = $dirs.Count $Database.Dirty = $true if ($count -lt 2) { return } $indexes = [int[]](0..($count - 1)) if ($By -eq 'Path') { # Latin-1 maps each UTF-8 byte to one char, so ordinal order on the result is byte order. $keys = [string[]]::new($count) for ($i = 0; $i -lt $count; $i++) { $keys[$i] = [Text.Encoding]::Latin1.GetString([Text.Encoding]::UTF8.GetBytes([string]$dirs[$i].Path)) } $order = [Linq.Enumerable]::OrderBy($indexes, [Func[int, string]] { param($i) $keys[$i] }, [StringComparer]::Ordinal) } else { if (-not $PSBoundParameters.ContainsKey('Now')) { throw 'Invoke-ZoSort -By Score needs -Now.' } # f64::total_cmp: flip every bit but the sign of negative values, then compare as i64. $keys = [long[]]::new($count) for ($i = 0; $i -lt $count; $i++) { # Dir::score inline (keep in sync with Get-ZoScore): a PowerShell function call per # entry costs more than the arithmetic it wraps, and every query sorts. $dir = $dirs[$i] $last = [uint64]$dir.LastAccessed $duration = if ($Now -gt $last) { [uint64]($Now - $last) } else { [uint64]0 } $score = if ($duration -lt 3600) { $dir.Rank * 4.0 } elseif ($duration -lt 86400) { $dir.Rank * 2.0 } elseif ($duration -lt 604800) { $dir.Rank * 0.5 } else { $dir.Rank * 0.25 } $bits = [BitConverter]::DoubleToInt64Bits($score) $keys[$i] = if ($bits -lt 0) { $bits -bxor [long]::MaxValue } else { $bits } } $order = [Linq.Enumerable]::OrderBy($indexes, [Func[int, long]] { param($i) $keys[$i] }) } $sorted = foreach ($i in [Linq.Enumerable]::ToArray($order)) { $dirs[$i] } $dirs.Clear() $dirs.AddRange([object[]]$sorted) } |