Private/Template.ps1

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# The init script, mirroring zoxide 0.10.0 templates/powershell.txt as askama renders it from
# shell::Opts (cmd, hook, echo, resolve_symlinks; the PowerShell template does not read
# resolve_symlinks). The template text below is the rendered default output with three markers
# on lines of their own: @@ECHO@@ (the _ZO_ECHO line in __zoxide_cd), @@HOOK@@ (the hook
# section body) and @@CMD@@ (the z and zi aliases). tests/fixtures/init_powershell*.ps1 hold the
# binary's output for every variant; Template.Tests.ps1 diffs them byte for byte.

$script:ZoInitTemplate = @'
# =============================================================================
#
# Utility functions for zoxide.
#

# Call zoxide binary, returning the output as UTF-8.
function global:__zoxide_bin {
    $encoding = [Console]::OutputEncoding
    try {
        [Console]::OutputEncoding = [System.Text.Utf8Encoding]::new()
        $result = zoxide @args
        return $result
    } finally {
        [Console]::OutputEncoding = $encoding
    }
}

# pwd based on zoxide's format.
function global:__zoxide_pwd {
    $cwd = Microsoft.PowerShell.Management\Get-Location
    if ($cwd.Provider.Name -eq "FileSystem") {
        $cwd.ProviderPath
    }
}

# cd + custom logic based on the value of _ZO_ECHO.
function global:__zoxide_cd($dir, $literal) {
    $dir = if ($literal) {
        if ($null -eq $dir) {
            Microsoft.PowerShell.Management\Set-Location
        } else {
            Microsoft.PowerShell.Management\Set-Location -LiteralPath $dir -Passthru -ErrorAction Stop
        }
    } else {
        if ($dir -eq '-' -and ($PSVersionTable.PSVersion -lt 6.1)) {
            Microsoft.PowerShell.Utility\Write-Error "cd - is not supported below PowerShell 6.1. Please upgrade your version of PowerShell."
        }
        elseif ($dir -eq '+' -and ($PSVersionTable.PSVersion -lt 6.2)) {
            Microsoft.PowerShell.Utility\Write-Error "cd + is not supported below PowerShell 6.2. Please upgrade your version of PowerShell."
        }
        else {
            Microsoft.PowerShell.Management\Set-Location -Path $dir -Passthru -ErrorAction Stop
        }
    }
@@ECHO@@
}

# =============================================================================
#
# Hook configuration for zoxide.
#

@@HOOK@@

# =============================================================================
#
# When using zoxide with --no-cmd, alias these internal functions as desired.
#

# Jump to a directory using only keywords.
function global:__zoxide_z {
    if ($args.Length -eq 0) {
        __zoxide_cd $null $true
    }
    elseif ($args.Length -eq 1 -and ($args[0] -eq '-' -or $args[0] -eq '+')) {
        __zoxide_cd $args[0] $false
    }
    elseif ($args.Length -eq 1 -and (Microsoft.PowerShell.Management\Test-Path -PathType Container -LiteralPath $args[0])) {
        __zoxide_cd $args[0] $true
    }
    elseif ($args.Length -eq 1 -and (Microsoft.PowerShell.Management\Test-Path -PathType Container -Path $args[0] )) {
        __zoxide_cd $args[0] $false
    }
    else {
        $result = __zoxide_pwd
        if ($null -ne $result) {
            $result = __zoxide_bin query --exclude $result "--" @args
        }
        else {
            $result = __zoxide_bin query "--" @args
        }
        if ($LASTEXITCODE -eq 0) {
            __zoxide_cd $result $true
        }
    }
}

# Jump to a directory using interactive search.
function global:__zoxide_zi {
    $result = __zoxide_bin query -i "--" @args
    if ($LASTEXITCODE -eq 0) {
        __zoxide_cd $result $true
    }
}

# =============================================================================
#
# Commands for zoxide. Disable these using --no-cmd.
#

@@CMD@@

# =============================================================================
#
# To initialize zoxide, add this to your configuration (find it by running
# `echo $profile` in PowerShell):
#
# Invoke-Expression (& { (zoxide init powershell | Out-String) })
'@


$script:ZoInitEcho = ' Microsoft.PowerShell.Utility\Write-Output $dir.Path'

$script:ZoInitNotConfigured = '# -- not configured --'

$script:ZoInitHookPrompt = @'
# Hook to add new entries to the database.
function global:__zoxide_hook {
    $result = __zoxide_pwd
    if ($null -ne $result) {
        zoxide add "--" $result
    }
}
'@


$script:ZoInitHookPwd = @'
# Hook to add new entries to the database.
$global:__zoxide_oldpwd = __zoxide_pwd
function global:__zoxide_hook {
    $result = __zoxide_pwd
    if ($result -ne $global:__zoxide_oldpwd) {
        if ($null -ne $result) {
            zoxide add "--" $result
        }
        $global:__zoxide_oldpwd = $result
    }
}
'@


$script:ZoInitHookInstall = @'

# Initialize hook.
$global:__zoxide_hooked = (Microsoft.PowerShell.Utility\Get-Variable __zoxide_hooked -ErrorAction Ignore -ValueOnly)
if ($global:__zoxide_hooked -ne 1) {
    $global:__zoxide_hooked = 1
    $global:__zoxide_prompt_old = $function:prompt

    function global:prompt {
        if ($null -ne $__zoxide_prompt_old) {
            & $__zoxide_prompt_old
        }
        $null = __zoxide_hook
    }
}
'@


function Get-ZoInitScript {
    <#
    .SYNOPSIS
        The PowerShell init script that `zoxide init powershell` prints, without the final newline.
    .DESCRIPTION
        Mirrors the Powershell(opts).render() call in cmd/init.rs over templates/powershell.txt.
        -Cmd is the alias prefix (Opts.cmd: Some unless --no-cmd), -Hook is Opts.hook and -Echo
        is Opts.echo (config::echo, _ZO_ECHO=1). -ResolveSymlinks is accepted because Opts carries
        it, but the PowerShell template never reads it. Lines are joined with LF, as the binary
        writes them; the caller adds the newline that writeln! appends.
    .PARAMETER Cmd
        The prefix for the z and zi aliases, or $null (or empty) for --no-cmd.
    .PARAMETER Hook
        none, prompt or pwd.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ResolveSymlinks', Justification = 'Part of shell::Opts; templates/powershell.txt does not use it.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Cmd = 'z',

        [ValidateSet('none', 'prompt', 'pwd')]
        [string]$Hook = 'pwd',

        [bool]$Echo = $false,

        [bool]$ResolveSymlinks = $false
    )
    $hookText = switch ($Hook) {
        'none' { $script:ZoInitNotConfigured }
        'prompt' { $script:ZoInitHookPrompt + "`n" + $script:ZoInitHookInstall }
        'pwd' { $script:ZoInitHookPwd + "`n" + $script:ZoInitHookInstall }
    }
    $cmdText = if ($Cmd) {
        "Microsoft.PowerShell.Utility\Set-Alias -Name $Cmd -Value __zoxide_z -Option AllScope -Scope Global -Force`n" +
        "Microsoft.PowerShell.Utility\Set-Alias -Name ${Cmd}i -Value __zoxide_zi -Option AllScope -Scope Global -Force"
    }
    else { $script:ZoInitNotConfigured }

    # The source file may be checked out with CRLF; the binary prints LF only.
    $text = $script:ZoInitTemplate.Replace("`r`n", "`n")
    $text = if ($Echo) { $text.Replace('@@ECHO@@', $script:ZoInitEcho) } else { $text.Replace("@@ECHO@@`n", '') }
    $text.Replace('@@HOOK@@', $hookText.Replace("`r`n", "`n")).Replace('@@CMD@@', $cmdText)
}

function Get-ZoInitFunction {
    <#
    .SYNOPSIS
        The functions a rendered init script defines, by name, as unbound script blocks.
    .DESCRIPTION
        Parses the output of Get-ZoInitScript and returns an ordered dictionary from each function
        name (without the 'global:' prefix: __zoxide_bin, __zoxide_pwd, __zoxide_cd, __zoxide_hook
        when hooked, prompt when hooked, __zoxide_z, __zoxide_zi) to a script block with the
        function's text, its parameters turned into a param() block. The script blocks are
        created with [scriptblock]::Create, so they are not bound to this module and run in the
        session state of whoever calls them, as the template's functions do when the init output
        is evaluated (Install-ZoxideHook explains why that matters). Only those seven names are
        returned, each from its first definition, so text appended to the script can never add
        or replace a function (defence in depth; Install-ZoxideHook renders without user data).
    #>

    [CmdletBinding()]
    [OutputType([Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]
        [string]$Script
    )
    $errors = $null
    $ast = [Management.Automation.Language.Parser]::ParseInput($Script, [ref]$null, [ref]$errors)
    if ($errors) { throw "the init script does not parse: $($errors[0].Message)" }
    $functions = [ordered]@{}
    $definitions = $ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] }, $true)
    $known = @('__zoxide_bin', '__zoxide_pwd', '__zoxide_cd', '__zoxide_hook', 'prompt', '__zoxide_z', '__zoxide_zi')
    foreach ($definition in $definitions) {
        $name = $definition.Name -replace '^global:', ''
        if ($known -cnotcontains $name -or $functions.Contains($name)) { continue }
        $params = if ($definition.Parameters) { 'param(' + (($definition.Parameters | ForEach-Object { $_.Extent.Text }) -join ', ') + ")`n" } else { '' }
        $body = $definition.Body.Extent.Text
        $functions[$name] = [scriptblock]::Create($params + $body.Substring(1, $body.Length - 2))
    }
    $functions
}