PSCompletions.psm1

function PSCompletions {
    try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { }

    $arg = $args

    # Must live in the psm1 (module session state): Invoke-Expression can't reach module variables from the ScriptsToProcess scope
    function _replace {
        param ($data, $separator = '')
        $data = [string]::Join($separator, @($data))
        if ($data -notlike '*{{*') { return $data }
        $matches = [regex]::Matches($data, $PSCompletions.replace_pattern)
        foreach ($match in $matches) {
            $data = $data.Replace($match.Value, [string]::Join($separator, @(Invoke-Expression $match.Groups[1].Value)))
        }
        if ($data -match $PSCompletions.replace_pattern) { _replace $data }else { return $data }
    }
    function _param_err {
        param($flag, $cmd, $err_info = $PSCompletions.info.$cmd.err.$flag)
        $err = if ($flag -eq 'min') { $PSCompletions.info.param_min }
        elseif ($flag -eq 'max') { $PSCompletions.info.param_max }
        else { $PSCompletions.info.param_err }
        $PSCompletions.write_with_color((_replace $err))
        if ($err_info) { $PSCompletions.write_with_color((_replace $err_info)) }
    }
    function _help {
        $PSCompletions.write_with_color((_replace $PSCompletions.info.description))
    }
    # Forward management commands to the psc CLI (platform-agnostic core); with -Json request structured output
    function _forward_psc {
        param([switch]$Json)
        $pscBinary = $PSCompletions.psc_binary()
        if (!$pscBinary) {
            $PSCompletions.write_with_color('[PSCompletions] psc binary missing.')
            return
        }
        $dataDir = [System.IO.Path]::GetDirectoryName($PSCompletions.path.data)
        # psc emits UTF-8, but PowerShell decodes native output via [Console]::OutputEncoding (GBK on Chinese systems); switch temporarily
        $oldEncoding = [Console]::OutputEncoding
        [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
        try {
            $raw = & $pscBinary --data $dataDir $(if ($Json) { '--json' }) @($arg) 2>&1
            $exit = $LASTEXITCODE
        }
        finally {
            [Console]::OutputEncoding = $oldEncoding
        }
        $text = ($raw | ForEach-Object {
                if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.Exception.Message } else { [string]$_ }
            }) -join "`n"
        # Structured mode: when the CLI emits JSON, return it as-is (per-item results carry their own ok/error)
        if ($Json -and $text.Trim()) {
            try { return ($text | ConvertFrom-Json) } catch { }
        }
        # Render binary errors uniformly in red and stop
        if ($exit -ne 0) {
            if ($text.Trim()) {
                $PSCompletions.write_with_color((_replace "<@Red>$($text.Trim())"))
            }
            return
        }
        if (-not $Json) { return $raw }
        try {
            return ($text | ConvertFrom-Json)
        }
        catch {
            return $raw
        }
    }
    # Render in the original show_list table format (Completion / Alias columns).
    function _render_list {
        param($rows)
        foreach ($row in $rows) {
            [pscustomobject]@{ Completion = $row.completion; Alias = $row.aliases }
        }
    }
    # Render info.<kind>.done (add/update/rm); its {{ }} templates read $completion/$config/$json/$conflict_alias via dynamic scope
    function _render_completion_done {
        param([string]$completion, [string]$kind)
        $completion_dir = [System.IO.Path]::Combine($PSCompletions.path.completions, $completion)
        $config = $null
        $json = $null
        $conflict_alias = @()
        if ($kind -ne 'rm') {
            $config = $PSCompletions.get_raw_content("$completion_dir/config.json") | ConvertFrom-Json
            $language = $PSCompletions.get_language($completion)
            $json = $PSCompletions.ConvertFrom_JsonAsHashtable($PSCompletions.get_raw_content("$completion_dir/language/$language.json"))
            foreach ($a in $PSCompletions.data.alias[$completion]) {
                if ($PSCompletions.data.aliasMap[$a] -and $PSCompletions.data.aliasMap[$a] -ne $completion) {
                    $conflict_alias += $a
                }
            }
        }
        $PSCompletions.write_with_color((_replace $PSCompletions.info.$kind.done))
    }
    $need_init = $true
    switch ($arg[0]) {
        'list' { _render_list (_forward_psc -Json); $need_init = $false }
        'add' {
            $targets = @()
            if ($arg -contains '--all') {
                $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.add.all_confirm))
                while (($PressKey = $host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')).VirtualKeyCode) {
                    # Ignore keys with any modifier (Alt/Ctrl/Shift bits of ControlKeyStates):
                    # only a bare key decides. NumLock/CapsLock/ScrollLock bits are excluded.
                    if (-not ($PressKey.ControlKeyState -band 0x1F)) {
                        if ($PressKey.VirtualKeyCode -eq 13) { $targets = @($PSCompletions.list) }
                        else { $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.confirm_cancel)) }
                        break
                    }
                }
            }
            else { $targets = @($arg[1..($arg.Count - 1)]) }
            if ($targets.Count) {
                $is_exist_before = @{}
                foreach ($t in $targets) { if ($t) { $is_exist_before[$t] = [System.IO.Directory]::Exists([System.IO.Path]::Combine($PSCompletions.path.completions, $t)) } }
                $result = _forward_psc -Json
                if ($null -ne $result) {
                    $PSCompletions.init_data()
                    foreach ($r in @($result)) {
                        if ($r.ok) {
                            _render_completion_done $r.completion $(if ($is_exist_before[$r.completion]) { 'update' } else { 'add' })
                        }
                        else {
                            $PSCompletions.write_with_color((_replace "<@Red>$($r.completion): $($r.error)"))
                        }
                    }
                }
            }
            $need_init = $false
        }
        'rm' {
            $targets = @()
            if ($arg -contains '--all') {
                $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.rm.all_confirm))
                while (($PressKey = $host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')).VirtualKeyCode) {
                    # Ignore keys with any modifier (Alt/Ctrl/Shift bits of ControlKeyStates):
                    # only a bare key decides. NumLock/CapsLock/ScrollLock bits are excluded.
                    if (-not ($PressKey.ControlKeyState -band 0x1F)) {
                        if ($PressKey.VirtualKeyCode -eq 13) { $targets = @($PSCompletions.data.list) }
                        else { $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.confirm_cancel)) }
                        break
                    }
                }
            }
            else { $targets = @($arg[1..($arg.Count - 1)]) }
            if ($targets.Count) {
                $result = _forward_psc -Json
                if ($null -ne $result) {
                    $PSCompletions.init_data()
                    foreach ($r in @($result)) {
                        if ($r.ok) {
                            _render_completion_done $r.completion 'rm'
                        }
                        else {
                            $PSCompletions.write_with_color((_replace "<@Red>$($r.completion): $($r.error)"))
                        }
                    }
                }
            }
            $need_init = $false
        }
        'update' {
            $targets = @()
            if ($arg -contains '--all') {
                # --all = force-update every installed completion; only render feedback for completions in the remote repo
                $remote = @()
                try { $remote = @(($PSCompletions.get_raw_content($PSCompletions.path.completions_json) | ConvertFrom-Json).update.PSObject.Properties.Name) } catch { }
                $targets = @($PSCompletions.data.list | Where-Object { $_ -in $remote })
            }
            elseif ($arg -contains '--old') {
                # --old = update only the out-of-date completions
                $targets = @($PSCompletions.update)
            }
            elseif ($arg.Count -gt 1) { $targets = @($arg[1..($arg.Count - 1)] | Where-Object { $_ -notin '--all', '--old' }) }
            # Plain `psc update` (no targets) = live check; the CLI refreshes update.txt/change.txt
            $isNoArg = $arg.Count -eq 1
            _forward_psc | Out-Null
            if ($isNoArg) {
                # no-arg live check: CLI refreshed update.txt/change.txt; render update_info if there's content, else a one-line reply
                $PSCompletions.update = $PSCompletions.get_content($PSCompletions.path.update)
                $PSCompletions.change = $PSCompletions.get_content($PSCompletions.path.change)
                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 }
                }
                else {
                    $PSCompletions.write_with_color((_replace $PSCompletions.info.update.no))
                }
            }
            elseif ($LASTEXITCODE -eq 0) {
                if (-not $targets.Count) {
                    # --old / --all but nothing to update: reply with a message rather than staying silent
                    $PSCompletions.write_with_color((_replace $PSCompletions.info.update.no))
                }
                else {
                    $PSCompletions.init_data()
                    foreach ($completion in $targets) {
                        if ($completion -in $PSCompletions.data.list) {
                            # Locally-linked completion: update skips it
                            $isLink = $false
                            try { $isLink = $null -ne (Get-Item (Join-Path $PSCompletions.path.completions $completion) -Force).LinkType } catch { }
                            if ($isLink) {
                                $PSCompletions.write_with_color((_replace $PSCompletions.info.update.skip))
                            }
                            else {
                                _render_completion_done $completion 'update'
                            }
                        }
                    }
                }
            }
            $need_init = $false
        }
        'info' { _forward_psc -Json | ForEach-Object { [pscustomobject]@{ Name = $_.name; Alias = $_.alias; Url = $_.url; Description = $_.description; Path = $_.path; Update = $_.update; Updated = if ($null -ne $_.updated) { [DateTimeOffset]::FromUnixTimeSeconds([int64]$_.updated).LocalDateTime } } }; $need_init = $false }
        'alias' {
            # `alias add` pre-check: an alias colliding with a real command is rejected before forwarding
            $alias_conflict = $false
            if ($arg[1] -eq 'add' -and $arg.Count -ge 3) {
                foreach ($a in $arg[2..($arg.Count - 1)]) {
                    if ($a -ne $arg[1] -and (Get-Command $a -ErrorAction Ignore)) {
                        $alias = $a
                        $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.alias.add.err.cmd_exist))
                        $alias_conflict = $true
                    }
                }
            }
            if (-not $alias_conflict) {
                if ($arg.Count -eq 1) {
                    # No args = list all trigger aliases, wrapped as objects like list
                    _forward_psc -Json | ForEach-Object { [pscustomobject]@{ Completion = $_.completion; Alias = $_.aliases } }
                }
                else { _forward_psc }
            }
        }
        'config' {
            # Grouped syntax: config | config <group> | config <group> <key> | config <group> <key> <value>
            if ($arg -contains '--reset') {
                _forward_psc
                if ($LASTEXITCODE -eq 0 -and $arg[1] -eq 'menu' -and $arg[2] -eq 'trigger_key') {
                    $PSCompletions.init_data()
                    $PSCompletions.handle_completion()
                    $need_init = $false
                }
            }
            elseif ($arg.Count -le 2) { _forward_psc -Json | ForEach-Object { [pscustomobject]@{ Key = $_.key; Value = $_.value } } }
            elseif ($arg.Count -eq 3) { _forward_psc -Json | ForEach-Object { $_.value } }
            else {
                # trigger_key host side-effect: rebind PSReadLine first, persist only on success
                if ($arg[1] -eq 'menu' -and $arg[2] -eq 'trigger_key') {
                    $oldKey = $PSCompletions.config.trigger_key
                    try {
                        Remove-PSReadLineKeyHandler $oldKey
                        Set-PSReadLineKeyHandler -Key $arg[3] -ScriptBlock $PSCompletions.menu.module_completion_menu_script
                    }
                    catch {
                        # Rebind failed: restore the removed old trigger key to avoid "old key dead and new key not active"
                        try {
                            Set-PSReadLineKeyHandler -Key $oldKey -ScriptBlock $PSCompletions.menu.module_completion_menu_script
                        }
                        catch { }
                        _param_err 'err' 'trigger_key' $PSCompletions.info.menu.config.err.trigger_key
                        $need_init = $false
                        break
                    }
                }
                _forward_psc
                if ($LASTEXITCODE -eq 0 -and $arg[1] -eq 'menu' -and $arg[2] -eq 'trigger_key') {
                    $PSCompletions.config.trigger_key = $arg[3]
                    $PSCompletions.handle_completion()
                }
            }
        }
        'completion' {
            if ($arg -contains '--reset') { _forward_psc }
            elseif ($arg.Count -eq 3) { (_forward_psc -Json) | ForEach-Object { $_.value } }
            elseif ($arg.Count -le 2) {
                # No args / <name>: list special config, wrapped as objects
                _forward_psc -Json | ForEach-Object { [pscustomobject]@{ Completion = $_.completion; Config = $_.config } }
            }
            else { _forward_psc }
        }
        '--reset' {
            $need_init = $false
            # After interactive confirmation, clear the module's data directory and re-initialize
            $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.reset.init_confirm))
            while (($PressKey = $host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')).VirtualKeyCode) {
                # Ignore keys with any modifier (Alt/Ctrl/Shift bits of ControlKeyStates):
                # only a bare key decides. NumLock/CapsLock/ScrollLock bits are excluded.
                if (-not ($PressKey.ControlKeyState -band 0x1F)) {
                    if ($PressKey.VirtualKeyCode -eq 13) {
                        Get-ChildItem ($PSCompletions.path.root + '/data') | ForEach-Object { Remove-Item $_.FullName -Force -Recurse }
                        $PSCompletions.write_with_color((_replace $PSCompletions.info.reset.init_done))
                        $PSCompletions.ensure_dir($PSCompletions.path.completions)
                        $PSCompletions.init_data()
                    }
                    else {
                        $PSCompletions.write_with_color($PSCompletions.replace_content($PSCompletions.info.confirm_cancel))
                    }
                    break
                }
            }
        }
        default {
            $need_init = $false
            _help
        }
    }
    if ($need_init) { $PSCompletions.init_data() }
}

Export-ModuleMember -Function PSCompletions