Completion.ps1

[hashtable]$CacheAllCompletions = @{}
[hashtable]$CacheCommands = @{}
$PSVersion = $PSVersionTable.PSVersion

<#
.SYNOPSIS
  Convert to hashtable format.
.DESCRIPTION
  Recursive conversion of data to hashtable format. hashtable values will be converted to hashtable.
  e.g.
   @{arg1 = "arg1_1"} -> @{arg1 = @{arg1_1 = ""}}
.PARAMETER InputObject
  Input data, support for basic data types
.EXAMPLE
  ConvertTo-Hash "arg"
  Convert string to hashtable format
.EXAMPLE
  ConvertTo-Hash 100
  Convert number to hashtable format
.EXAMPLE
  ConvertTo-Hash "['hello','world']"
  Convert Javascript array to hashtable format
.EXAMPLE
  ConvertTo-Hash "[{arg: {arg_1: 'arg_1_1'}}]"
  Convert Javascript array object to hashtable format
.EXAMPLE
  ConvertTo-Hash "[{arg: {arg_1: {arg_1_1: ['arg_1_1_1', 'arg_1_1_2']}}}]"
  Convert Javascript nested array object to hashtable format
.EXAMPLE
  ConvertTo-Hash "[100, 'hello', {arg1: 'arg1_1'}, ['arg2', 'arg3']]"
  Convert Javascript array to hashtable format
.EXAMPLE
  ConvertTo-Hash @("arg1", "arg2")
  Convert array to hashtable format
.EXAMPLE
  ConvertTo-Hash @("arg1", @{arg2 = "arg2_1"; arg3 = @("arg3_1", "arg3_2")})
  Convert nested array to hashtable format
.INPUTS
  None.
.OUTPUTS
  System.Collections.Hashtable
#>

function ConvertTo-Hash {
  Param($InputObject)

  if (!$InputObject) {
    return ""
  }

  [hashtable]$hash = @{}
  $inputType = $InputObject.getType()

  if ($inputType -eq [hashtable]) {
    $InputObject.Keys | ForEach-Object { $hash[$_] = ConvertTo-Hash $InputObject[$_] }
  }
  elseif ($inputType -eq [Object[]]) {
    $InputObject | ForEach-Object { $hash += ConvertTo-Hash $_ }
  }
  elseif ($inputType -eq [System.Management.Automation.PSCustomObject]) {
    $InputObject.psobject.Properties |
    ForEach-Object { $hash[$_.Name] = ConvertTo-Hash $_.Value }
  }
  else {
    try {
      if ($PSVersion -lt "7.0") {
        $json = ConvertFrom-Json -InputObject $InputObject
      }
      else {
        $json = ConvertFrom-Json -InputObject $InputObject -AsHashtable
      }
      if ($json.getType() -in [hashtable],[Object[]],[System.Management.Automation.PSCustomObject]) {
        $hash = ConvertTo-Hash $json
      }
      else {
        $hash.Add($json, "")
      }
    }
    catch {
      $hash.Add($InputObject, "")
    }
  }
  return $hash
}

<#
.SYNOPSIS
  Get the completion keys.
.DESCRIPTION
  According to the input word and data, return the corresponding command keys.
  it usually used in the cmdlet `Register-ArgumentCompleter`, when provide datasets, it will return the right completion keys.
.PARAMETER Word
  The input word. From `$wordToComplete`
.PARAMETER Ast
  The input data. From `$commandAst`
.PARAMETER HashList
  The datasets, support basic data types.
.PARAMETER Filter
  The filter function. if provided, it will be used to filter the completion keys.
.EXAMPLE
  Get-CompletionKeys "" "case" "hello","world"
  Returns `hello` and `world`
.EXAMPLE
  Get-CompletionKeys "h" "case h" "hello","world"
  Returns `hello`
.EXAMPLE
  Get-CompletionKeys "" "case h" "hello","world"
  Returns None.
.INPUTS
  None.
.OUTPUTS
  System.Array
#>

function Get-CompletionKeys {
  Param([string]$Word, $Ast, $HashList, [ScriptBlock]$Filter)

  if (!$HashList) {
    return @()
  }

  $arr = $Ast.ToString().Split().ToLower() | Where-Object { $null -ne $_ }

  # Empty, need to return children completion keys
  if (!$Word) {
    [string]$key = ($arr -join ".").trim(".")
    $keyLevel = $arr
  }
  # Character, need to return sibling completion keys
  else {
    [string]$key = (($arr | Select-Object -SkipLast 1) -join ".").trim(".")
    $keyLevel = $key | ForEach-Object { $_.split(".") }
  }

  if (!$CacheAllCompletions.ContainsKey($key)) {
    $map = ConvertTo-Hash $HashList
    $prefix = ""
    $keyLevel | ForEach-Object {
      if ($prefix) {
        $map = $map[$_]
        $prefix = "$prefix.$($_)"
      }
      else {
        $prefix = $_
      }
      if (!$CacheAllCompletions.ContainsKey($prefix)) {
        $CacheAllCompletions[$prefix] = $map.Keys
      }
    }
  }

  if ($Filter -is [scriptblock]) {
    & $Filter $CacheAllCompletions[$key] $Word
  }
  else {
    $CacheAllCompletions[$key] |
    Where-Object { $_ -Like "*$Word*" } |
    Sort-Object -Property @{Expression = { $_.ToString().StartsWith($Word) }; Descending = $true },
                          @{Expression = { $_.ToString().indexOf($Word) }; Descending = $false },
                          @{Expression = { $_.ToString().StartsWith('-') }; Descending = $false },
                          @{Expression = { $_ }; Descending = $false }
  }
}

function Remove-Completion {
  Param([string]$Command)

  $CacheCommands.Remove($Command)
  $CacheCommands.Remove("$Command--filter")
  $CacheAllCompletions.Clone().Keys |
    Where-Object { $_.StartsWith("$Command.") -or ($_ -eq $Command) } |
    ForEach-Object { $CacheAllCompletions.Remove($_) }
}

<#
.SYNOPSIS
  Register a completion.
.DESCRIPTION
  Register a completion. provide the command name and the completion datasets. when type the command name, and press `Tab`, it will show the completion keys.
.PARAMETER Command
  The command name.
.PARAMETER HashList
  The datasets, support basic data types.
.PARAMETER Force
  Enable replaced the existing completion. default is false.
.PARAMETER Filter
  The filter function. if provided, it will be used to filter the completion keys.
  The function will be called with two parameters: $Keys and $Word, and the return value is the filtered and sorted keys.
.EXAMPLE
  New-Completion demo "hello","world"
  Register a completion with command name `demo` and datasets `hello`、`world`.
  Press `demo <Tab>` will get `demo hello`
.EXAMPLE
  New-Completion demo "100" -Force
  Replace the existing completion with command name `demo` and datasets `100`.
  Press `demo <Tab>` will get `demo 100`
.EXAMPLE
  $cmds = "{
    'access': ['public', { grant: ['read-only', 'read-write'] }, 'revoke', 'edit', '--help'],
    '--help': ''
  }"
  New-Completion nc $cmds -filter {
    Param($Keys, $Word)
    $Keys | Where-Object { $_ -Like "*$Word*" } | Sort-Object -Descending
  }
  Replace the default filter function, and will returns the filtered completion keys with provided fitler function.
.INPUTS
  None.
.OUTPUTS
  None.
#>

function New-Completion {
  Param(
    [string]$Command,
    $HashList,
    [switch]$Force = $false,
    [ScriptBlock]$Filter
  )

  if ($CacheCommands.ContainsKey($Command)) {
    if ($Force) {
      Remove-Completion $Command
    }
    else {
      return
    }
  }
  $CacheCommands.Add($Command, $HashList)
  $CacheCommands.Add("$Command--filter", $Filter)

  Register-ArgumentCompleter -Native -CommandName $Command -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    [Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = [System.Text.Utf8Encoding]::new()

    $cmd = $commandAst.CommandElements[0].Value
    $cmdHashList = $CacheCommands[$cmd]
    $cmdFilter = $CacheCommands["$cmd--filter"]

    if ($null -ne $cmdHashList) {
      Get-CompletionKeys $wordToComplete $commandAst $cmdHashList $cmdFilter |
      ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
    }
  }
}