PSCompletions.psm1
|
function PSCompletions { try { Microsoft.PowerShell.Core\Set-StrictMode -Off } catch { } $PSCompletions.initialize() if (-not $PSCompletions.binary_ok) { return } $arg = $args # `--json` anywhere in the invocation is the machine contract: hand it to the engine as it is # and print what it says — no rendering, no color, and no prompt (design/json.md §7). With no # command word it is the **root** response (§5), not the info page; the shell bridges do the # same, so a caller gets parseable JSON from every host. if ($arg -contains '--json') { $pscBinary = $PSCompletions.psc_binary() if ($pscBinary) { $oldEncoding = [Console]::OutputEncoding [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) try { & $pscBinary --data $PSCompletions.path.dir.data @($arg) } finally { [Console]::OutputEncoding = $oldEncoding } } return } # Must live in the psm1 (module session state): the renderer is pure, but the # call must resolve here so future host-specific lookups keep working. function _replace { param ($data, $ctx = @{}, $separator = '') $PSCompletions.render_engine($data, $ctx, $separator) } function _get_library_changes { $json = $PSCompletions.get_raw_content($PSCompletions.path.file.change) if ($json) { try { return $PSCompletions.ConvertFrom_JsonAsHashtable($json) } catch { } } return $null } function _param_err { param($flag, $cmd, $err_info = $PSCompletions.info.$cmd.err.$flag) $err = if ($flag -eq 'min') { $PSCompletions.info.too_few_params } elseif ($flag -eq 'max') { $PSCompletions.info.too_many_params } else { $PSCompletions.info.param_err } $PSCompletions.write_with_color((_replace $err)) if ($err_info) { $PSCompletions.write_with_color((_replace $err_info @{Rejected = if ($arg.Count -gt 3) { [string]$arg[3] } else { '' } })) } } function _help { $PSCompletions.write_with_color((_replace $PSCompletions.info.description @{Version = [string]$PSCompletions.version })) } # Forward management commands to the psc CLI (platform-agnostic core); with -Json request # structured output, with -Plain print a read uncolored (a status message takes green). function _forward_psc { param([switch]$Json, [switch]$Quiet, [switch]$Plain) $pscBinary = $PSCompletions.psc_binary() if (!$pscBinary) { $PSCompletions.write_with_color('[PSCompletions] psc binary missing.') return } $dataDir = $PSCompletions.path.dir.data # The engine never prompts: it refuses a destructive operation unless it is authorised # (design/json.md §7). The host is the one that asks the human, so it supplies the # authorisation here — the single place the module calls the engine. `rm --all` has # already been confirmed by the case that called this; a group `--reset` is not # destructive enough to ask about, so the host simply says yes. $destructive = ($arg -contains '--reset') -or (($arg | Select-Object -First 1) -eq 'rm' -and ($arg -contains '--all')) $authorised = if ($destructive) { @('--yes') } else { @() } # 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) @($authorised) 2>&1 $exit = $LASTEXITCODE } finally { [Console]::OutputEncoding = $oldEncoding } $text = ($raw | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.Exception.Message } else { [string]$_ } }) -join "`n" # Render binary errors uniformly in red and stop — check exit before Json if ($exit -ne 0) { if ($Json) { # A partial failure (`ok:false` per item) is not a whole-command error: hand the # payload back so the caller renders the items. Only an envelope carrying an # `error` is shown as one red line. try { $parsed = $text | ConvertFrom-Json if ($null -ne $parsed.data) { return $parsed.data } if ($parsed.error.message) { $PSCompletions.write_with_color((_replace "<@Red>$($parsed.error.message)")) return } } catch { } } if ($text.Trim()) { $PSCompletions.write_with_color((_replace "<@Red>$($text.Trim())")) } return } if ($Json -and $text.Trim()) { try { # The envelope's payload: `data` is what this command's caller renders. return ($text | ConvertFrom-Json).data } catch { return @{ ok = $false; error = $_.Exception.Message } } } if (-not $Json) { if (-not $Quiet) { # A data read prints the CLI's text as it is; a status message is a message and # takes green. The failure path above stays red either way. if ($Plain) { $raw -join "`n" } else { $PSCompletions.write_with_color((_replace "<@Green>$($raw -join "`n")")) } } return } try { return $text | ConvertFrom-Json } catch { if ($Json) { return @{ ok = $false; error = $_.Exception.Message } } return $raw } } # Build the add/update done-template context: trigger aliases plus the # manifest config rows (with stored current values) for [Special Configuration]. function _done_ctx { param([string]$completion, [string]$display = '') $alias = @($PSCompletions.data.alias[$completion]) if (-not $alias) { $alias = @($completion) } $rows = @() $completion_dir = [System.IO.Path]::Combine($PSCompletions.path.dir.completions, $completion) try { $cfgjson = $PSCompletions.get_raw_content("$completion_dir/config.json") | ConvertFrom-Json } catch { $cfgjson = $null } try { $clang = $PSCompletions.get_language($completion) $man = $PSCompletions.ConvertFrom_JsonAsHashtable($PSCompletions.get_raw_content("$completion_dir/language/$clang.json")) } catch { $man = $null } if ($cfgjson -and $null -ne $cfgjson.hooks) { $rows += @{ Name = 'enable_hooks'; Value = [int][bool]$cfgjson.hooks; Tip = (($PSCompletions.info.completion.enable_hooks.tip) -join "`n") } } foreach ($item in @($man.config)) { if ($item.name) { $rows += @{ Name = [string]$item.name; Value = $item.value; Tip = ($item.tip -join "`n") } } } $stored = @{} if ($PSCompletions.config.completion) { $stored = $PSCompletions.config.completion[$completion] } foreach ($row in $rows) { $cur = $null if ($stored) { if ($stored -is [hashtable] -and $stored.ContainsKey($row.name)) { $cur = $stored[$row.name] } elseif ($null -ne $stored.PSObject.Properties[$row.name]) { $cur = $stored.PSObject.Properties[$row.name].Value } } if ($null -ne $cur) { $row.Current = $cur } } @{ Completion = $completion; Display = $display; Alias = $alias; Configs = $rows } } # Render info.<kind>.done; templates read $completion/$display/etc via dynamic scope. # $display overrides the name line (e.g. "git -> git1" for a rename); aliases read local data.alias. function _render_completion_done { param([string]$completion, [string]$kind, [string]$display = '') if ($kind -in @('add', 'update')) { # Each of these renders a multi-line block, so consecutive blocks are separated by a # blank line — the same gap the templates put between their own repeated items # (design/templates.md). `rm`'s done is one line per completion and stays tight. if ($script:_done_block) { $PSCompletions.write_with_color('') } $PSCompletions.write_with_color((_replace $PSCompletions.info.$kind.done (_done_ctx $completion $display))) $script:_done_block = $true return } $PSCompletions.write_with_color((_replace $PSCompletions.info.$kind.done @{ Completion = $completion })) } $need_init = $true switch ($arg[0]) { 'list' { _forward_psc -Plain; $need_init = $false } 'add' { $targets = @($arg[1..($arg.Count - 1)]) if ($targets.Count) { # The payload names its collection; each item carries its name and a stable code. $rows = @((_forward_psc -Json).completions) if ($rows.Count) { $PSCompletions.init_data() $script:_done_block = $false foreach ($r in $rows) { if ($r.ok) { _render_completion_done $r.name 'add' } else { $PSCompletions.write_with_color((_replace "<@Red>$(if ($r.name) { "$($r.name): " })$($r.error.message)")) } } } } $need_init = $false } 'rm' { $targets = @() if ($arg -contains '--all') { $PSCompletions.write_with_color((_replace $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((_replace $PSCompletions.info.confirm_cancel)) } break } } } else { $targets = @($arg[1..($arg.Count - 1)]) } if ($targets.Count) { # The payload names its collection; each item carries its name and a stable code. $rows = @((_forward_psc -Json).completions) if ($rows.Count) { $PSCompletions.init_data() foreach ($r in $rows) { if ($r.ok) { _render_completion_done $r.name 'rm' } else { $PSCompletions.write_with_color((_replace "<@Red>$(if ($r.name) { "$($r.name): " })$($r.error.message)")) } } } } $need_init = $false } 'update' { # Plain `psc update` (no targets) = live check; the CLI refreshes change.json. # --all / --old / named update: the CLI returns per-completion JSON results. $isNoArg = $arg.Count -eq 1 if ($isNoArg) { # no-arg live check: render change.json, else a one-line reply. # update/renamed persist until `psc update --old`; added/removed are one-shot. _forward_psc -Quiet | Out-Null $changes = _get_library_changes $upd = @($changes.update) $add = @($changes.added) $rm = @($changes.removed) $renamed = @($changes.renamed) if ($upd -or $add -or $rm -or $renamed) { $PSCompletions.update = $upd $PSCompletions.added = $add $PSCompletions.removed = $rm $PSCompletions.renamed = $renamed $PSCompletions.write_with_color((_replace $PSCompletions.info.update_info @{ Added = @($add); Removed = @($rm); Update = @($upd); Renamed = @($renamed) })) $changes.added = @() $changes.removed = @() try { [System.IO.File]::WriteAllText( $PSCompletions.path.file.change, ($changes | ConvertTo-Json -Depth 6 -Compress), [System.Text.Encoding]::UTF8) } catch { } } else { $PSCompletions.write_with_color((_replace $PSCompletions.info.update.no)) } # This command already rendered the library changes; skip them in the pending tail. $PSCompletions.pending_skip_library = $true } else { # --all / --old / named update: the payload names its per-completion collection. $rows = @((_forward_psc -Json).completions) if (-not $rows.Count) { $PSCompletions.write_with_color((_replace $PSCompletions.info.update.no)) } else { $PSCompletions.init_data() $anyOk = $false $script:_done_block = $false $migratedRenamed = @() foreach ($r in $rows) { if ($r.ok) { $anyOk = $true if ($r.renamed_from) { $migratedRenamed += , @($r.renamed_from, $r.name) # Migrated rename: display "old -> new", resolve config/alias from the real name. _render_completion_done $r.name 'update' "$($r.renamed_from) -> $($r.name)" } else { _render_completion_done $r.name 'update' } } else { $PSCompletions.write_with_color((_replace "<@Red>$(if ($r.name) { "$($r.name): " })$($r.error.message)")) } } if (-not $anyOk -and -not $rows.Count) { $PSCompletions.write_with_color((_replace $PSCompletions.info.update.no)) } # Consume only the renames actually migrated this run (from the CLI results); # unmigrated renames (e.g. a different renamed completion) stay for later prompts. if ($migratedRenamed.Count) { $changes = _get_library_changes if ($changes) { $changes.renamed = @(@($changes.renamed) | Where-Object { $pair = @($_) $hit = $false foreach ($m in $migratedRenamed) { if ($pair[0] -eq $m[0] -and $pair[1] -eq $m[1]) { $hit = $true; break } } -not $hit }) try { [System.IO.File]::WriteAllText( $PSCompletions.path.file.change, ($changes | ConvertTo-Json -Depth 6 -Compress), [System.Text.Encoding]::UTF8) } catch { } } } } } $need_init = $false } 'info' { # A read is the CLI's text, uncolored — the same thing the POSIX bridges print. Data # leaves through `--json`; the human view is one shape everywhere (bridge.md §7). _forward_psc -Plain $need_init = $false } 'alias' { # A read is the CLI's text; `add` / `rm` / `--reset` stay on the action path so the # init refresh and the pending tail still run. if ($arg.Count -eq 1) { _forward_psc -Plain; $need_init = $false } 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() Set-PSReadLineKeyHandler -Key $PSCompletions.config.trigger_key -ScriptBlock $PSCompletions.menu.script -BriefDescription 'PSCompletionsMenuComplete' -Description 'Open the completion menu provided by PSCompletions.' $need_init = $false } } elseif ($arg.Count -le 3) { # Every read is the CLI's text: a group listing, or the bare value of one key. _forward_psc -Plain $need_init = $false } 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.script -BriefDescription 'PSCompletionsMenuComplete' -Description 'Open the completion menu provided by PSCompletions.' } 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.script -BriefDescription 'PSCompletionsMenuComplete' -Description 'Open the completion menu provided by PSCompletions.' } 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] Set-PSReadLineKeyHandler -Key $arg[3] -ScriptBlock $PSCompletions.menu.script -BriefDescription 'PSCompletionsMenuComplete' -Description 'Open the completion menu provided by PSCompletions.' } } } 'completion' { # Reads print the CLI's text; `--reset` and a set stay on the action path. if ($arg -contains '--reset' -or $arg.Count -gt 3) { _forward_psc } else { _forward_psc -Plain; $need_init = $false } } '--self-update' { # A root-level flag, not a command: the engine detects how this copy was installed and # says what updates it. `-Plain` because the text is a read, like `info`. _forward_psc -Plain $need_init = $false } '--reset' { $need_init = $false # After interactive confirmation, clear the module's data directory and re-initialize $PSCompletions.write_with_color((_replace $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) { if ([System.IO.Directory]::Exists($PSCompletions.path.dir.data)) { Get-ChildItem -LiteralPath $PSCompletions.path.dir.data -Force | ForEach-Object { Remove-Item $_.FullName -Force -Recurse } } $PSCompletions.write_with_color((_replace $PSCompletions.info.reset.init_done)) $PSCompletions.ensure_dir($PSCompletions.path.dir.completions) $seedTarget = [System.IO.Path]::Combine($PSCompletions.path.dir.completions, 'psc') foreach ($seed in @( [System.IO.Path]::Combine($PSCompletions.path.dir.root, 'completions', 'psc'), [System.IO.Path]::Combine((Split-Path (Split-Path $PSCompletions.path.dir.root -Parent) -Parent), 'completions', 'psc') )) { if ([System.IO.File]::Exists([System.IO.Path]::Combine($seed, 'config.json')) -and [System.IO.File]::Exists([System.IO.Path]::Combine($seed, 'language', 'en-US.json'))) { Copy-Item -LiteralPath $seed -Destination $seedTarget -Recurse -Force break } } $PSCompletions.init_data() } else { $PSCompletions.write_with_color((_replace $PSCompletions.info.confirm_cancel)) } break } } } default { $need_init = $false _help } } if ($need_init) { $PSCompletions.init_data() } # Append pending module/library notifications after the command's own output. $PSCompletions.render_pending() } Set-Alias psc PSCompletions -Force Export-ModuleMember -Function PSCompletions -Alias psc |