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.
.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)

  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
      }
    }
  }

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

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

  $CacheCommands.Remove($Command)
  $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.
.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`
.INPUTS
  None.
.OUTPUTS
  None.
#>

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

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

  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]

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