Private/Cli.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # The command line, mirroring zoxide 0.10.0 src/main.rs, src/cmd/cmd.rs (the clap 4 derive) and # src/cmd/{add,query,remove}.rs. ConvertFrom-ZoArgs is a hand-written clap parser over the raw # tokens of Invoke-Zoxide's $args; its errors reproduce clap's text, tips, usage lines and exit # code 2, checked against the binary. Invoke-ZoMain is main(): it dispatches, writes results to # the pipeline, writes errors through Write-ZoError and sets $global:LASTEXITCODE. # # Command model (built once by Get-ZoCliRoot): # Command Name, Args (declaration order, then help and version), Subcommands, # SubcommandRequired, HelpWhenEmpty (clap's arg_required_else_help), Hidden # Arg Id (the Rust field name), Long, Short, Aliases, Kind (Flag, Value, Positional, Help, # Version), ValueName, Multiple, Required, PossibleValues, ValueAliases, Default, # Float, PathBuf (clap rejects an empty value), Conflicts # # Parse result (ConvertFrom-ZoArgs): Command and Subcommand names, Values keyed by Rust field name # (flags [bool], options [string] or [double] or $null, multi-value positionals [string[]]), and # Exit: $null, or Text/Stderr/Code when clap would print help, a version or an error and stop. # The single stderr seam. Tests swap this scriptblock to capture what would reach the host. $script:ZoErrorWriter = { param([string]$Text) $host.UI.WriteErrorLine($Text) } $script:ZoCliRoot = $null # The script fzf's edit bindings run: edit-helper.ps1 at the module root, beside the manifest, so # every installed copy carries its own. Tests swap it. $script:ZoEditHelper = [IO.Path]::GetFullPath([IO.Path]::Join($PSScriptRoot, '..', 'edit-helper.ps1')) function Write-ZoError { <# .SYNOPSIS Writes one message to stderr through the host, exactly as given. .DESCRIPTION Mirrors the `writeln!(io::stderr(), ...)` in main.rs and clap's error printing. The text is passed to $script:ZoErrorWriter, which calls $host.UI.WriteErrorLine (the host adds the newline). Callers include the 'zoxide: ' prefix themselves; clap errors and help have none. #> [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Message ) & $script:ZoErrorWriter $Message } function New-ZoSilentExit { <# .SYNOPSIS An exception that makes Invoke-ZoMain exit with a code and print nothing. .DESCRIPTION Mirrors error::SilentExit (fzf's interrupt exits 130 this way). Reserved for Phase 3. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')] [CmdletBinding()] [OutputType([Exception])] param( [Parameter(Mandatory)] [int]$Code ) $exception = [Exception]::new("silent exit $Code") $exception.Data['ZoSilentExit'] = $Code $exception } function New-ZoCliArg { <# .SYNOPSIS One argument of the command model: a #[clap(...)] field of cmd/cmd.rs. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')] [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string]$Id, [string]$Long, [string]$Short, [string[]]$Alias = @(), [ValidateSet('Flag', 'Value', 'Positional', 'Help', 'Version')] [string]$Kind = 'Flag', [string]$ValueName, [switch]$Multiple, [switch]$Required, [string[]]$PossibleValue = @(), [hashtable]$ValueAlias = @{}, [string]$Default, [switch]$Float, [switch]$PathBuf, [string[]]$ConflictsWith = @() ) [pscustomobject]@{ PSTypeName = 'PSZoxide.CliArg' Id = $Id Long = $Long Short = $Short Aliases = [string[]]$Alias Kind = $Kind ValueName = $ValueName Multiple = [bool]$Multiple Required = [bool]$Required PossibleValues = [string[]]$PossibleValue ValueAliases = $ValueAlias Default = if ($PSBoundParameters.ContainsKey('Default')) { $Default } else { $null } Float = [bool]$Float PathBuf = [bool]$PathBuf Conflicts = [string[]]$ConflictsWith } } function New-ZoCliCommand { <# .SYNOPSIS One command of the command model; clap's auto-generated help and version args are appended. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')] [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string]$Name, [object[]]$Arguments = @(), [object[]]$Subcommands = @(), [switch]$SubcommandRequired, [switch]$HelpWhenEmpty, [switch]$Hidden ) $builtIn = @( (New-ZoCliArg -Id 'help' -Long 'help' -Short 'h' -Kind Help) (New-ZoCliArg -Id 'version' -Long 'version' -Short 'V' -Kind Version) ) [pscustomobject]@{ PSTypeName = 'PSZoxide.CliCommand' Name = $Name Args = [object[]](@($Arguments) + $builtIn) Subcommands = [object[]]@($Subcommands) SubcommandRequired = [bool]$SubcommandRequired HelpWhenEmpty = [bool]$HelpWhenEmpty Hidden = [bool]$Hidden } } function Get-ZoCliRoot { <# .SYNOPSIS The command model of zoxide 0.10.0, built once. .DESCRIPTION Mirrors the clap derive in cmd/cmd.rs: Cmd (add, edit, import, init, query, remove, with subcommand_required and arg_required_else_help from the derive), the hidden EditCommand subcommands, the ImportFrom sources with the global --merge flag, Init's value enums and aliases (--no-aliases, ksh), and Query's -i/-l conflict. #> [CmdletBinding()] [OutputType([pscustomobject])] param() if ($null -ne $script:ZoCliRoot) { return $script:ZoCliRoot } $merge = New-ZoCliArg -Id 'merge' -Long 'merge' $sources = foreach ($name in 'atuin', 'autojump', 'fasd', 'z', 'z.lua', 'zsh-z') { New-ZoCliCommand -Name $name -Arguments @($merge) } $editCommands = @( foreach ($name in 'decrement', 'delete', 'increment') { New-ZoCliCommand -Name $name -Hidden -Arguments @(New-ZoCliArg -Id 'path' -Kind Positional -ValueName 'PATH' -Required) } New-ZoCliCommand -Name 'reload' -Hidden ) $add = New-ZoCliCommand -Name 'add' -Arguments @( New-ZoCliArg -Id 'paths' -Kind Positional -ValueName 'PATHS' -Multiple -Required -PathBuf New-ZoCliArg -Id 'score' -Long 'score' -Short 's' -Kind Value -ValueName 'SCORE' -Float ) $edit = New-ZoCliCommand -Name 'edit' -Subcommands $editCommands $import = New-ZoCliCommand -Name 'import' -Arguments @($merge) -Subcommands $sources -SubcommandRequired -HelpWhenEmpty $init = New-ZoCliCommand -Name 'init' -Arguments @( New-ZoCliArg -Id 'shell' -Kind Positional -ValueName 'SHELL' -Required -ValueAlias @{ ksh = 'posix' } -PossibleValue @( 'bash', 'elvish', 'fish', 'nushell', 'posix', 'powershell', 'tcsh', 'xonsh', 'zsh') New-ZoCliArg -Id 'no_cmd' -Long 'no-cmd' -Alias 'no-aliases' New-ZoCliArg -Id 'cmd' -Long 'cmd' -Kind Value -ValueName 'CMD' -Default 'z' New-ZoCliArg -Id 'hook' -Long 'hook' -Kind Value -ValueName 'HOOK' -Default 'pwd' -PossibleValue @('none', 'prompt', 'pwd') ) $query = New-ZoCliCommand -Name 'query' -Arguments @( New-ZoCliArg -Id 'keywords' -Kind Positional -ValueName 'KEYWORDS' -Multiple New-ZoCliArg -Id 'all' -Long 'all' -Short 'a' New-ZoCliArg -Id 'interactive' -Long 'interactive' -Short 'i' -ConflictsWith 'list' New-ZoCliArg -Id 'list' -Long 'list' -Short 'l' -ConflictsWith 'interactive' New-ZoCliArg -Id 'score' -Long 'score' -Short 's' New-ZoCliArg -Id 'exclude' -Long 'exclude' -Kind Value -ValueName 'path' New-ZoCliArg -Id 'base_dir' -Long 'base-dir' -Kind Value -ValueName 'path' ) $remove = New-ZoCliCommand -Name 'remove' -Arguments @( New-ZoCliArg -Id 'paths' -Kind Positional -ValueName 'PATHS' -Multiple ) $script:ZoCliRoot = New-ZoCliCommand -Name 'zoxide' -SubcommandRequired -HelpWhenEmpty -Subcommands @( $add, $edit, $import, $init, $query, $remove) $script:ZoCliRoot } function Get-ZoJaro { <# .SYNOPSIS The Jaro similarity of two strings, 0.0 to 1.0. .DESCRIPTION Mirrors strsim::jaro (generic_jaro), which clap uses for its "similar ... exists" tips. Two empty strings score 1.0; one empty string scores 0.0. Characters are compared exactly. #> [CmdletBinding()] [OutputType([double])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$A, [Parameter(Mandatory)] [AllowEmptyString()] [string]$B ) if ($A.Length -eq 0 -and $B.Length -eq 0) { return 1.0 } if ($A.Length -eq 0 -or $B.Length -eq 0) { return 0.0 } $range = [Math]::Max([int][Math]::Floor([Math]::Max($A.Length, $B.Length) / 2) - 1, 0) $aFlags = [bool[]]::new($A.Length) $bFlags = [bool[]]::new($B.Length) $common = 0 for ($i = 0; $i -lt $A.Length; $i++) { $low = [Math]::Max($i - $range, 0) $high = [Math]::Min($B.Length, $i + $range + 1) for ($j = $low; $j -lt $high; $j++) { if (-not $bFlags[$j] -and $A[$i] -ceq $B[$j]) { $aFlags[$i] = $true $bFlags[$j] = $true $common++ break } } } if ($common -eq 0) { return 0.0 } $transpositions = 0 $k = 0 for ($i = 0; $i -lt $A.Length; $i++) { if (-not $aFlags[$i]) { continue } while (-not $bFlags[$k]) { $k++ } if ($A[$i] -cne $B[$k]) { $transpositions++ } $k++ } $transpositions = [Math]::Floor($transpositions / 2) ($common / $A.Length + $common / $B.Length + ($common - $transpositions) / $common) / 3.0 } function Get-ZoSuggestion { <# .SYNOPSIS The candidates similar to a value, least similar first. .DESCRIPTION Mirrors clap's suggestions::did_you_mean: candidates whose Jaro similarity is above 0.7, sorted by ascending similarity, ties in candidate order. clap shows all of them for subcommands and only the last (the best) for arguments and values. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Candidates ) $scored = for ($i = 0; $i -lt $Candidates.Count; $i++) { $score = Get-ZoJaro -A $Value -B $Candidates[$i] if ($score -gt 0.7) { [pscustomobject]@{ Score = $score; Index = $i; Name = $Candidates[$i] } } } @($scored) | Sort-Object -Property Score, Index | ForEach-Object Name } function ConvertFrom-ZoFloat { <# .SYNOPSIS Parses a number the way Rust's f64::from_str does, or throws Rust's error text. .DESCRIPTION Mirrors clap's f64 value parser (str::parse::<f64>): optional sign, digits with an optional fraction ('5', '5.', '.5'), optional exponent, or inf/infinity/nan in any case. No spaces, hex, or underscores. An empty string throws 'cannot parse float from empty string'; anything else invalid throws 'invalid float literal'. #> [CmdletBinding()] [OutputType([double])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) if ($Text -eq '') { throw 'cannot parse float from empty string' } # Rust's NaN is the positive quiet NaN (0x7FF8...), '-nan' sets the sign bit; .NET's # [double]::NaN has the sign bit set, which would change db.zo bytes after an import. if ($Text -match '^([+-]?)nan$') { if ($Matches[1] -eq '-') { return [BitConverter]::Int64BitsToDouble([long]-2251799813685248) } return [BitConverter]::Int64BitsToDouble([long]0x7FF8000000000000) } if ($Text -match '^([+-]?)(inf|infinity)$') { if ($Matches[1] -eq '-') { return [double]::NegativeInfinity } return [double]::PositiveInfinity } if ($Text -cnotmatch '^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$') { throw 'invalid float literal' } [double]::Parse($Text, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture) } function Format-ZoCliArg { <# .SYNOPSIS An argument as clap names it in messages: '--all', '--exclude <path>', '<PATHS>...'. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object]$Arg ) switch ($Arg.Kind) { 'Positional' { "<$($Arg.ValueName)>" + $(if ($Arg.Multiple) { '...' } else { '' }) } 'Value' { "--$($Arg.Long) <$($Arg.ValueName)>" } default { "--$($Arg.Long)" } } } function Get-ZoCliCommandName { <# .SYNOPSIS The command's bin name as clap prints it: 'zoxide.exe import z'. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object]$State ) (@(Get-ZoBinName) + @($State.Names)) -join ' ' } function Get-ZoCliUsage { <# .SYNOPSIS The usage line clap prints after 'Usage: ' in an error. .DESCRIPTION Mirrors clap's Usage::create_usage_with_title. With no used arguments (and without -Smart) it is the full usage: name, '[OPTIONS]' when the command has options, each positional ('<X>' required, '[X]' optional, '...' when repeated), then '<COMMAND>' or '[COMMAND]' for visible subcommands. Otherwise it is the smart usage: name, each used option in the order given, every positional ('<X>' when required or used), then '<COMMAND>' when a subcommand is required. .PARAMETER Used Argument ids, in the order the parser met them. .PARAMETER Smart Force the smart form even with no used arguments (clap does this for missing arguments). #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object]$State, [AllowEmptyCollection()] [string[]]$Used = @(), [switch]$Smart ) $spec = $State.Spec $parts = [Collections.Generic.List[string]]::new() $parts.Add((Get-ZoCliCommandName -State $State)) $positionals = @($spec.Args | Where-Object Kind -EQ 'Positional') if (-not $Smart -and $Used.Count -eq 0) { if (@($spec.Args | Where-Object { $_.Kind -in 'Flag', 'Value' }).Count) { $parts.Add('[OPTIONS]') } foreach ($arg in $positionals) { $name = if ($arg.Required) { "<$($arg.ValueName)>" } else { "[$($arg.ValueName)]" } $parts.Add($name + $(if ($arg.Multiple) { '...' } else { '' })) } if (@($spec.Subcommands | Where-Object { -not $_.Hidden }).Count) { $parts.Add($(if ($spec.SubcommandRequired) { '<COMMAND>' } else { '[COMMAND]' })) } return $parts -join ' ' } foreach ($id in ($Used | Select-Object -Unique)) { $arg = $spec.Args | Where-Object Id -EQ $id | Select-Object -First 1 if ($null -ne $arg -and $arg.Kind -ne 'Positional') { $parts.Add((Format-ZoCliArg -Arg $arg)) } } foreach ($arg in $positionals) { $name = if ($arg.Required -or $Used -contains $arg.Id) { "<$($arg.ValueName)>" } else { "[$($arg.ValueName)]" } $parts.Add($name + $(if ($arg.Multiple) { '...' } else { '' })) } if ($spec.SubcommandRequired) { $parts.Add('<COMMAND>') } $parts -join ' ' } function New-ZoCliExit { <# .SYNOPSIS The exception the parser throws to stop with output and an exit code (help, version, error). #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')] [CmdletBinding()] [OutputType([Exception])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text, [Parameter(Mandatory)] [int]$Code, [switch]$Stderr ) $exception = [Exception]::new($Text) $exception.Data['ZoClap'] = [pscustomobject]@{ Text = $Text; Stderr = [bool]$Stderr; Code = $Code } $exception } function New-ZoCliError { <# .SYNOPSIS A clap usage error (exit 2, stderr) in clap's rich format. .DESCRIPTION Mirrors clap's RichFormatter: 'error: <message>', context lines, a blank line and one ' tip: ...' line per tip, then a blank line and 'Usage: <usage>' when there is a usage, then a blank line and "For more information, try '--help'.". #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')] [CmdletBinding()] [OutputType([Exception])] param( [Parameter(Mandatory)] [string]$Message, [AllowEmptyCollection()] [string[]]$Context = @(), [AllowEmptyCollection()] [string[]]$Tip = @(), [string]$Usage ) # Callers may pass $null for "none" (an if-expression that yields @() collapses to $null). $Context = @($Context | Where-Object { $null -ne $_ }) $Tip = @($Tip | Where-Object { $null -ne $_ }) $lines = [Collections.Generic.List[string]]::new() $lines.Add("error: $Message") foreach ($line in $Context) { $lines.Add($line) } if ($Tip.Count) { $lines.Add('') foreach ($line in $Tip) { $lines.Add(" tip: $line") } } if ($Usage) { $lines.Add('') $lines.Add("Usage: $Usage") } $lines.Add('') $lines.Add("For more information, try '--help'.") New-ZoCliExit -Text ($lines -join "`n") -Code 2 -Stderr } function Find-ZoCliArg { <# .SYNOPSIS The argument of a command with a given long name or alias, short name, or id; $null if none. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [object]$Spec, [string]$Long, [string]$Short, [string]$Id ) foreach ($arg in $Spec.Args) { if ($PSBoundParameters.ContainsKey('Long') -and $arg.Long -and ($arg.Long -ceq $Long -or $arg.Aliases -ccontains $Long)) { return $arg } if ($PSBoundParameters.ContainsKey('Short') -and $arg.Short -and $arg.Short -ceq $Short) { return $arg } if ($PSBoundParameters.ContainsKey('Id') -and $arg.Id -eq $Id) { return $arg } } $null } function Get-ZoCliLongName { <# .SYNOPSIS Every long name and alias of a command, in declaration order (clap's keymap order). #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object]$Spec ) foreach ($arg in $Spec.Args) { if ($arg.Long) { $arg.Long } foreach ($alias in $arg.Aliases) { $alias } } } function Resolve-ZoCliPending { <# .SYNOPSIS Fails when an option is still waiting for its value. .DESCRIPTION Mirrors clap's Parser::resolve_pending: an option given without a value at the end of the line, before '--', or before another known argument is an error, with the possible values listed when there are any. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State ) $arg = $State.Pending if ($null -eq $arg) { return } $State.Pending = $null $context = if ($arg.PossibleValues.Count) { " [possible values: $($arg.PossibleValues -join ', ')]" } else { @() } throw (New-ZoCliError -Message "a value is required for '$(Format-ZoCliArg -Arg $arg)' but none was supplied" -Context $context) } function ConvertTo-ZoCliValue { <# .SYNOPSIS Validates and converts one value for an argument, or throws clap's invalid-value error. .DESCRIPTION Mirrors the value parsers in cmd/cmd.rs: f64 for add --score, the ValueEnum lists for the init shell (with the hidden alias ksh for posix) and --hook, and plain strings elsewhere. clap's PathBufValueParser (add's paths) and EnumValueParser reject an empty value with Error::empty_value, the same text as a missing value; the String and f64 parsers do not. #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [object]$Arg, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) $display = Format-ZoCliArg -Arg $Arg if ($Arg.Float) { try { return ConvertFrom-ZoFloat -Text $Value } catch { throw (New-ZoCliError -Message "invalid value '$Value' for '$display': $($_.Exception.Message)") } } if ($Value -eq '' -and ($Arg.PathBuf -or $Arg.PossibleValues.Count)) { $context = if ($Arg.PossibleValues.Count) { " [possible values: $($Arg.PossibleValues -join ', ')]" } else { @() } throw (New-ZoCliError -Message "a value is required for '$display' but none was supplied" -Context $context) } if ($Arg.PossibleValues.Count -eq 0) { return $Value } if ($Arg.PossibleValues -ccontains $Value) { return $Value } foreach ($alias in $Arg.ValueAliases.Keys) { if ($alias -ceq $Value) { return $Arg.ValueAliases[$alias] } } $best = @(Get-ZoSuggestion -Value $Value -Candidates $Arg.PossibleValues) | Select-Object -Last 1 $tip = if ($null -ne $best) { "a similar value exists: '$best'" } else { @() } throw (New-ZoCliError -Message "invalid value '$Value' for '$display'" -Context " [possible values: $($Arg.PossibleValues -join ', ')]" -Tip $tip) } function Add-ZoCliValue { <# .SYNOPSIS Records a value for an option or positional in the parse state. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State, [Parameter(Mandatory)] [object]$Arg, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) $converted = ConvertTo-ZoCliValue -Arg $Arg -Value $Value if (-not $Arg.Multiple) { $State.Values[$Arg.Id] = $converted return } if (-not $State.Values.Contains($Arg.Id)) { $State.Values[$Arg.Id] = [Collections.Generic.List[string]]::new() } $State.Values[$Arg.Id].Add($converted) } function Invoke-ZoCliReact { <# .SYNOPSIS Applies one flag or option occurrence to the parse state. .DESCRIPTION Mirrors clap's Parser::react: a pending option is resolved first (so '--exclude -l' is a missing value), help and version stop parsing at once, a second occurrence of the same argument is an error, and an option without an attached value waits for the next token. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State, [Parameter(Mandatory)] [object]$Arg, [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Value = @() ) Resolve-ZoCliPending -State $State if ($Arg.Kind -eq 'Help') { $key = if ($State.Names.Count) { $State.Names -join ' ' } else { 'root' } throw (New-ZoCliExit -Text (Get-ZoHelp -Command $key) -Code 0) } if ($Arg.Kind -eq 'Version') { throw (New-ZoCliExit -Text (Get-ZoVersion -Command ($State.Names -join ' ')) -Code 0) } if ($State.Values.Contains($Arg.Id)) { throw (New-ZoCliError -Message "the argument '$(Format-ZoCliArg -Arg $Arg)' cannot be used multiple times" -Usage (Get-ZoCliUsage -State $State)) } if ($Arg.Kind -ne 'Value') { $State.Values[$Arg.Id] = $true return } if ($Value.Count -eq 0) { $State.Pending = $Arg return } Add-ZoCliValue -State $State -Arg $Arg -Value $Value[0] } function Read-ZoCliLong { <# .SYNOPSIS Parses one '--name' or '--name=value' token. .DESCRIPTION Mirrors clap's Parser::parse_long_arg and did_you_mean_error. An unknown name is an error whose tip names the most similar long argument of this command, else a similar argument of a subcommand named later on the line, else (when the command has positionals) how to pass the token as a value. A value attached to a flag is an error. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State, [Parameter(Mandatory)] [string]$Token, [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Remaining = @() ) $body = $Token.Substring(2) $equals = $body.IndexOf('=') $name = if ($equals -ge 0) { $body.Substring(0, $equals) } else { $body } $attached = @() if ($equals -ge 0) { $attached = @($body.Substring($equals + 1)) } $spec = $State.Spec $arg = Find-ZoCliArg -Spec $spec -Long $name if ($null -eq $arg) { $State.Pending = $null $used = [Collections.Generic.List[string]]@($State.Values.Keys) $candidate = @(Get-ZoSuggestion -Value $name -Candidates @(Get-ZoCliLongName -Spec $spec)) | Select-Object -Last 1 $tip = @() if ($null -ne $candidate) { $tip = "a similar argument exists: '--$candidate'" $used.Add((Find-ZoCliArg -Spec $spec -Long $candidate).Id) } else { $best = [int]::MaxValue foreach ($child in $spec.Subcommands) { $childCandidate = @(Get-ZoSuggestion -Value $name -Candidates @(Get-ZoCliLongName -Spec $child)) | Select-Object -Last 1 $position = [Array]::IndexOf([string[]]$Remaining, $child.Name) if ($null -eq $childCandidate -or $position -lt 0 -or $position -ge $best) { continue } $best = $position $tip = "'$($child.Name) --$childCandidate' exists" } if (-not $tip -and -not $State.Trailing -and @($spec.Args | Where-Object Kind -EQ 'Positional').Count) { $tip = "to pass '--$name' as a value, use '-- --$name'" } } throw (New-ZoCliError -Message "unexpected argument '--$name' found" -Tip $tip -Usage (Get-ZoCliUsage -State $State -Used $used)) } if ($arg.Kind -eq 'Value') { Invoke-ZoCliReact -State $State -Arg $arg -Value $attached return } if ($attached.Count) { $State.Pending = $null $used = @($State.Values.Keys) + $arg.Id throw (New-ZoCliError -Message "unexpected value '$($attached[0])' for '$(Format-ZoCliArg -Arg $arg)' found; no more were expected" -Usage (Get-ZoCliUsage -State $State -Used $used)) } Invoke-ZoCliReact -State $State -Arg $arg } function Read-ZoCliShort { <# .SYNOPSIS Parses one '-abc' token: a cluster of short flags, possibly ending in an option and its value. .DESCRIPTION Mirrors clap's Parser::parse_short_arg: flags react one by one; an option takes the rest of the token as its value ('-s5', '-s=5'), or the next token when nothing is left. An unknown character is an error naming '-<char>', with the full usage. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State, [Parameter(Mandatory)] [string]$Token ) for ($k = 1; $k -lt $Token.Length; $k++) { $char = [string]$Token[$k] $arg = Find-ZoCliArg -Spec $State.Spec -Short $char if ($null -eq $arg) { $State.Pending = $null $tip = if (-not $State.Trailing -and @($State.Spec.Args | Where-Object Kind -EQ 'Positional').Count) { "to pass '-$char' as a value, use '-- -$char'" } else { @() } throw (New-ZoCliError -Message "unexpected argument '-$char' found" -Tip $tip -Usage (Get-ZoCliUsage -State $State)) } if ($arg.Kind -ne 'Value') { Invoke-ZoCliReact -State $State -Arg $arg continue } $rest = $Token.Substring($k + 1) $hasEquals = $rest.StartsWith('=') if ($hasEquals) { $rest = $rest.Substring(1) } $attached = @() if ($rest.Length -gt 0 -or $hasEquals) { $attached = @($rest) } Invoke-ZoCliReact -State $State -Arg $arg -Value $attached return } } function New-ZoCliMatchError { <# .SYNOPSIS The error for a token that is neither an argument nor a subcommand. .DESCRIPTION Mirrors clap's Parser::match_arg_error: a subcommand name after '--' gets the "remove the '--'" tip; a command with subcommands reports an unrecognized subcommand (with similar names as tips) unless it also takes positionals; anything else is an unexpected argument. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')] [CmdletBinding()] [OutputType([Exception])] param( [Parameter(Mandatory)] [object]$State, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Token ) $spec = $State.Spec $usage = Get-ZoCliUsage -State $State if ($State.Trailing -and $null -ne (Find-ZoCliSubcommand -Spec $spec -Name $Token)) { return New-ZoCliError -Message "unexpected argument '$Token' found" -Tip "subcommand '$Token' exists; to use it, remove the '--' before it" -Usage $usage } $hasPositionals = @($spec.Args | Where-Object Kind -EQ 'Positional').Count -gt 0 if ($spec.Subcommands.Count) { $candidates = @(Get-ZoSuggestion -Value $Token -Candidates @($spec.Subcommands | ForEach-Object Name)) $tip = switch ($candidates.Count) { 0 { @() } 1 { "a similar subcommand exists: '$($candidates[0])'" } default { "some similar subcommands exist: $(($candidates | ForEach-Object { "'$_'" }) -join ', ')" } } if ($candidates.Count -or -not $hasPositionals) { return New-ZoCliError -Message "unrecognized subcommand '$Token'" -Tip $tip -Usage $usage } } New-ZoCliError -Message "unexpected argument '$Token' found" -Usage $usage } function Find-ZoCliSubcommand { <# .SYNOPSIS The subcommand with exactly this name, hidden ones included; $null if none. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [object]$Spec, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Name ) foreach ($sub in $Spec.Subcommands) { if ($sub.Name -ceq $Name) { return $sub } } $null } function Test-ZoCliCommand { <# .SYNOPSIS Validates a parsed command once its tokens are consumed. .DESCRIPTION Mirrors clap's Validator::validate: help on stderr (exit 2) when a command that needs a subcommand got no arguments at all, the missing-subcommand error, argument conflicts (query -i with -l), then missing required arguments. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$State ) $spec = $State.Spec if ($spec.Subcommands.Count -and $null -eq $State.Child) { if ($spec.HelpWhenEmpty -and $State.Values.Count -eq 0) { $key = if ($State.Names.Count) { $State.Names -join ' ' } else { 'root' } throw (New-ZoCliExit -Text (Get-ZoHelp -Command $key) -Code 2 -Stderr) } if ($spec.SubcommandRequired) { $names = @($spec.Subcommands | Where-Object { -not $_.Hidden } | ForEach-Object Name) -join ', ' throw (New-ZoCliError -Message "'$(Get-ZoCliCommandName -State $State)' requires a subcommand but one was not provided" -Context " [subcommands: $names]" -Usage (Get-ZoCliUsage -State $State)) } } foreach ($id in @($State.Values.Keys)) { $arg = Find-ZoCliArg -Spec $spec -Id $id foreach ($other in $arg.Conflicts) { if (-not $State.Values.Contains($other)) { continue } $used = @($State.Values.Keys | Where-Object { $_ -ne $other }) $otherArg = Find-ZoCliArg -Spec $spec -Id $other throw (New-ZoCliError -Message "the argument '$(Format-ZoCliArg -Arg $arg)' cannot be used with '$(Format-ZoCliArg -Arg $otherArg)'" -Usage (Get-ZoCliUsage -State $State -Used $used)) } } $missing = @($spec.Args | Where-Object { $_.Required -and -not $State.Values.Contains($_.Id) }) if ($missing.Count) { $context = foreach ($arg in $missing) { " $(Format-ZoCliArg -Arg $arg)" } throw (New-ZoCliError -Message 'the following required arguments were not provided:' -Context $context -Usage (Get-ZoCliUsage -State $State -Used @($State.Values.Keys) -Smart)) } } function Read-ZoCommandLine { <# .SYNOPSIS Parses the tokens of one command level, recursing into a subcommand, and validates it. .DESCRIPTION Mirrors clap's Parser::get_matches_with. Before '--', a subcommand name starts the subcommand, '--' ends option parsing, '--x' and '-x' are arguments, and anything else is a pending option's value or the next positional. After '--' every token is a positional. Returns the parse state: Spec, Names, Values (ordered by first use), Child. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [object]$Spec, [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Tokens = @(), [int]$Start = 0, [AllowEmptyCollection()] [string[]]$Names = @() ) $state = [pscustomobject]@{ PSTypeName = 'PSZoxide.CliState' Spec = $Spec Names = [string[]]$Names Values = [ordered]@{} Pending = $null Position = 0 Trailing = $false Child = $null } $positionals = @($Spec.Args | Where-Object Kind -EQ 'Positional') for ($i = $Start; $i -lt $Tokens.Count; $i++) { $token = $Tokens[$i] if (-not $state.Trailing) { $sub = Find-ZoCliSubcommand -Spec $Spec -Name $token if ($null -ne $sub) { Resolve-ZoCliPending -State $state $state.Child = Read-ZoCommandLine -Spec $sub -Tokens $Tokens -Start ($i + 1) -Names (@($Names) + $sub.Name) break } if ($token -ceq '--') { Resolve-ZoCliPending -State $state $state.Trailing = $true continue } if ($token.StartsWith('--')) { $remaining = @() if ($i + 1 -lt $Tokens.Count) { $remaining = @($Tokens[($i + 1)..($Tokens.Count - 1)]) } Read-ZoCliLong -State $state -Token $token -Remaining $remaining continue } if ($token.Length -gt 1 -and $token[0] -eq [char]'-') { Read-ZoCliShort -State $state -Token $token continue } } if ($null -ne $state.Pending) { $arg = $state.Pending $state.Pending = $null Add-ZoCliValue -State $state -Arg $arg -Value $token continue } if ($state.Position -lt $positionals.Count) { $arg = $positionals[$state.Position] Add-ZoCliValue -State $state -Arg $arg -Value $token if (-not $arg.Multiple) { $state.Position++ } continue } throw (New-ZoCliMatchError -State $state -Token $token) } Resolve-ZoCliPending -State $state Test-ZoCliCommand -State $state $state } function ConvertFrom-ZoArgs { <# .SYNOPSIS Parses raw zoxide arguments the way the binary's clap parser does. .DESCRIPTION Mirrors Cmd::parse() for the derive in cmd/cmd.rs. Returns Command (add, edit, import, init, query, remove), Subcommand (the edit action or import source, else $null), Values keyed by the Rust field names with defaults applied (init --cmd z, --hook pwd), and Exit. Exit is $null for a command to run; otherwise it holds the Text clap would print, whether it goes to stderr, and the exit code: 0 for help and version, 2 for usage errors and for the help printed when a command that needs a subcommand gets no arguments. .PARAMETER Arguments The raw tokens, as typed after 'zoxide'. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors clap parsing a list of arguments.')] [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Arguments = @() ) try { $top = Read-ZoCommandLine -Spec (Get-ZoCliRoot) -Tokens $Arguments } catch { if (-not $_.Exception.Data.Contains('ZoClap')) { throw } return [pscustomobject]@{ PSTypeName = 'PSZoxide.ParsedArgs' Command = $null Subcommand = $null Values = @{} Exit = $_.Exception.Data['ZoClap'] } } $levels = [Collections.Generic.List[object]]::new() for ($level = $top.Child; $null -ne $level; $level = $level.Child) { $levels.Add($level) } $values = @{} foreach ($level in $levels) { foreach ($arg in $level.Spec.Args) { if ($arg.Kind -in 'Help', 'Version') { continue } $present = $level.Values.Contains($arg.Id) if ($arg.Kind -eq 'Flag') { # A global flag (import --merge) set at any level stays set. $values[$arg.Id] = $present -or ($values.ContainsKey($arg.Id) -and $values[$arg.Id]) } elseif ($arg.Multiple) { $list = [string[]]@() if ($present) { $list = [string[]]$level.Values[$arg.Id].ToArray() } $values[$arg.Id] = $list } elseif ($present) { $values[$arg.Id] = $level.Values[$arg.Id] } else { $values[$arg.Id] = $arg.Default } } } [pscustomobject]@{ PSTypeName = 'PSZoxide.ParsedArgs' Command = $levels[0].Spec.Name Subcommand = if ($levels.Count -gt 1) { $levels[1].Spec.Name } else { $null } Values = $values Exit = $null } } function Invoke-ZoAdd { <# .SYNOPSIS Adds directories to the database or increments their rank. .DESCRIPTION Mirrors Add::run in cmd/add.rs. The exclude globs, _ZO_MAXAGE and the time are read before the database is opened. Each path is resolved (canonically when _ZO_RESOLVE_SYMLINKS=1); a path containing a line break or matching an exclude glob is skipped silently; a path that is not a directory throws 'not a directory: <path>' before anything is saved. The rank grows by --score (default 1). When anything changed the database is aged, then saved. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Values ) $exclude = @(Get-ZoExcludeDirs) $maxAge = Get-ZoMaxAge $now = Get-ZoEpoch $database = Open-ZoDatabase $resolveSymlinks = Test-ZoResolveSymlinks $by = if ($null -ne $Values.score) { [double]$Values.score } else { 1.0 } foreach ($typed in $Values.paths) { $path = if ($resolveSymlinks) { Resolve-ZoCanonicalPath -Path $typed } else { Resolve-ZoPath -Path $typed } if ($path.Contains("`n") -or $path.Contains("`r")) { continue } $excluded = $false foreach ($glob in $exclude) { if (Test-ZoGlobMatch -Pattern $glob -Path $path) { $excluded = $true; break } } if ($excluded) { continue } # Path::is_dir follows links, as Directory.Exists does. if (-not [IO.Directory]::Exists($path)) { throw "not a directory: $path" } Add-ZoDirUpdate -Database $database -Path $path -By $by -Now $now } if ($database.Dirty) { Invoke-ZoAge -Database $database -MaxAge $maxAge } Save-ZoDatabase -Database $database } function Invoke-ZoQuery { <# .SYNOPSIS Searches the database and writes the result lines to the pipeline. .DESCRIPTION Mirrors Query::run in cmd/query.rs: `self.query(&mut db).and(db.save())`, so the database is saved whether or not the query succeeded (the score sort always makes it dirty), and the query's error wins over a save error. See Invoke-ZoQueryStream for the search itself. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [hashtable]$Values ) $database = Open-ZoDatabase $queryError = $null try { Invoke-ZoQueryStream -Database $database -Values $Values } catch { $queryError = $_ } try { Save-ZoDatabase -Database $database } catch { if ($null -eq $queryError) { throw } } if ($null -ne $queryError) { throw $queryError } } function Invoke-ZoQueryStream { <# .SYNOPSIS Runs one query over an open database: the first match, a list, or an interactive pick. .DESCRIPTION Mirrors Query::query, query_first, query_list and get_stream. The stream gets the keywords, the exclude globs and --base-dir; without --all it also checks that directories exist. --exclude <path> skips that exact path. The first-match form throws 'no match found', or 'you are already in the only match' when only the excluded path matched; the list form prints nothing when nothing matches. -s prefixes each path with its score. -i mirrors query_interactive: the entries (without the --exclude path) go to fzf, and its output is printed whole with -s, else from the eighth character on (selection.get(7..)). Without fzf the console picker (Select-ZoConsolePicker) chooses instead, a deliberate deviation. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object]$Database, [Parameter(Mandatory)] [hashtable]$Values ) $now = Get-ZoEpoch $options = @{ Database = $Database Now = $now Keywords = [string[]]$Values.keywords Exclude = @(Get-ZoExcludeDirs) BaseDir = [string]$Values.base_dir } if (-not $Values.all) { $options.Exists = $true $options.ResolveSymlinks = Test-ZoResolveSymlinks } $stream = New-ZoStream @options $exclude = $Values.exclude $format = @{} if ($Values.score) { $format.Now = $now } if ($Values.interactive) { $fzf = Find-ZoExecutable -Name 'fzf' $feed = New-ZoFeed -Stream $stream -Now $now -Exclude $exclude $selection = if ($fzf) { Invoke-ZoFzf -Command (New-ZoQueryFzfCommand -Path $fzf) -Feed $feed } else { Select-ZoConsolePicker -Feed $feed } # print!("{selection}") or print!("{}", selection.get(7..)): fzf's output keeps its newline. if (-not $Values.score) { if ($selection.Length -lt 7) { throw 'could not read selection from fzf' } $selection = $selection.Substring(7) } if ($selection.EndsWith("`n")) { $selection = $selection.Substring(0, $selection.Length - 1) } if ($selection -ne '') { $selection.Split("`n") } return } if ($Values.list) { while ($null -ne ($dir = Get-ZoStreamNext -Stream $stream)) { if ($null -ne $exclude -and $dir.Path -ceq $exclude) { continue } Format-ZoDir -Dir $dir @format } return } $dir = Get-ZoStreamNext -Stream $stream if ($null -eq $dir) { throw 'no match found' } while ($null -ne $exclude -and $dir.Path -ceq $exclude) { $dir = Get-ZoStreamNext -Stream $stream if ($null -eq $dir) { throw 'you are already in the only match' } } Format-ZoDir -Dir $dir @format } function Invoke-ZoRemove { <# .SYNOPSIS Removes directories from the database. .DESCRIPTION Mirrors Remove::run in cmd/remove.rs: each path is removed by exact match, else by its resolved absolute form; when neither is in the database it throws 'path not found in database: <path as typed>' before anything is saved. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Values ) $database = Open-ZoDatabase foreach ($path in $Values.paths) { if (Remove-ZoDir -Database $database -Path $path) { continue } $absolute = Resolve-ZoPath -Path $path if ($absolute -ceq $path -or -not (Remove-ZoDir -Database $database -Path $absolute)) { throw "path not found in database: $path" } } Save-ZoDatabase -Database $database } function Invoke-ZoInit { <# .SYNOPSIS Prints the init script for `zoxide init powershell`, one line at a time. .DESCRIPTION Mirrors Init::run in cmd/init.rs: Opts.cmd is --cmd unless --no-cmd, Opts.hook is --hook, and Opts.echo and Opts.resolve_symlinks come from _ZO_ECHO and _ZO_RESOLVE_SYMLINKS. The rendered template plus writeln!'s newline is written to the pipeline as lines. Every other shell throws 'PSZoxide only supports init powershell, not <shell>', a deliberate deviation (spec section 2: other shells are out of scope); ksh arrives here already mapped to posix. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [hashtable]$Values ) if ($Values.shell -cne 'powershell') { throw "PSZoxide only supports init powershell, not $($Values.shell)" } $cmd = if ($Values.no_cmd) { $null } else { [string]$Values.cmd } $script = Get-ZoInitScript -Cmd $cmd -Hook $Values.hook -Echo (Test-ZoEcho) -ResolveSymlinks (Test-ZoResolveSymlinks) $script.Split("`n") } function Invoke-ZoEdit { <# .SYNOPSIS Edits the database: the hidden subcommands change one entry, plain `edit` opens fzf. .DESCRIPTION Mirrors Edit::run in cmd/edit.rs. With a subcommand, increment and decrement add +1 or -1 to the path's rank (Database::add: a new entry starts at that rank and now, the rank never drops below 0), delete removes the exact path, reload changes nothing; the database is saved when dirty, then every entry is printed from last to first, unsorted, as the fzf feed ("{score:>6.1}\t{path}" each followed by NUL, no newline) in one string. The path is used as given, without resolving it. Without a subcommand the entries are sorted by score and saved, and fzf runs with the edit bindings (New-ZoEditFzfCommand), whose reload actions call the module's edit-helper.ps1; fzf's `enter:abort` makes the usual end a silent exit 130. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [hashtable]$Values, [string]$Subcommand ) $now = Get-ZoEpoch $database = Open-ZoDatabase if (-not $Subcommand) { Invoke-ZoSort -Database $database -By Score -Now $now Save-ZoDatabase -Database $database $fzf = Find-ZoFzf if (-not [IO.File]::Exists($script:ZoEditHelper)) { throw "could not find the edit helper: $($script:ZoEditHelper)" } $null = Invoke-ZoFzf -Command (New-ZoEditFzfCommand -Path $fzf -Helper $script:ZoEditHelper) return } switch ($Subcommand) { 'decrement' { Add-ZoDir -Database $database -Path $Values.path -By -1.0 -Now $now } 'delete' { $null = Remove-ZoDir -Database $database -Path $Values.path } 'increment' { Add-ZoDir -Database $database -Path $Values.path -By 1.0 -Now $now } } Save-ZoDatabase -Database $database $feed = [Text.StringBuilder]::new() for ($i = $database.Dirs.Count - 1; $i -ge 0; $i--) { [void]$feed.Append((Format-ZoDir -Dir $database.Dirs[$i] -Now $now -Separator "`t")).Append([char]0) } if ($feed.Length) { $feed.ToString() } } function Invoke-ZoMain { <# .SYNOPSIS Runs one zoxide command line: parse, dispatch, print, and set $global:LASTEXITCODE. .DESCRIPTION Mirrors main() in main.rs. Help and version text go to the pipeline one line at a time (exit 0); clap errors and the no-arguments help go to stderr (exit 2). A command's output lines go to the pipeline. A thrown error is written as 'zoxide: <message>' through Write-ZoError (multi-line anyhow chains unchanged) and sets exit code 1, except a silent exit (New-ZoSilentExit), which only sets its code. Never throws. .PARAMETER Arguments The raw tokens after 'zoxide'. #> [CmdletBinding()] [OutputType([string])] param( [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Arguments = @() ) try { $parsed = ConvertFrom-ZoArgs -Arguments $Arguments if ($null -ne $parsed.Exit) { if ($parsed.Exit.Stderr) { Write-ZoError -Message $parsed.Exit.Text } else { $parsed.Exit.Text.Split("`n") } $global:LASTEXITCODE = $parsed.Exit.Code return } switch ($parsed.Command) { 'add' { Invoke-ZoAdd -Values $parsed.Values } 'edit' { Invoke-ZoEdit -Values $parsed.Values -Subcommand $parsed.Subcommand } 'import' { Invoke-ZoImport -Values $parsed.Values -Subcommand $parsed.Subcommand } 'init' { Invoke-ZoInit -Values $parsed.Values } 'query' { Invoke-ZoQuery -Values $parsed.Values } 'remove' { Invoke-ZoRemove -Values $parsed.Values } } $global:LASTEXITCODE = 0 } catch { $exception = $_.Exception while ($exception -is [Management.Automation.MethodInvocationException] -and $null -ne $exception.InnerException) { $exception = $exception.InnerException } if ($exception.Data.Contains('ZoSilentExit')) { $global:LASTEXITCODE = [int]$exception.Data['ZoSilentExit'] return } Write-ZoError -Message "zoxide: $($exception.Message)" $global:LASTEXITCODE = 1 } } |