Skip to content

Instantly share code, notes, and snippets.

@tygerbytes
Last active June 19, 2021 07:13
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tygerbytes/ca27e400f6b4b2eed6a9eef1ba0a76bd to your computer and use it in GitHub Desktop.
Save tygerbytes/ca27e400f6b4b2eed6a9eef1ba0a76bd to your computer and use it in GitHub Desktop.
Register tab completion for custom commands that change depending on the current directory.
param(
[Parameter(Mandatory=$true)]
[String[]]$commandsToComplete)
<#
.SYNOPSIS
Register tab completion for custom commands that change depending on the current directory.
Source: https://gist.github.com/tygerbytes/833a73fe77f1f426f1f094c6ae7b5b2c
Author: Ty Walls (https://twitter.com/tygertec)
Website: https://www.tygertec.com
.DESCRIPTION
Many projects have CI/CD commands like build, ci, make, test, etc.
Each of these commands has their own set of possible arguments, often dozens of them,
and their usage and interface can vary from between projects and certainly between organizations.
The idea here is to place a JSON file called ".argument-completer-registry.json" at the root
of each project directory, which is often a Git repo. Here is a sample:
# Sample .argument-completer-registry.json
{
"completableCommands": [
{
"name": "build",
"argsPath": "./build/ci.cake",
"type": "cake",
"funcDefaults": {
"logOutput": true,
"talk": true,
"useTimer": true,
"useGlobal": false,
}
},
{
"name": "scrape",
"argsPath": "./tools/scrape.ps1",
"type": "custom",
"regex": "^Task ([A-Z][^ ]+)"
},
{
"name": "test|test.cmd",
"helpCommand": "--help",
"type": "custom",
"regex": "(--[a-z]+=?)"
}
]
}
# Description of object attributes used in the file:
name: The name of the command to be completed.
Use alternation to specify multiple command names,
in case they are aliases for the same command. E.g. "build|ci|make".
argsPath: The path to the script containing the arguments to be parsed.
helpCommand: For compiled tools. Parse the arguments from the tool's help output.
Supply something like "--help" or "-h".
Note that the command's output will be cached in $env:TEMP.
type: The type of script being parsed. See the switch statement below for natively-supported types.
regex: (For custom types) The regular expression used to extract the task names
from "argsPath" or the output of "helpCommand". The first capturing group
must contain the task name.
funcDefaults: Sets defaults for the generated wrapper function.
logOutput: true or false. If true, log command output to the TEMP directory.
Note that you will lose color output.
talk: true or false. If true, audibly communicate a failed or successfully
command invocation.
useTimer: true or false. If true, display elapsed time when command finishes.
useGlobal: true or false. If true, use the command found in your PATH rather than the local directory.
In the above file sample we can assume that there is an executable .\build, a .\scrape, and a .\test
in the same directory alongside .argument-completer-registry.json. This simple "registry" tells
the argument completer where the actual script is located and how to parse it.
For example, the "build" command above will execute the Cake script located in ./build/ci.cake.
The "type" is listed as "cake". The argument completer knows how to parse Cake files and
extract the arguments, which are then used for tab completion.
When you are in this directory and you type build, PowerShell will find the registered argument completer
and invoke the associated script block. The script block will load ./.argument-completer-registry.json
and find the "build" command. Then it parses the Cake script using the path provided in the .json file,
and uses that to provide tab completion. This happens every time you run the build command, but it happens
extremely fast. There is typically no lag.
.PARAMETER commandsToComplete
An array of strings representing the commands to register for tab completion.
.EXAMPLE
Register-DynamicArgumentCompleters -commandsToComplete @("build", "ci", "test")
.NOTES
Just to reiterate, there will be no tab completion without a .argument-completer-registry.json
file in the directory containing the commands. See DESCRIPTION for more details.
#>
Register-ArgumentCompleter -Native -CommandName $commandsToComplete -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
# Grab the base command from the abstract syntax tree
$baseCommand = $commandAst.CommandElements[0].Value
$leadingDotSlash = $false
if ($baseCommand -match "^\.[/\\]") {
# Strip the leading ./ or .\ from the command
$leadingDotSlash = $true
$baseCommand = $baseCommand.Substring(2)
}
$argumentCompleterRegistryPath = ".\.argument-completer-registry.json"
# Get the matching command object from the local "argument completer registry"
$cmd = ((Get-Content $argumentCompleterRegistryPath | ConvertFrom-Json).completableCommands | Where-Object name -match $baseCommand);
if ($cmd -eq $null) { return }
$argsRegex = ""
$switchesRegex = ""
$argsPath = $cmd.argsPath
switch ( $cmd.type ) {
cake {
$argsRegex = "^Task\(`"([A-Z][^`"]+)`""
$switchesRegex = "Argument<\w+>\(`"(\w+)`""
}
make {
$argsRegex = "^([a-z_]+):"
}
psake {
$argsRegex = "^Task ([A-Z][^ ]+)"
}
default {
$argsRegex = $cmd.regex
if ($cmd.helpCommand) {
$uniquePath = "global";
if (!($cmd.funcDefaults.useGlobal)) {
$uniquePath = $(Get-Location) -replace "[:\\/]", ""
}
$argsPath = "$env:temp/$baseCommand-$uniquePath.txt"
if (!(Test-Path $argsPath)) {
if (!($cmd.helpCommand -match "^(-h|/\?|--help|help|-\?|--list|-l)$")) {
# To avoid command injection, only allow whitelisted "help" arguments
return
}
$expression = "$baseCommand $($cmd.helpCommand)"
if (Test-Path "./$baseCommand.*" -Include *.bat,*.cmd,*.ps1) {
$expression = "./$expression"
}
Write-Host " >>> " -ForegroundColor Cyan -NoNewline
Write-Host "DANGER! " -ForegroundColor Red -NoNewline
Write-Host "OK to run " -NoNewline -ForegroundColor Cyan
Write-Host "``$expression``" -ForegroundColor Red -NoNewline
Write-Host "? (yes/no)" -NoNewline -ForegroundColor Cyan
if ($(Read-Host " ") -ne "yes") { return }
# Temporarily disable ansicon, if enabled
$ansiconSetting = $env:ANSICON
$env:ANSICON = $null
Invoke-Expression $expression *>&1 > $argsPath
$env:ANSICON = $ansiconSetting
}
}
}
}
# Parse the args file registered for the command using the regex registered for the command
$argumentCandidates = (Select-String $argsPath -Pattern $argsRegex |
ForEach-Object {
# The task name must be in the first capturing group
$_.matches.groups[1].value }) |
# Attempt to complete the supplied word using the parsed task names
Where-Object { $_ -like "$wordToComplete*" } |
Select-Object -Unique
$switchCandidates = $null
# Workaround [bug](https://github.com/PowerShell/PowerShell-Docs/issues/1979)
# Args starting with a hyphen aren't autocompleted. Start with a colon instead.
$switchFormatString = ":-{0}"
if ($leadingDotSlash) {
# But only do the workaround when the wrapper function is invoked
$switchFormatString = "-{0}"
}
if ($switchesRegex) {
$switchCandidates = (Select-String $argsPath -Pattern $switchesRegex |
ForEach-Object {
$_.matches.groups[1].value }) |
ForEach-Object { $switchFormatString -f $_ } |
Where-Object { $_ -like "$wordToComplete*" }
}
($argumentCandidates + $switchCandidates) |
Sort-Object |
ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
# Create helper functions for each command so that you don't have to use the leading ./ or .\
$script:helperFunctionTemplate = @'
function global:__NAME__ {
Param(
[switch]$Talk, [switch]$NoTalk,
[switch]$Log, [switch]$NoLog,
[switch]$Timer,[switch]$NoTimer)
$sw = [Diagnostics.Stopwatch]::StartNew()
$args = $args -replace ":-", "-" # <--Lousy "workaround" for args starting with a hyphen
$outputPath = "$($env:TEMP)\__NAME__-$($(Get-Location) -replace "[:\\/]", """).txt"
$defaultOptions = ((Get-Content -ErrorAction Ignore ".\.argument-completer-registry.json" | ConvertFrom-Json).completableCommands | Where-Object name -match __NAME__).funcDefaults;
$options = @{
log = $(!($NoLog.IsPresent) -and ($defaultOptions.logOutput -or $Log.IsPresent));
talk = $(!($NoTalk.IsPresent) -and ($defaultOptions.talk -or $Talk.IsPresent));
timer = $(!($NoTimer.IsPresent) -and ($defaultOptions.useTimer -or $Timer.IsPresent));
useGlobal = $(!($defaultOptions) -or $defaultOptions.useGlobal);
}
$cmdToInvoke = "./__NAME__ $args"
if ($options.useGlobal) {
$cmdToInvoke = "$(Get-Command __NAME__ -All | Where-Object CommandType -eq Application | Select-Object -First 1) $args"
}
if ($options.log) {
Invoke-Expression $($cmdToInvoke) | Tee-Object $outputPath
}
else {
Invoke-Expression $($cmdToInvoke)
}
$result = $LASTEXITCODE -eq 0
$message = "`"__NAME__ $args`" finished {0}" -f $(if($result){"successfully"}else{"with errors"})
Write-Host "`n$message" -ForegroundColor Cyan
$sw.Stop()
if ($options.timer) {
Write-Host "Total time:" -ForegroundColor Magenta -NoNewline
$sw.Elapsed
}
if ($options.log) {
Write-Host "View logs at:" -ForegroundColor Green
Write-Host "`t$outputPath"
}
if ($options.talk) {
Add-Type -AssemblyName System.Speech
$synth = New-Object -TypeName System.Speech.Synthesis.SpeechSynthesizer
try {
$synth.SpeakAsync($message) | Out-Null
}
catch {
Write-Host "[No audio device]: $message" -ForegroundColor Yellow
}
}
Write-Host "`nReturning: " -NoNewline
$result
}
'@
Write-Host "Enabled " -NoNewLine
Write-Host "tab completion" -ForegroundColor Green -NoNewLine
Write-Host " and generated " -NoNewline
Write-Host "helper functions" -ForegroundColor Green -NoNewLine
Write-Host " for (" -NoNewLine
foreach($script:command in $commandsToComplete) {
Write-Host " $script:command" -ForegroundColor Magenta -NoNewLine
Invoke-Expression $($script:helperFunctionTemplate -replace "__NAME__", $script:command)
}
Write-Host " )"
@B4Art
Copy link

B4Art commented Jan 25, 2021

$wordToComplete* filter should always be used at the end of the scriptblock, otherwise you will give unexpected result to the user.

Another suggestion is to use object embedded Where and ForEach:

(
<your code>
).Where( {
    $_.Name -like "$wordToComplete*"
 }).ForEach( {
    [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Name)
 })

This will work faster than:

(
<your code>
) | Where-Object {
     $_.Name -like "$wordToComplete*"
} | ForEach-Object {
     [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Name)
}

Both will give the same result

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment