PSBashCompletions.psm1

<#
.SYNOPSIS
  Registers command line completions from bash into PowerShell.
 
.DESCRIPTION
  Registers a command line completion that runs in bash so it can be brought into PowerShell. This
  is helpful for some commands like "kubectl" that have bash completions supported but where there
  is no built-in support for PowerShell.
 
  The command assumes you either have bash in your path or Git for Windows installed. If you don't
  have bash in the path then the version packaged with Git for Windows will be used.
 
  If you aren't getting completions, check the following:
 
  - Run with the -Verbose flag to see what the completer is finding.
  - Try manually running the completion command that -Verbose outputs using a sample command line.
  - Make sure bash is in your path or that you have Git for Windows installed so bash.exe can be found.
 
.PARAMETER Command
  The name of the command in bash that needs completions in PowerShell (e.g., kubectl). This is what
  PowerShell will get completions on.
 
.PARAMETER BashCompletions
  The full path to the bash completion script that generates completions for the command. You can usually
  download this or export it from the command itself.
 
.EXAMPLE
  This example shows how to use the argument completer with kubectl.
 
  First, export the bash completions from the command:
 
  kubectl completion bash > C:\completions\kubectl_completions.sh
 
  Then register your completion with PowerShell:
 
  Register-BashArgumentCompleter kubectl C:\completions\kubectl_completions.sh
 
.EXAMPLE
  This example shows how to troubleshoot completions using a manual bash command.
 
  First, register the completion with PowerShell and use -Verbose:
 
  Register-BashArgumentCompleter kubectl C:\completions\kubectl_completions.sh -Verbose
 
  This will output something like the following:
 
  VERBOSE: bash is not in the path.
  VERBOSE: Found bash packaged with git.
  VERBOSE: bash = C:\Program Files\Git\bin\bash.exe
  VERBOSE: Starting command completion registration for kubectl
  VERBOSE: Completion bridge = /c/Users/username/Documents/WindowsPowerShell/Modules/PSBashCompletions/1.0.0/bash_completion_bridge.sh
  VERBOSE: Bash completions for kubectl = /c/completions/kubectl_completions.sh
  VERBOSE: Completion command = &"C:\Program Files\Git\bin\bash.exe" "/c/Users/username/Documents/WindowsPowerShell/Modules/PSBashCompletions/1.0.0/bash_completion_bridge.sh" "/c/completions/kubectl_completions.sh" "<url-encoded-command-line>"
 
  The last line, the completion command, is the interesting bit.
 
  Create a URL-encoded version of the thing you want to complete. For example, this command line:
 
  kubectl c
 
  That shows you want to complete all the "c" commands for kubectl. URL encode that and use %20 for spaces:
 
  kubectl%20c
 
  Now run the completion command with your completion line:
 
  &"C:\Program Files\Git\bin\bash.exe" "/c/Users/username/Documents/WindowsPowerShell/Modules/PSBashCompletions/1.0.0/bash_completion_bridge.sh" "/c/completions/kubectl_completions.sh" "kubectl%20c"
 
  This should generate the list of completions, like:
 
  certificate
  cluster-info
  completion
  config
  convert
  cordon
  cp
  create
 
  If instead you see an error, that's what you need to troubleshoot.
#>

function Register-BashArgumentCompleter {
  [CmdletBinding()]
  Param(
    [Parameter(Mandatory=$True, Position=0)]
    [ValidateNotNullOrEmpty()]
    [string]
    $Command,

    [Parameter(Mandatory=$True, Position=1)]
    [ValidateScript({if(-Not($_ | Test-Path -PathType Leaf)){ throw "The completion file was not found." } return $true})]
    [ValidateNotNullOrEmpty()]
    [string]
    $BashCompletions
  )

  # Locate bash
  $bash = Get-Command bash -ErrorAction Ignore
  if($Null -eq $bash) {
    Write-Verbose "bash is not in the path."

    # Try for bash packaged with Git for Windows
    $git = Get-Command git -ErrorAction Ignore
    if($Null -eq $git) {
      Write-Error "Unable to locate bash."
      Exit 1
    }

    $bash = [System.IO.Path]::Combine([System.IO.DirectoryInfo]::new([System.IO.Path]::GetDirectoryName((Get-command git).Source)).Parent.FullName, "bin", "bash.exe")
    if(-not (Test-Path $bash)) {
      Write-Error "Unable to locate bash."
      Exit 1
    }

    Write-Verbose "Found bash packaged with git."
  } else {
    Write-Verbose "Found bash in path."
    $bash = $bash.Source
  }

  Write-Verbose "bash = $bash"

  # Determine drive letter mount point
  # this assumes you have a drive C and looks for either /mnt/c or just /c to find where it is mounted in bash
  # the resulting string should be either / or /mnt/
  $mountPath = (&"$bash" -c "mount | grep -e '^C:\\\\\\?[[:space:]]' | cut -f3 -d\ ") -replace "/c", "/"

  Write-Verbose "Starting command completion registration for $Command"
  $bashBridgeScriptPath = Resolve-Path -Path "$PSScriptRoot\bash_completion_bridge.sh"
  $driveLetter = $bashBridgeScriptPath.Drive.Name.ToLowerInvariant()
  $driveLetterMountPoint = "$mountPath$driveLetter"
  $bashBridgeScript = $bashBridgeScriptPath.Path -Replace '^([A-Z]:)',$driveLetterMountPoint -Replace '\\','/'
  Write-Verbose "Completion bridge = $bashBridgeScript"

  $bashCompletionScriptPath = Resolve-Path -Path $BashCompletions
  $driveLetter = $bashCompletionScriptPath.Drive.Name.ToLowerInvariant()
  $driveLetterMountPoint = "$mountPath$driveLetter"
  $bashCompletionScript = $bashCompletionScriptPath.Path -Replace '^([A-Z]:)',$driveLetterMountPoint -Replace '\\', '/'
  $resolvedCommand = Expand-Command $Command
  Write-Verbose "Bash completions for $resolvedCommand = $bashCompletionScript"

  Write-Verbose "Completion command = &`"$bash`" `"$bashBridgeScript`" `"$bashCompletionScript`" `"<url-encoded-command-line>`""

  $block = {
    param($partialWordToComplete, $commandSoFar, $cursorPosition)
    $resolvedCommandSoFar = $commandSoFar -replace "^$Command",$resolvedCommand
    Add-Type -Assembly System.Web
    $encodedCommand = [System.Web.HttpUtility]::UrlEncode($resolvedCommandSoFar).Replace('+', "%20")
    $result = (&"$bash" "$bashBridgeScript" "$bashCompletionScript" "$encodedCommand")

    # CompletionResult https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.completionresult.-ctor?view=powershellsdk-1.1.0#System_Management_Automation_CompletionResult__ctor_System_String_System_String_System_Management_Automation_CompletionResultType_System_String_
    # string - the text used as the auto completion result
    # string - the text to be displayed in a list
    # CompletionResultType https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.completionresulttype?view=powershellsdk-1.1.0
    # string the text for the tooltip with details
    $result | ForEach-Object {
      [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
  }.GetNewClosure()

  Register-ArgumentCompleter -Native -CommandName $Command -ScriptBlock $block
}

# If the command is an alias, this expands it to be the full command
# name. If it's not an alias, it exits untouched.
function Expand-Command {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "", Justification = "False positive - https://github.com/PowerShell/PSScriptAnalyzer/issues/676")]
  [CmdletBinding()]
  Param(
      [Parameter(Mandatory = $True, Position = 0)]
      [ValidateNotNullOrEmpty()]
      [string]
      $Command
  )

  $alias = Get-Alias -Name $Command -ErrorAction Ignore
  if (($null -eq $alias) -or ($null -eq $alias.ResolvedCommandName)) {
    Write-Verbose "$Command is not an alias."
    return $Command
  }

  $resolved = $alias.ResolvedCommandName
  $pathext = $Env:PATHEXT;
  if($null -eq $pathext) {
    Write-Verbose "$Command is an alias for $resolved."
    return $resolved
  }

  foreach ($ext in $pathext.Split(';')) {
    if ($resolved.EndsWith($ext, [System.StringComparison]::OrdinalIgnoreCase)) {
      $resolved = $resolved.Substring(0, $resolved.Length - $ext.Length)
      break
    }
  }

  Write-Verbose "$Command is an alias for $resolved."
  return $resolved
}