PSCompletions.ps1

using namespace System.Management.Automation

Microsoft.PowerShell.Core\Set-StrictMode -Off

if ($PSCompletions.guid) { return }

$_ = "$PSScriptRoot/data"
New-Variable -Name PSCompletions -Option Constant -Value @{
    version         = '7.0.0'
    binary_ok       = $false
    path            = @{
        root             = $PSScriptRoot
        completions      = "$_/completions"
        data             = "$_/settings.json"
        temp             = "$_/temp"
        menu             = "$_/temp/menu"
        cache            = "$_/temp/cache"
        log              = "$_/temp/log"
        order            = "$_/temp/order"
        completions_json = "$_/temp/completions.json"
        update           = "$_/temp/update.txt"
        change           = "$_/temp/change.txt"
        last_update      = "$_/temp/last-update.txt"
        module_update    = "$_/temp/module-update.txt"
    }
    cmd             = ''
    guid            = '00929632-527d-4dab-a5b3-21197faccd05'
    language        = $PSUICulture
    separator       = [System.IO.Path]::DirectorySeparatorChar
    replace_pattern = [regex]::new('(?s)\{\{(.*?(\})*)(?=\}\})\}\}', [System.Text.RegularExpressions.RegexOptions]::Compiled)
    input_pattern   = [regex]::new("(?:`"[^`"]*`"|'[^']*'|\S)+", [System.Text.RegularExpressions.RegexOptions]::Compiled)
    menu            = @{
        encoding                      = [System.Text.Encoding]::GetEncoding(0)
        module_completion_menu_script = {
            try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }

            $buffer = ''
            $cursor = 0
            [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$buffer, [ref]$cursor)
            if (!$buffer) { return }

            $PSCompletions.buffer_after_cursor = $buffer.Substring($cursor)
            $buffer = $PSCompletions.buffer_before_cursor = $buffer.Substring(0, $cursor)
            if (!$buffer) { return }

            $isSpaceTab = $buffer[-1] -eq ' '
            $inputs = @()
            $matches = [regex]::Matches($buffer, $PSCompletions.input_pattern)
            foreach ($match in $matches) { $inputs += $match.Value }

            if (!$inputs) { return }

            $alias = $inputs[0]
            if ($null -eq $PSCompletions.data.aliasMap[$alias]) { $alias = $inputs[0] -replace '\.(exe|cmd|bat)$', '' }

            if ($null -ne $PSCompletions.data.aliasMap[$alias] -and ($isSpaceTab -or ($inputs.Count -gt 1 -and $inputs[-1] -notmatch '^[''"]?(?:[A-Za-z]:[/\\]|(?:\.\.?|~)?[/\\]).*'))) {
                $PSCompletions.cmd = $cmd = $PSCompletions.data.aliasMap[$alias]
                $filter_list = $PSCompletions.get_completion($cmd, $inputs)
                $result = $PSCompletions.menu.show_module_menu($filter_list)
                if ($result) {
                    # Only the word being edited is replaced: wordStart/wordEnd derive from the cursor position
                    if ($isSpaceTab) {
                        $wordStart = $buffer.Length
                    }
                    else {
                        $wordStart = $buffer.Length - [string]$inputs[-1].Length
                    }
                    # Extend the word end only when the char after the cursor continues the word (space/quote/empty = new-word boundary)
                    $afterText = [string]$PSCompletions.buffer_after_cursor
                    $wordEnd = $buffer.Length
                    if ($afterText -and $afterText[0] -ne ' ' -and $afterText[0] -ne "`t" -and $afterText[0] -ne '"' -and $afterText[0] -ne "'") {
                        $wordEnd += [regex]::Match($afterText, $PSCompletions.input_pattern).Length
                    }
                    [Microsoft.PowerShell.PSConsoleReadLine]::Replace($wordStart, $wordEnd - $wordStart, $result)
                }
            }
            else {
                # Native fallback: route PowerShell's TabExpansion2 results through the module menu when enable_native_completion is set
                if (!$PSCompletions.config.enable_native_completion) {
                    [Microsoft.PowerShell.PSConsoleReadLine]::MenuComplete()
                    return
                }
                try {
                    $completion = TabExpansion2 $buffer $cursor
                }
                catch {
                    return
                }
                $filter_list = $completion.CompletionMatches
                if (!$filter_list) { return }

                $PSCompletions.cmd = $cmd = $inputs[0]
                if (![System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($cmd)) {
                    # Hand the raw input tokens to the engine: it decides whether the first token
                    # is still being completed (`g<Tab>`, `.\src\<Tab>`) and only then consults
                    # the shared global files (_commands.json / _paths.json). Once the command is
                    # complete (`npm <Tab>`) the candidates are its own subcommands.
                    $filter_list = $PSCompletions.apply_completions_sort($cmd, $filter_list, $inputs, $PSCompletions.buffer_before_cursor[-1] -eq ' ')
                }
                $result = $PSCompletions.menu.show_module_menu($filter_list)
                if ($result) {
                    [Microsoft.PowerShell.PSConsoleReadLine]::Replace($completion.ReplacementIndex, $completion.ReplacementLength, $result)
                }
            }
        }
    }
}

# Native-fallback ranking (TabExpansion2 items) via the engine's --sort mode; also hands the
# menu-order metadata to the menu process. Ranked commands run through the menu's build mode.
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod apply_completions_sort {
    param([string]$cmd, [object]$filter_list, [array]$tokens, [bool]$treatLastAsComplete)
    if (!$filter_list) { return $filter_list }
    $PSCompletions.set_menu_order($cmd)
    if (!$PSCompletions.config.enable_sort_by_history) { return $filter_list }
    $items = @(
        foreach ($item in $filter_list) {
            @{ text = [string]$item.CompletionText }
        }
    )
    $sortInput = @{
        items                  = $items
        order                  = @{
            cmd_order      = "$($PSCompletions.path.order)/$([uri]::EscapeDataString($cmd)).json"
            paths_order    = "$($PSCompletions.path.order)/_shared/_paths.json"
            commands_order = "$($PSCompletions.path.order)/_shared/_commands.json"
        }
        tokens                 = $tokens
        treat_last_as_complete = $treatLastAsComplete
    }
    $sorted = $PSCompletions.run_sort($sortInput)
    if ($null -eq $sorted -or $sorted.Count -eq 0) { return $filter_list }
    $textOrder = @{}
    for ($i = 0; $i -lt $sorted.Count; $i++) { $textOrder[[string]$sorted[$i].text] = $i }
    $filter_list = @($filter_list | Sort-Object {
            $idx = $textOrder[[string]$_.CompletionText]
            if ($null -eq $idx) { [int]::MaxValue } else { $idx }
        })
    $filter_list
}

Add-Member -InputObject $PSCompletions -MemberType ScriptMethod set_menu_order {
    param([string]$cmd)
    if (!$PSCompletions.config.enable_sort_by_history) { return }
    $orderAliases = @($PSCompletions.data.alias[$cmd]).Where({ $_ })
    if (-not $orderAliases) { $orderAliases = @($cmd) }
    $PSCompletions.menu_order = @{
        history = (Get-PSReadLineOption).HistorySavePath
        cmd     = $cmd
        aliases = $orderAliases
        path    = "$($PSCompletions.path.order)/$([uri]::EscapeDataString($cmd)).json"
    }
}

Add-Member -InputObject $PSCompletions -MemberType ScriptMethod get_completion {
    param([string]$cmd, [array]$inputs)
    try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }

    if ($null -eq $cmd) { return }

    $language = $PSCompletions.get_language($cmd)
    $manifest = "$($PSCompletions.path.completions)/$cmd/language/$language.json"
    $hooksLuaPath = "$($PSCompletions.path.completions)/$cmd/hooks.lua"
    # enable_hooks gate: per-completion override (0 disables it); psc itself always runs
    $hasLuaHooks = [System.IO.File]::Exists($hooksLuaPath)
    if ($hasLuaHooks -and $cmd -ne 'psc' -and $PSCompletions.config.completion) {
        $hasLuaHooks = $PSCompletions.config.completion[$cmd].enable_hooks -ne 0
    }
    $argTokens = if ($inputs.Count -le 1) { , @() } else { $inputs[1..($inputs.Count - 1)] }
    # psc completion needs module-level data (paths + live config)
    $pscData = $null
    if ($cmd -eq 'psc') {
        $pscData = @{
            settings         = $PSCompletions.path.data
            completions_json = $PSCompletions.path.completions_json
            completions      = $PSCompletions.path.completions
            config           = @{
                language   = $PSCompletions.config.language
                completion = $PSCompletions.config.completion
            }
        }
    }
    $orderInfo = $null
    if ($PSCompletions.config.enable_sort_by_history) {
        $orderInfo = @{
            cmd_order      = "$($PSCompletions.path.order)/$([uri]::EscapeDataString($cmd)).json"
            paths_order    = "$($PSCompletions.path.order)/_shared/_paths.json"
            commands_order = "$($PSCompletions.path.order)/_shared/_commands.json"
        }
    }
    $buildContext = @{
        cmd                    = $cmd
        arg_tokens             = @($argTokens)
        treat_last_as_complete = [bool]($PSCompletions.buffer_before_cursor[-1] -eq ' ')
        manifest               = $manifest
        hooks                  = [bool]$hasLuaHooks
        cwd                    = (Get-Location).Path
        config                 = $PSCompletions.config.completion[$cmd]
        global_config          = $PSCompletions.config
        data                   = $pscData
        order                  = $orderInfo
        cache_dir              = $PSCompletions.path.cache
        log_dir                = $PSCompletions.path.log
    }
    if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($cmd)) {
        return ''
    }
    # The engine builds and ranks the candidates inside the menu process (build mode).
    $PSCompletions.set_menu_order($cmd)
    return @{ build = $buildContext }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod run_sort {
    param([hashtable]$sortInput)
    $binary = $PSCompletions.menu.menu_binary()
    if (!$binary) { return }
    $tmp = $PSCompletions.path.menu
    $PSCompletions.ensure_dir($tmp)
    $id = [System.Guid]::NewGuid().ToString('N')
    $inputPath = [System.IO.Path]::Combine($tmp, "psc-menu-$id-sort-in.json")
    $outputPath = [System.IO.Path]::Combine($tmp, "psc-menu-$id-sort-out.json")
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    [System.IO.File]::WriteAllText($inputPath, (ConvertTo-Json -InputObject $sortInput -Depth 10 -Compress), $utf8)
    $process = $null
    try {
        $psi = [System.Diagnostics.ProcessStartInfo]::new()
        $psi.FileName = $binary
        $psi.Arguments = "--sort `"$inputPath`" --result `"$outputPath`""
        $psi.UseShellExecute = $false
        $psi.CreateNoWindow = $true
        $psi.RedirectStandardError = $true
        $process = [System.Diagnostics.Process]::Start($psi)
        $stderrTask = $process.StandardError.ReadToEndAsync()
        if ($process.WaitForExit(15000)) {
            $null = $stderrTask.Result
            if ($process.ExitCode -eq 0 -and [System.IO.File]::Exists($outputPath)) {
                return ([System.IO.File]::ReadAllText($outputPath, $utf8) | ConvertFrom-Json)
            }
            return
        }
        try { $null = $stderrTask.Result } catch { }
        return
    }
    catch { return }
    finally {
        try { if ($null -ne $process -and !$process.HasExited) { $process.Kill() } } catch { }
        Remove-Item $inputPath, $outputPath -Force -ErrorAction Ignore
    }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod ensure_dir {
    param([string]$path)
    if (![System.IO.Directory]::Exists($path)) { New-Item -ItemType Directory $path -ErrorAction SilentlyContinue | Out-Null }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod get_language {
    param ([string]$completion)
    if ($PSCompletions.lang_cache.ContainsKey($completion)) {
        return $PSCompletions.lang_cache[$completion]
    }
    $path_config = "$($PSCompletions.path.completions)/$completion/config.json"
    $raw_config = $PSCompletions.get_raw_content($path_config)
    $content_config = $null
    if ($raw_config) {
        try { $content_config = $raw_config | ConvertFrom-Json } catch { $content_config = $null }
    }
    if (!$content_config -or !$content_config.language) {
        $PSCompletions.download_file("completions/$completion/config.json", $path_config, $PSCompletions.urls)
        $raw_retry = $PSCompletions.get_raw_content($path_config)
        if ($raw_retry) {
            try { $content_config = $raw_retry | ConvertFrom-Json } catch { $content_config = $null }
        }
        if ($content_config) {
            $content_config | ConvertTo-Json -Compress | Out-File $path_config -Encoding utf8 -Force
        }
    }
    $config_language = $PSCompletions.config.completion[$completion].language
    if ($config_language) {
        $language = if ($config_language -in $content_config.language) { $config_language }else { $content_config.language[0] }
    }
    else {
        $language = if ($PSCompletions.language -in $content_config.language) { $PSCompletions.language }else { $content_config.language[0] }
    }
    $PSCompletions.lang_cache[$completion] = $language
    $language
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod get_content {
    param ([string]$path)
    try {
        $res = [System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::UTF8).Where({ $_ -ne '' })
        if ($res) { return $res }
    }
    catch { }
    , @()
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod get_raw_content {
    param ([string]$path, [bool]$trim = $true)
    try {
        $res = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
        if ($trim) { return $res.Trim() }
        return $res
    }
    catch {
        return ''
    }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod replace_content {
    param ($data, $separator = '')
    $data = [string]::Join($separator, @($data))
    if ($data -notlike '*{{*') { return $data }
    $matchList = [regex]::Matches($data, $PSCompletions.replace_pattern)
    foreach ($match in $matchList) {
        $expr = $match.Groups[1].Value
        $value = $null
        if ($expr -match '^\$info\.([\w.]+)$') {
            $value = $info
            foreach ($p in ($matches[1] -split '\.')) {
                if ($null -eq $value) { break }
                $value = $value.$p
            }
        }
        elseif ($expr -match '^\$PSCompletions\.config\.(\w+)$') {
            $value = $PSCompletions.config[$matches[1]]
        }
        elseif ($expr -match '^\$env:(\w+)$') {
            $value = [Environment]::GetEnvironmentVariable($matches[1])
        }
        if ($null -ne $value) {
            $valueStr = if ($value -is [string]) { $value } elseif ($null -eq $value) { '' } else { [string]::Join($separator, @($value)) }
            $data = $data.Replace($match.Value, $valueStr)
        }
        else {
            $data = $data.Replace($match.Value, [string]::Join($separator, @(Invoke-Expression $expr)))
        }
    }
    if ($data -match $PSCompletions.replace_pattern) { $PSCompletions.replace_content($data) }else { return $data }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod write_with_color {
    param([string]$str)
    try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }

    $color_list = @()
    $str = $str -replace "`n", $PSCompletions.guid
    $str_list = foreach ($_ in ($str -split '(<\@[^>]+>.*?(?=<\@|$))').Where({ $_ -ne '' })) {
        if ($_ -match '<\@([\s\w]+)>(.*)') {
            ($matches[2] -replace $PSCompletions.guid, "`n") -replace '^<\@>', ''
            $color = $matches[1] -split ' '
            $color_list += @{color = $color[0]; bgColor = $color[1] }
        }
        else {
            ($_ -replace $PSCompletions.guid, "`n") -replace '^<\@>', ''
            $color_list += @{color = $null; bgColor = $null }
        }
    }
    $str_list = @($str_list)
    for ($i = 0; $i -lt $str_list.Count; $i++) {
        $param = @{ Object = $str_list[$i]; NoNewline = $true }
        if ($color_list[$i]['color']) { $param['ForegroundColor'] = $color_list[$i]['color'] }
        if ($color_list[$i]['bgColor']) { $param['BackgroundColor'] = $color_list[$i]['bgColor'] }
        Microsoft.PowerShell.Utility\Write-Host @param
    }
    Microsoft.PowerShell.Utility\Write-Host ''
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod download_file {
    param([string]$path, [string]$file, [array]$baseUrl)
    try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }

    $params = @{ ErrorAction = 'Stop' }
    if ($PSEdition -eq 'Core') { $params['OperationTimeoutSeconds'] = 30 } else { $params['TimeoutSec'] = 30 }
    for ($i = 0; $i -lt $baseUrl.Count; $i++) {
        $item = $baseUrl[$i]
        $url = $item + '/' + $path
        $params['Uri'] = $url
        $params['OutFile'] = $file
        try {
            Invoke-RestMethod @params
            break
        }
        catch {
            if ($i -eq $baseUrl.Count - 1) {
                throw
            }
            else {
                Write-Host $_.Exception.Message -ForegroundColor Red
            }
        }
    }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod write_binary_error {
    Microsoft.PowerShell.Utility\Write-Host -ForegroundColor Red @'
 
[PSCompletions] Binary files not found.
Refer to: https://pscompletions.abgox.com/docs/binary-not-found
 
'@

}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod init_data {
    $PSCompletions.lang_cache = @{}
    $pscBinary = $PSCompletions.psc_binary()
    if (!$pscBinary) {
        $PSCompletions.write_binary_error()
        $PSCompletions.binary_ok = $false
        return
    }
    $dataDir = [System.IO.Path]::GetDirectoryName($PSCompletions.path.data)
    $tmp = $PSCompletions.path.menu
    $PSCompletions.ensure_dir($tmp)
    $id = [System.Guid]::NewGuid().ToString('N')
    $initPath = [System.IO.Path]::Combine($tmp, "psc-init-$id.json")
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    # All init data (settings/aliases/index/URL/info) is provided by psc init — a single source of truth
    & $pscBinary --data $dataDir --language $PSUICulture init --result $initPath 2>$null
    if ($LASTEXITCODE -ne 0 -or ![System.IO.File]::Exists($initPath)) {
        Remove-Item $initPath -Force -ErrorAction SilentlyContinue
        $PSCompletions.write_binary_error()
        $PSCompletions.binary_ok = $false
        return
    }
    $all = $PSCompletions.ConvertFrom_JsonAsHashtable([System.IO.File]::ReadAllText($initPath, $utf8))
    Remove-Item $initPath -Force -ErrorAction SilentlyContinue
    if ($null -eq $all -or $null -eq $all.data) {
        $PSCompletions.write_binary_error()
        $PSCompletions.binary_ok = $false
        return
    }
    $PSCompletions.data = $all.data
    $PSCompletions.data.aliasMap = $all.aliasMap
    $PSCompletions.data.list = @($PSCompletions.data.alias.Keys)
    $PSCompletions.default_config = $all.default_config
    $PSCompletions.config = $PSCompletions.data.config
    if ($PSCompletions.config -is [hashtable] -and $PSCompletions.default_config -is [hashtable]) {
        foreach ($k in $PSCompletions.default_config.Keys) {
            if (-not $PSCompletions.config.ContainsKey($k)) {
                $PSCompletions.config[$k] = $PSCompletions.default_config[$k]
            }
        }
    }
    $PSCompletions.language = $PSCompletions.config.language
    $PSCompletions.urls = @($all.urls)
    $PSCompletions.list = @($all.list)
    $PSCompletions.update = @($all.update)
    $PSCompletions.change = @($all.change)
    $PSCompletions.new_version = [string]$all.new_version
    $PSCompletions.info = $all.info
    $PSCompletions.binary_ok = $true
    if ('psc' -notin $PSCompletions.data.list -and $PSCompletions.init_reentry -lt 2) {
        $PSCompletions.init_reentry = [int]$PSCompletions.init_reentry + 1
        & $pscBinary --data $dataDir add psc 2>$null | Out-Null
        if ($LASTEXITCODE -eq 0) {
            $PSCompletions.init_data()
        }
    }
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod wrap_whitespace {
    param([string]$String)
    if ([string]::IsNullOrWhiteSpace($String)) { return "`"$String`"" }
    if ($String.StartsWith(' ') -or $String.EndsWith(' ')) {
        if ($String.Contains('"')) {
            if ($String.Contains("'")) { return $String } else { return "'$String'" }
        }
        else { return "`"$String`"" }
    }
    return $String
}

Add-Member -InputObject $PSCompletions -MemberType ScriptMethod psc_binary {
    if ($null -ne $PSCompletions.psc_bin_path) { return $PSCompletions.psc_bin_path }
    $menuBin = $PSCompletions.menu.menu_binary()
    if (!$menuBin) { return }
    $dir = [System.IO.Path]::GetDirectoryName($menuBin)
    $name = [System.IO.Path]::GetFileNameWithoutExtension($menuBin)
    $psc = [System.IO.Path]::Combine($dir, ('psc' + [System.IO.Path]::GetExtension($menuBin)))
    if (-not [System.IO.File]::Exists($psc)) { return }
    $PSCompletions.psc_bin_path = $psc
    $psc
}

# menu
Add-Member -InputObject $PSCompletions.menu -MemberType ScriptMethod get_menu_buffer {
    param($startPos, $endPos)
    $rawUI = $Host.UI.RawUI
    $top = [System.Management.Automation.Host.Coordinates]::new($startPos.X, $startPos.Y)
    $bottom = [System.Management.Automation.Host.Coordinates]::new($endPos.X , $endPos.Y)
    $buffer = $rawUI.GetBufferContents([System.Management.Automation.Host.Rectangle]::new($top, $bottom))
    @{ top = $top; bottom = $bottom; buffer = $buffer }
}
Add-Member -InputObject $PSCompletions.menu -MemberType ScriptMethod handle_menu_output {
    param($item)
    $suffix = if ($PSCompletions.config.enable_append_space) { ' ' } else { '' }
    $out = $item.CompletionText.Trim()
    if ($null -eq $item.ResultType) {
        if ($PSCompletions.buffer_after_cursor -match '^\s+[^\s]') {
            return $out
        }
        return "$out$suffix"
    }
    if ($item.ResultType -in
        [System.Management.Automation.CompletionResultType]::Method,
        [System.Management.Automation.CompletionResultType]::Property,
        [System.Management.Automation.CompletionResultType]::Variable,
        [System.Management.Automation.CompletionResultType]::Type,
        [System.Management.Automation.CompletionResultType]::Namespace
    ) {
        return $out
    }
    # Directory, registry key, or other container types
    $_out = $null
    if ($item.ResultType -eq [System.Management.Automation.CompletionResultType]::ProviderContainer) {
        if ($PSCompletions.config.enable_path_trailing_separator) {
            if ($out.Length -ge 1 -and $out[-1] -match "^['`"]$") {
                if ($out.Length -ge 2 -and $out[-2] -match '^[/\\]$') {
                    $_out = $out
                }
                else {
                    $_out = $out.Insert($out.Length - 1, $PSCompletions.separator)
                }
            }
            else {
                $_out = $out + $PSCompletions.separator
            }
        }
        else {
            $_out = $out
        }
    }
    if ($_out) {
        $lastChar = $_out[-1]
        $afterMatch = [regex]::Matches($PSCompletions.buffer_after_cursor, $PSCompletions.input_pattern)
        if ($afterMatch.Count -gt 0 -and $lastChar -in '"', "'" -and $lastChar -eq $afterMatch[0].Value) {
            return $_out -replace "$lastChar`$", ''
        }
        return $_out
    }

    if ($PSCompletions.buffer_after_cursor -match '^\s+[^\s]') {
        return $out
    }
    else {
        $lastChar = $out[-1]
        $afterMatch = [regex]::Matches($PSCompletions.buffer_after_cursor, $PSCompletions.input_pattern)
        if ($afterMatch.Count -gt 0 -and $lastChar -in '"', "'" -and $lastChar -eq $afterMatch[0].Value) {
            return $out -replace "$lastChar`$", ''
        }
    }
    return "$out$suffix"
}
Add-Member -InputObject $PSCompletions.menu -MemberType ScriptMethod menu_binary {
    if ($null -ne $PSCompletions.menu.menu_binary_cache) { return $PSCompletions.menu.menu_binary_cache }
    $binRoot = [System.IO.Path]::Combine($PSCompletions.path.root, 'bin')
    $platform = if ($PSEdition -eq 'Desktop' -or $IsWindows) { 'windows' } elseif ($IsMacOS) { 'darwin' } else { 'linux' }
    try {
        $arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
    }
    catch {
        $arch = if ($env:PROCESSOR_ARCHITECTURE -match 'ARM64') { 'Arm64' } else { 'X64' }
    }
    $archName = if ($arch -match 'ARM64') { 'arm64' } else { 'x64' }
    $ext = if ($platform -eq 'windows') { '.exe' } else { '' }
    $binary = [System.IO.Path]::Combine($binRoot, "$platform-$archName", "psc-menu$ext")
    if (-not [System.IO.File]::Exists($binary)) {
        $PSCompletions.menu.menu_binary_cache = $null
        return
    }
    # nupkg packaging drops the executable bit on Unix; re-apply +x (idempotent, cached)
    if ($platform -ne 'windows') {
        $pscBin = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($binary), 'psc')
        foreach ($b in $binary, $pscBin) {
            if ([System.IO.File]::Exists($b)) {
                try { & chmod +x $b 2>$null } catch { }
            }
        }
    }
    $PSCompletions.menu.menu_binary_cache = $binary
    $binary
}
Add-Member -InputObject $PSCompletions.menu -MemberType ScriptMethod show_module_menu {
    param($filter_list)
    try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }
    if (-not $filter_list) { return '' }
    # Build mode: get_completion hands a build context (hashtable with `build`) instead of items;
    # the engine builds + ranks the candidates inside the menu process.
    $isBuild = ($filter_list -is [hashtable]) -and $filter_list.ContainsKey('build')
    $menu = $PSCompletions.menu
    $config = $PSCompletions.config
    $rawUI = $Host.UI.RawUI
    $binary = $menu.menu_binary()
    if (!$binary) { return }
    if ($rawUI.BufferSize.Height -lt 5) {
        [Microsoft.PowerShell.PSConsoleReadLine]::EndOfLine()
        [Microsoft.PowerShell.PSConsoleReadLine]::Insert(" $($PSCompletions.info.min_area)")
        return ''
    }

    # menu_order is set by get_completion; clear it now so it doesn't leak into the next completion
    $menuOrder = $PSCompletions.menu_order
    $PSCompletions.menu_order = $null

    $input = [ordered]@{}
    if ($isBuild) {
        $input.items = @()
        $input.build = $filter_list.build
    }
    else {
        $input.items = @(
            foreach ($item in $filter_list) {
                $listText = [string]$item.ListItemText
                @{
                    completion_text = [string]$item.CompletionText
                    list_item_text  = $listText
                    symbol          = [string]$item.symbol
                    tip             = [string]$item.ToolTip
                    usage           = [string]$item.Usage
                    example         = [string]$item.Example
                    result_type     = if ($null -eq $item.ResultType) { $null } else { [int]$item.ResultType }
                }
            }
        )
    }
    $input.config = [ordered]@{
        filter_hint    = [string]$PSCompletions.info.filter_hint
        flags          = [ordered]@{
            enable_list_loop           = [bool]$config.enable_list_loop
            filter_mode                = [string]$config.filter_mode
            enable_apply_when_single   = [bool]$config.enable_apply_when_single
            enable_apply_when_no_match = [bool]$config.enable_apply_when_no_match
            show_mode                  = [string]$config.show_mode
        }
        context_switch = [string]$config.switch
        context_stay   = [string]$config.stay
        raw_config     = [ordered]@{
            completion = if ($PSCompletions.config.completion) { $PSCompletions.config.completion[$PSCompletions.cmd] } else { $null }
            global     = $config
            default    = $PSCompletions.default_config
        }
    }
    $input.terminal = [ordered]@{
        cursor   = [ordered]@{ x = [int]$rawUI.CursorPosition.X; y = [int]$rawUI.CursorPosition.Y }
        buffer   = [ordered]@{ w = [int]$rawUI.BufferSize.Width; h = [int]$rawUI.BufferSize.Height }
        window   = [ordered]@{
            top = 0
            h   = [int]$rawUI.BufferSize.Height
        }
        platform = if ($PSEdition -eq 'Desktop' -or $IsWindows) { 'windows' } else { 'unix' }
    }
    if ($menuOrder) {
        $input.order = [ordered]@{
            history = [string]$menuOrder.history
            cmd     = [string]$menuOrder.cmd
            aliases = @($menuOrder.aliases)
            path    = [string]$menuOrder.path
        }
    }
    $initialFilter = $PSCompletions.menu.initial_filter
    $PSCompletions.menu.initial_filter = $null
    if ($initialFilter) {
        $input.initial_filter = [string]$initialFilter
    }
    try {
        # Visible window: WindowPosition is the window's top row in the buffer; WindowSize is the visible height
        $input.terminal.window.top = [int]$rawUI.WindowPosition.Y
        $input.terminal.window.h = [int]$rawUI.WindowSize.Height
    }
    catch { }

    # Use the module's temp dir, not %TEMP%: external cleaners may delete files the menu is still using; keep menu files separate from module state
    $tmp = $PSCompletions.path.menu
    $PSCompletions.ensure_dir($tmp)
    $id = [System.Guid]::NewGuid().ToString('N')
    $inputPath = [System.IO.Path]::Combine($tmp, "psc-menu-$id-input.json")
    $outputPath = [System.IO.Path]::Combine($tmp, "psc-menu-$id-output.json")
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    try {
        [System.IO.File]::WriteAllText($inputPath, (ConvertTo-Json -InputObject $input -Depth 10 -Compress), $utf8)
    }
    catch {
        # Input write failed: menu can't start, fall back and clean up
        try { Remove-Item -LiteralPath $inputPath -Force -ErrorAction Ignore } catch { }
        return ''
    }

    # The child moves the cursor; save the origin to restore on exit
    $originCursor = $rawUI.CursorPosition
    $savedOutputEncoding = [Console]::OutputEncoding
    try { [Console]::OutputEncoding = $PSCompletions.menu.encoding } catch { }

    $windowTop = [Math]::Max(0, [int]$rawUI.WindowPosition.Y)
    $windowH = [Math]::Max(1, [int]$rawUI.WindowSize.Height)
    $cursorToTop = [Math]::Max(0, [int]$originCursor.Y - $windowTop)
    $cursorToBottom = [Math]::Max(0, $windowTop + $windowH - 1 - [int]$originCursor.Y)
    $menuWillShowAbove = if ([string]$config.show_mode -eq 'auto') { $false }
    else { $cursorToTop -gt $cursorToBottom }

    $bufferSaved = $false
    if ($PSEdition -eq 'Desktop' -or $IsWindows) {
        try {
            if ($menuWillShowAbove) {
                # Menu above: save only the region above the cursor (a negative offset covers the input line, which is then saved too)
                $saveEndY = [Math]::Max(0, [int]$originCursor.Y - 1)
                $origin = $menu.get_menu_buffer(
                    [System.Management.Automation.Host.Coordinates]::new(0, 0),
                    [System.Management.Automation.Host.Coordinates]::new($rawUI.BufferSize.Width - 1, [Math]::Min($saveEndY, $rawUI.BufferSize.Height - 1)))
            }
            else {
                # Menu below: save the region under the cursor (the input line stays untouched)
                $saveStartY = [Math]::Max(0, [int]$originCursor.Y + 1)
                $saveStartY = [Math]::Min($saveStartY, $rawUI.BufferSize.Height - 1)
                $origin = $menu.get_menu_buffer(
                    [System.Management.Automation.Host.Coordinates]::new(0, $saveStartY),
                    [System.Management.Automation.Host.Coordinates]::new($rawUI.BufferSize.Width - 1, $rawUI.BufferSize.Height - 1))
            }
            $bufferSaved = $true
        }
        catch {
            # Buffer read failed: skip save/restore, degrade gracefully
            $bufferSaved = $false
        }
    }

    $result = $null
    $errorMsg = $null
    try {
        $psi = [System.Diagnostics.ProcessStartInfo]::new()
        $psi.FileName = $binary
        $psi.Arguments = "`"$inputPath`" --result `"$outputPath`""
        $psi.UseShellExecute = $false
        # No CreateNoWindow so the child inherits the console to read keypresses
        $psi.RedirectStandardError = $true
        $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
        $process = [System.Diagnostics.Process]::Start($psi)
        $stderr = $process.StandardError
        while (-not $process.HasExited) {
            $lineTask = $stderr.ReadLineAsync()
            if (-not $lineTask.Wait(30000)) {
                try { $process.Kill() } catch { }
                try {
                    $esc = [char]27
                    [Console]::Out.Write("$esc[?1049l")
                }
                catch { }
                $errorMsg = 'menu timed out'
                break
            }
            $line = $lineTask.Result
            if ($null -eq $line) { break }
        }
        # Bounded wait for full exit; Kill on abnormal residue so Tab never hangs
        if (-not $process.WaitForExit(30000)) {
            try { $process.Kill() } catch { }
            $errorMsg = 'menu timed out'
        }
        if ([System.IO.File]::Exists($outputPath)) {
            $result = [System.IO.File]::ReadAllText($outputPath, $utf8) | ConvertFrom-Json
        }
        elseif ($errorMsg) {
        }
        else {
            $result = @{ status = 'cancel' }
        }
    }
    catch {
        $errorMsg = $_.Exception.Message
    }
    finally {
        # Restore the saved buffer region on every non-alternate exit. When the menu reported
        # a covered range, restore exactly that. When it died abnormally (timeout / crash /
        # missing result) it may have drawn content without reporting coverage — restore the
        # whole saved region so no residue is left. A normal cancel (no error, no coverage)
        # means nothing was rendered, so skip the restore (writing it back would trigger
        # PSReadLine to repaint its prediction list).
        if ($bufferSaved -and -not ($result -and $result.alternate)) {
            try {
                $hasCovered = $result -and $null -ne $result.covered_top -and $null -ne $result.covered_bottom
                if ($hasCovered -or $errorMsg) {
                    if ($hasCovered) {
                        $top = [int]$result.covered_top
                        $bottom = [int]$result.covered_bottom
                    }
                    else {
                        $top = [int]$origin.top.Y
                        $bottom = [int]$origin.bottom.Y
                    }
                    if ($bottom -ge $top -and $bottom -lt [int]$rawUI.BufferSize.Height) {
                        $w = [int]$rawUI.BufferSize.Width
                        $h = $bottom - $top + 1
                        $sub = New-Object 'System.Management.Automation.Host.BufferCell[,]' $h, $w
                        $offsetY = $top - [int]$origin.top.Y
                        # 2D arrays need GetValue/SetValue: `$arr[$r, $c]` flattens, and `$i + $r, $c` triggers op_Addition
                        for ($r = 0; $r -lt $h; $r++) {
                            for ($c = 0; $c -lt $w; $c++) {
                                $sub.SetValue($origin.buffer.GetValue($offsetY + $r, $c), $r, $c)
                            }
                        }
                        $rawUI.SetBufferContents(
                            [System.Management.Automation.Host.Coordinates]::new(0, $top),
                            $sub)
                    }
                }
            }
            catch { }
        }
        # Retry deletes briefly (antivirus etc.); try/catch covers files already gone or never written
        foreach ($p in @($inputPath, $outputPath)) {
            for ($i = 0; $i -lt 3 -and [System.IO.File]::Exists($p); $i++) {
                try { Remove-Item -LiteralPath $p -Force } catch { Start-Sleep -Milliseconds 100 }
            }
        }
        # Restore encoding/cursor in finally so a throw mid-menu still recovers both.
        # Restore the cursor on every platform: the alternate-screen leave only returns the
        # terminal to the main screen; repositioning the cursor back to the input line is our
        # job (on Windows the menu also leaves it at the last drawn spot otherwise).
        try { [Console]::OutputEncoding = $savedOutputEncoding } catch { }
        try { $rawUI.CursorPosition = $originCursor } catch { }
    }
    if ($result) {
        switch ($result.status) {
            'selected' {
                if ($isBuild) {
                    # Build mode: the engine returns the selected item's text/type (no host item list)
                    return $menu.handle_menu_output(@{ CompletionText = $result.completion_text; ResultType = $result.result_type })
                }
                return $menu.handle_menu_output($filter_list[$result.index])
            }
            'cancel' { return '' }
            'input' { return [string]$result.text }
            'min_area' {
                [Microsoft.PowerShell.PSConsoleReadLine]::EndOfLine()
                [Microsoft.PowerShell.PSConsoleReadLine]::Insert(" $($PSCompletions.info.min_area)")
                return ''
            }
            default { $errorMsg = if ($result.message) { [string]$result.message } else { 'unexpected status' } }
        }
    }
    if ($errorMsg) {
        # Write-Error renders below the input line (not on it); SilentlyContinue neutralizes session-level $ErrorActionPreference
        Microsoft.PowerShell.Utility\Write-Error -Message "[PSCompletions] menu unavailable: $errorMsg" -ErrorAction SilentlyContinue
        return
    }
}

if ($IsWindows -or $PSEdition -eq 'Desktop') {
    if ($PSCompletions.path.root -like "$env:ProgramFiles*" -or $PSCompletions.path.root -like "$env:SystemRoot*") {
        if (![Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
            Microsoft.PowerShell.Utility\Write-Host -ForegroundColor Red @"
 
[PSCompletions] Administrator Required
-------------------------------------------------
PSCompletions is installed in a system-level directory.
Location: $($PSCompletions.path.root)
 
To use PSCompletions normally, please:
1. Run PowerShell as Administrator.
2. Or reinstall the module to a user-writable location via '-Scope CurrentUser'.
 
Refer to: https://pscompletions.abgox.com/docs/require-admin
 
"@

            return
        }
    }
}

Add-Member -InputObject $PSCompletions -MemberType ScriptMethod handle_completion {
    Set-PSReadLineKeyHandler -Key $PSCompletions.config.trigger_key -ScriptBlock $PSCompletions.menu.module_completion_menu_script
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod ConvertFrom_JsonAsHashtable {
    param([string]$json)
    # https://github.com/abgox/ConvertFrom-JsonAsHashtable
    if ($PSVersionTable.PSVersion.Major -ge 7) { return ConvertFrom-Json $json -AsHashtable }
    # V5: optimized for completion JSON schema (strings, numbers, arrays, objects)
    $parsed = ConvertFrom-Json $json
    function ConvertObj {
        param($obj)
        if ($obj -is [System.Management.Automation.PSCustomObject]) {
            $ht = @{}
            foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = ConvertObj $p.Value }
            return $ht
        }
        if ($obj -is [array]) {
            $list = [System.Collections.Generic.List[object]]::new($obj.Count)
            foreach ($item in $obj) { $list.Add((ConvertObj $item)) }
            return , $list.ToArray()
        }
        return $obj
    }
    ConvertObj $parsed
}
Add-Member -InputObject $PSCompletions -MemberType ScriptMethod start_job {
    $pscBinary = $PSCompletions.psc_binary()
    $dataDir = [System.IO.Path]::GetDirectoryName($PSCompletions.path.data)
    $moduleVersion = $PSCompletions.version
    $scriptBlock = {
        param($dataDir, $pscBinary, $moduleVersion)
        [System.IO.Directory]::CreateDirectory((Join-Path $dataDir 'temp')) | Out-Null
        [System.IO.Directory]::CreateDirectory((Join-Path $dataDir 'temp\order')) | Out-Null
        # Drop stale per-command order caches (rebuilt on next use);
        $cutoff = (Get-Date).AddDays(-90)
        Get-ChildItem (Join-Path $dataDir 'temp\order') -Filter '*.json' -File -ErrorAction SilentlyContinue |
        Where-Object { $_.LastWriteTime -lt $cutoff } |
        Remove-Item -Force -ErrorAction SilentlyContinue
        [System.IO.Directory]::CreateDirectory((Join-Path $dataDir 'completions\psc')) | Out-Null
        foreach ($p in 'temp\change.txt', 'temp\update.txt') {
            $f = Join-Path $dataDir $p
            if (![System.IO.File]::Exists($f)) { '' | Out-File $f -Force -Encoding utf8 }
        }
        # Update check is centralized to psc check (psc unifies the network/index/version logic)
        if ($pscBinary) {
            & $pscBinary --data $dataDir check $moduleVersion 2>$null | Out-Null
        }
    }
    $params = @{
        ScriptBlock  = $scriptBlock
        ArgumentList = $dataDir, $pscBinary, $moduleVersion
    }
    $null = if ($PSEdition -eq 'Core') { Start-ThreadJob @params } else { Start-Job @params }
}

if (![System.IO.Directory]::Exists($PSCompletions.path.order)) {
    Add-Member -InputObject $PSCompletions -MemberType ScriptMethod move_old_version {
        function _moveData {
            param($Dir, $JsonFile, $CompletionsDir)
            $PSCompletions.ensure_dir($CompletionsDir)
            if (![System.IO.File]::Exists($JsonFile) -and [System.IO.File]::Exists("$Dir/data.json")) {
                Move-Item "$Dir/data.json" $JsonFile -Force -ErrorAction Ignore
                $oldData = $PSCompletions.ConvertFrom_JsonAsHashtable($PSCompletions.get_raw_content($JsonFile))
                if ($oldData -and ($oldData.ContainsKey('list') -or $oldData.ContainsKey('aliasMap'))) {
                    $oldData.Remove('list')
                    $oldData.Remove('aliasMap')
                    $oldData | ConvertTo-Json -Depth 10 | Out-File $JsonFile -Force -Encoding utf8
                }
            }
            $Dir, $PSCompletions.path.root | ForEach-Object {
                if ([System.IO.Directory]::Exists("$_/completions")) {
                    Get-ChildItem "$_/completions" -Directory | ForEach-Object { Copy-Item $_.FullName $CompletionsDir -Force -Recurse }
                    Remove-Item "$_/completions" -Force -Recurse -ErrorAction Ignore
                }
            }
        }
        $version = (Get-ChildItem (Split-Path $PSCompletions.path.root -Parent) -ErrorAction Ignore).Name | Where-Object { $_ -match '^\d+\.\d.*' } | Sort-Object { [Version]$_ }
        if ($null -eq $version) {
            $scoop_persist = Join-Path $PSCompletions.path.root.Replace('\modules\PSCompletions', '') 'persist'
            foreach ($_ in "$scoop_persist/abgox.PSCompletions", "$scoop_persist/pscompletions") {
                if ([System.IO.Directory]::Exists($_)) { _moveData $_ "$_/data/settings.json" "$_/data/completions" }
            }
            return
        }
        if ($version.Count -ge 2) {
            $oldVerDir = Join-Path (Split-Path $PSCompletions.path.root -Parent) $version[-2]
            if ([System.IO.Directory]::Exists("$oldVerDir/data")) { Move-Item "$oldVerDir/data" $PSCompletions.path.root -Force -ErrorAction Ignore }
        }
        else {
            $oldVerDir = $PSCompletions.path.root
        }
        _moveData $oldVerDir $PSCompletions.path.data $PSCompletions.path.completions
    }
    $PSCompletions.move_old_version()
    $PSCompletions.ensure_dir($PSCompletions.path.completions)
    $PSCompletions.ensure_dir($PSCompletions.path.temp)
    $PSCompletions.ensure_dir($PSCompletions.path.order)
    $PSCompletions.is_init = $true
}

$PSCompletions.init_data()
# init_data already printed an error when the binary is missing; the module can't work, so stop loading
if (-not $PSCompletions.binary_ok) { return }
$PSCompletions.start_job()
$PSCompletions.handle_completion()

if ($PSCompletions.is_init) {
    $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.description))
}
if ($PSCompletions.config.enable_auto_alias_setup) {
    $Matches = $PSCompletions.data.aliasMap.Keys
    foreach ($_ in $Matches) {
        $args = $PSCompletions.data.aliasMap[$_]
        if ($args -eq 'psc') {
            Microsoft.PowerShell.Utility\Set-Alias $_ PSCompletions -Force -ErrorAction Ignore
        }
        elseif ($_ -ne $args -and $_ -notmatch '[\\/]') {
            Microsoft.PowerShell.Utility\Set-Alias $_ $args -Force -ErrorAction Ignore
        }
    }
    $Matches = $null
}
else {
    Microsoft.PowerShell.Utility\Set-Alias psc PSCompletions -Force -ErrorAction Ignore
}
if ($PSCompletions.new_version) {
    if ($PSCompletions.new_version -match '^[\d\.]+$') {
        $PSCompletions.version_list = $PSCompletions.new_version, $PSCompletions.version | Sort-Object { [version] $_ } -ErrorAction Ignore
        if ($PSCompletions.version_list[-1] -ne $PSCompletions.version) {
            $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.module.update))
        }
        else {
            Remove-Item $PSCompletions.path.module_update -Force -ErrorAction SilentlyContinue
        }
    }
    else {
        Remove-Item $PSCompletions.path.module_update -Force -ErrorAction SilentlyContinue
    }
}
else {
    if ($PSCompletions.update -or $PSCompletions.change) {
        $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.update_info))
    }
    if ($PSCompletions.change) {
        Clear-Content $PSCompletions.path.change -Force -ErrorAction SilentlyContinue
    }
}