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