Private/Config.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Configuration, mirroring zoxide 0.10.0 src/config.rs. Every getter re-reads the environment on # each call, like the binary, which reads it once per process (and each zoxide call is a process). # # Error messages follow anyhow's Debug rendering, which is what the binary prints after "zoxide: ": # the context line, then a blank line, "Caused by:" and the indented cause(s). Format-ZoErrorChain # builds that text; private functions throw it and the CLI (Phase 2) prints it verbatim. function Format-ZoErrorChain { <# .SYNOPSIS Renders an error and its causes the way anyhow's Debug format does. .DESCRIPTION Mirrors `writeln!(io::stderr(), "zoxide: {e:?}")` in main.rs for an anyhow::Error: one cause is indented four spaces; several causes are numbered from 0. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Message, [string[]]$Cause ) if (-not $Cause) { return $Message } $lines = if ($Cause.Count -eq 1) { " $($Cause[0])" } else { for ($k = 0; $k -lt $Cause.Count; $k++) { " ${k}: $($Cause[$k])" } } "$Message`n`nCaused by:`n" + ($lines -join "`n") } function New-ZoError { <# .SYNOPSIS Builds an exception for an anyhow-style chain of messages (outermost context first). .DESCRIPTION The message is Format-ZoErrorChain of the chain; the chain itself is kept in Data['ZoChain'] so an outer caller can add its own context, the way anyhow's .context() nests (util::write inside Database::save, for example). With -ErrorRecord, the chain of that caught error (Get-ZoErrorChain) is appended as the causes. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')] [CmdletBinding()] [OutputType([Exception])] param( [Parameter(Mandatory)] [string[]]$Chain, [Management.Automation.ErrorRecord]$ErrorRecord ) if ($ErrorRecord) { $Chain = @($Chain) + @(Get-ZoErrorChain -ErrorRecord $ErrorRecord) } $cause = if ($Chain.Count -gt 1) { $Chain[1..($Chain.Count - 1)] } else { $null } $exception = [Exception]::new((Format-ZoErrorChain -Message $Chain[0] -Cause $cause)) $exception.Data['ZoChain'] = $Chain $exception } function Get-ZoErrorChain { <# .SYNOPSIS The message chain of a caught error: Data['ZoChain'] when New-ZoError built it, else the message of the innermost exception PowerShell wrapped around a .NET call. #> [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)] [Management.Automation.ErrorRecord]$ErrorRecord ) $exception = $ErrorRecord.Exception while ($exception -is [Management.Automation.MethodInvocationException] -and $exception.InnerException) { $exception = $exception.InnerException } if ($exception.Data.Contains('ZoChain')) { return $exception.Data['ZoChain'] } $exception.Message } function Get-ZoLocalDataDir { <# .SYNOPSIS The platform's local data directory, or $null when it cannot be found. .DESCRIPTION Mirrors dirs::data_local_dir (dirs 6.0): %LOCALAPPDATA% (FOLDERID_LocalAppData) on Windows, ~/Library/Application Support on macOS, and $XDG_DATA_HOME when absolute else ~/.local/share elsewhere. #> [CmdletBinding()] [OutputType([string])] param() if ($IsWindows) { $dir = [Environment]::GetFolderPath('LocalApplicationData') } else { $userHome = [Environment]::GetFolderPath('UserProfile') $xdg = [Environment]::GetEnvironmentVariable('XDG_DATA_HOME') $dir = if ($IsMacOS) { if ($userHome) { [IO.Path]::Join($userHome, 'Library/Application Support') } } elseif ($xdg -and $xdg.StartsWith('/')) { $xdg } elseif ($userHome) { [IO.Path]::Join($userHome, '.local/share') } } if ($dir) { $dir } else { $null } } function Get-ZoDataDir { <# .SYNOPSIS The directory that holds db.zo. .DESCRIPTION Mirrors config::data_dir: $env:_ZO_DATA_DIR, else the local data directory plus 'zoxide'. Throws '_ZO_DATA_DIR must be an absolute path' when the result is not fully qualified (on Windows, a drive or UNC root is required; '\x' is not absolute). #> [CmdletBinding()] [OutputType([string])] param() $dir = [Environment]::GetEnvironmentVariable('_ZO_DATA_DIR') if ($null -eq $dir) { $local = Get-ZoLocalDataDir if (-not $local) { throw 'could not find data directory, please set _ZO_DATA_DIR manually' } $dir = [IO.Path]::Join($local, 'zoxide') } if (-not [IO.Path]::IsPathFullyQualified($dir)) { throw '_ZO_DATA_DIR must be an absolute path' } $dir } function Split-ZoPathList { <# .SYNOPSIS Splits a PATH-style list the way std::env::split_paths does. .DESCRIPTION On Unix the list splits on ':' and keeps empty entries. On Windows it splits on ';' outside double quotes, and the quotes are removed. One (possibly empty) entry is always returned. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value, [bool]$Windows = $IsWindows ) if (-not $Windows) { return $Value.Split(':') } $current = [Text.StringBuilder]::new() $inQuote = $false foreach ($ch in $Value.ToCharArray()) { if ($ch -eq [char]'"') { $inQuote = -not $inQuote } elseif ($ch -eq [char]';' -and -not $inQuote) { $current.ToString() [void]$current.Clear() } else { [void]$current.Append($ch) } } $current.ToString() } function Get-ZoExcludeDirs { <# .SYNOPSIS The exclude globs, each with its text and its compiled regex. .DESCRIPTION Mirrors config::exclude_dirs. With _ZO_EXCLUDE_DIRS set, each entry of the list (split like std::env::split_paths: ';' on Windows, ':' elsewhere) is compiled with ConvertTo-ZoGlobRegex; an invalid glob throws 'invalid glob in _ZO_EXCLUDE_DIRS: <glob>' with the glob error as the cause. Unset, the default is the home directory as one escaped glob, so only the home directory itself is excluded. Outputs one PSZoxide.Glob object per glob: Glob (the text as the user wrote it) and Regex (the compiled [regex]). Test-ZoGlobMatch and New-ZoStream take these objects. Each regex has a 2-second match timeout, so a caller that runs .Regex.IsMatch() directly must catch RegexMatchTimeoutException and throw 'glob match timed out: <Glob>' through New-ZoError, as Test-ZoGlobMatch does. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::exclude_dirs.')] [CmdletBinding()] [OutputType([pscustomobject])] param() $raw = [Environment]::GetEnvironmentVariable('_ZO_EXCLUDE_DIRS') if ($null -eq $raw) { $userHome = [Environment]::GetFolderPath('UserProfile') if ($userHome) { $glob = ConvertTo-ZoEscapedGlob -Value $userHome [pscustomobject]@{ PSTypeName = 'PSZoxide.Glob'; Glob = $glob; Regex = ConvertTo-ZoGlobRegex -Pattern $glob } } return } foreach ($glob in Split-ZoPathList -Value $raw) { try { [pscustomobject]@{ PSTypeName = 'PSZoxide.Glob'; Glob = $glob; Regex = ConvertTo-ZoGlobRegex -Pattern $glob } } catch { throw (New-ZoError -Chain "invalid glob in _ZO_EXCLUDE_DIRS: $glob", $_.Exception.Message) } } } function ConvertFrom-ZoMaxAge { <# .SYNOPSIS Parses a _ZO_MAXAGE value like Rust's u32::from_str and returns it as a double. .DESCRIPTION Mirrors the parse inside config::maxage: ASCII digits with an optional leading '+', no whitespace, at most 4294967295. Errors carry Rust's ParseIntError text as the cause. #> [CmdletBinding()] [OutputType([double])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) $cause = $null $digits = if ($Value.StartsWith('+')) { $Value.Substring(1) } else { $Value } if ($Value.Length -eq 0) { $cause = 'cannot parse integer from empty string' } elseif ($digits.Length -eq 0) { $cause = 'invalid digit found in string' } else { [uint64]$number = 0 foreach ($ch in $digits.ToCharArray()) { $digit = [int]$ch - [int][char]'0' if ($digit -lt 0 -or $digit -gt 9) { $cause = 'invalid digit found in string'; break } $number = $number * 10 + $digit if ($number -gt [uint32]::MaxValue) { $cause = 'number too large to fit in target type'; break } } } if ($cause) { throw (New-ZoError -Chain "unable to parse _ZO_MAXAGE as integer: $Value", $cause) } [double]$number } function Get-ZoMaxAge { <# .SYNOPSIS The aging threshold: $env:_ZO_MAXAGE as a double, default 10000. .DESCRIPTION Mirrors config::maxage. #> [CmdletBinding()] [OutputType([double])] param() $raw = [Environment]::GetEnvironmentVariable('_ZO_MAXAGE') if ($null -eq $raw) { return [double]10000 } ConvertFrom-ZoMaxAge -Value $raw } function Test-ZoEcho { <# .SYNOPSIS True when $env:_ZO_ECHO is exactly '1'. .DESCRIPTION Mirrors config::echo. #> [CmdletBinding()] [OutputType([bool])] param() [Environment]::GetEnvironmentVariable('_ZO_ECHO') -ceq '1' } function Test-ZoResolveSymlinks { <# .SYNOPSIS True when $env:_ZO_RESOLVE_SYMLINKS is exactly '1'. .DESCRIPTION Mirrors config::resolve_symlinks. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::resolve_symlinks.')] [CmdletBinding()] [OutputType([bool])] param() [Environment]::GetEnvironmentVariable('_ZO_RESOLVE_SYMLINKS') -ceq '1' } function Get-ZoFzfOpts { <# .SYNOPSIS The raw value of $env:_ZO_FZF_OPTS, or $null when unset. .DESCRIPTION Mirrors config::fzf_opts. When set, the fzf runner passes it to fzf as FZF_DEFAULT_OPTS instead of zoxide's default options. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::fzf_opts.')] [CmdletBinding()] [OutputType([string])] param() [Environment]::GetEnvironmentVariable('_ZO_FZF_OPTS') } function Get-ZoShellOption { <# .SYNOPSIS The shell options Import-Module applies, from _ZO_HOOK, _ZO_CMD and _ZO_NO_CMD. .DESCRIPTION The module's counterparts of the `init` flags (spec section 2), which the binary has no variables for: Hook is _ZO_HOOK when it is exactly none, prompt or pwd (clap's values), else pwd (--hook's default); Cmd is _ZO_CMD when set and not empty, else z (--cmd's default); NoCmd is true when _ZO_NO_CMD is exactly '1', like the other flag variables. Unlike --cmd, which the user types, an environment variable can be set by any parent process, so _ZO_CMD is accepted only as a plain command name (a letter or underscore, then letters, digits, underscores or hyphens). Anything else falls back to z with one `zoxide: ignoring _ZO_CMD ...` line on stderr, unless _ZO_NO_CMD makes the name unused. #> [CmdletBinding()] [OutputType([pscustomobject])] param() $hook = [Environment]::GetEnvironmentVariable('_ZO_HOOK') if (@('none', 'prompt', 'pwd') -cnotcontains $hook) { $hook = 'pwd' } $noCmd = [Environment]::GetEnvironmentVariable('_ZO_NO_CMD') -ceq '1' $cmd = [Environment]::GetEnvironmentVariable('_ZO_CMD') if (-not $cmd) { $cmd = 'z' } elseif ($cmd -cnotmatch '\A[A-Za-z_][A-Za-z0-9_-]*\z') { if (-not $noCmd) { Write-ZoError -Message 'zoxide: ignoring _ZO_CMD: not a valid command name, using z' } $cmd = 'z' } [pscustomobject]@{ PSTypeName = 'PSZoxide.ShellOption' Hook = $hook Cmd = $cmd NoCmd = $noCmd } } |