Skip to content

Commit a4ab3fa

Browse files
authored
powershell completion with custom comp (#1208)
The current powershell completion is not very capable. Let's port it to the go custom completion logic to have a unified experience accross all shells. Powershell supports three different completion modes - TabCompleteNext (default windows style - on each key press the next option is displayed) - Complete (works like bash) - MenuComplete (works like zsh) You set the mode with `Set-PSReadLineKeyHandler -Key Tab -Function <mode>` To keep it backwards compatible `GenPowerShellCompletion` will not display descriptions. Use `GenPowerShellCompletionWithDesc` instead. Descriptions will only be displayed with `MenuComplete` or `Complete`. Signed-off-by: Paul Holzinger <[email protected]>
1 parent 471c9ac commit a4ab3fa

File tree

4 files changed

+309
-209
lines changed

4 files changed

+309
-209
lines changed

powershell_completions.go

Lines changed: 254 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// PowerShell completions are based on the amazing work from clap:
2-
// https://github.com/clap-rs/clap/blob/3294d18efe5f264d12c9035f404c7d189d4824e1/src/completions/powershell.rs
3-
//
41
// The generated scripts require PowerShell v5.0+ (which comes Windows 10, but
52
// can be downloaded separately for windows 7 or 8.1).
63

@@ -11,90 +8,278 @@ import (
118
"fmt"
129
"io"
1310
"os"
14-
"strings"
15-
16-
"github.com/spf13/pflag"
1711
)
1812

19-
var powerShellCompletionTemplate = `using namespace System.Management.Automation
20-
using namespace System.Management.Automation.Language
21-
Register-ArgumentCompleter -Native -CommandName '%s' -ScriptBlock {
22-
param($wordToComplete, $commandAst, $cursorPosition)
23-
$commandElements = $commandAst.CommandElements
24-
$command = @(
25-
'%s'
26-
for ($i = 1; $i -lt $commandElements.Count; $i++) {
27-
$element = $commandElements[$i]
28-
if ($element -isnot [StringConstantExpressionAst] -or
29-
$element.StringConstantType -ne [StringConstantType]::BareWord -or
30-
$element.Value.StartsWith('-')) {
31-
break
32-
}
33-
$element.Value
34-
}
35-
) -join ';'
36-
$completions = @(switch ($command) {%s
37-
})
38-
$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
39-
Sort-Object -Property ListItemText
40-
}`
41-
42-
func generatePowerShellSubcommandCases(out io.Writer, cmd *Command, previousCommandName string) {
43-
var cmdName string
44-
if previousCommandName == "" {
45-
cmdName = cmd.Name()
46-
} else {
47-
cmdName = fmt.Sprintf("%s;%s", previousCommandName, cmd.Name())
48-
}
49-
50-
fmt.Fprintf(out, "\n '%s' {", cmdName)
51-
52-
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
53-
if nonCompletableFlag(flag) {
54-
return
55-
}
56-
usage := escapeStringForPowerShell(flag.Usage)
57-
if len(flag.Shorthand) > 0 {
58-
fmt.Fprintf(out, "\n [CompletionResult]::new('-%s', '%s', [CompletionResultType]::ParameterName, '%s')", flag.Shorthand, flag.Shorthand, usage)
59-
}
60-
fmt.Fprintf(out, "\n [CompletionResult]::new('--%s', '%s', [CompletionResultType]::ParameterName, '%s')", flag.Name, flag.Name, usage)
61-
})
62-
63-
for _, subCmd := range cmd.Commands() {
64-
usage := escapeStringForPowerShell(subCmd.Short)
65-
fmt.Fprintf(out, "\n [CompletionResult]::new('%s', '%s', [CompletionResultType]::ParameterValue, '%s')", subCmd.Name(), subCmd.Name(), usage)
13+
func genPowerShellComp(buf *bytes.Buffer, name string, includeDesc bool) {
14+
compCmd := ShellCompRequestCmd
15+
if !includeDesc {
16+
compCmd = ShellCompNoDescRequestCmd
6617
}
18+
buf.WriteString(fmt.Sprintf(`# powershell completion for %-36[1]s -*- shell-script -*-
6719
68-
fmt.Fprint(out, "\n break\n }")
69-
70-
for _, subCmd := range cmd.Commands() {
71-
generatePowerShellSubcommandCases(out, subCmd, cmdName)
72-
}
20+
function __%[1]s_debug {
21+
if ($env:BASH_COMP_DEBUG_FILE) {
22+
"$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE"
23+
}
7324
}
7425
75-
func escapeStringForPowerShell(s string) string {
76-
return strings.Replace(s, "'", "''", -1)
26+
filter __%[1]s_escapeStringWithSpecialChars {
27+
`+" $_ -replace '\\s|#|@|\\$|;|,|''|\\{|\\}|\\(|\\)|\"|`|\\||<|>|&','`$&'"+`
7728
}
7829
79-
// GenPowerShellCompletion generates PowerShell completion file and writes to the passed writer.
80-
func (c *Command) GenPowerShellCompletion(w io.Writer) error {
81-
buf := new(bytes.Buffer)
30+
Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock {
31+
param(
32+
$WordToComplete,
33+
$CommandAst,
34+
$CursorPosition
35+
)
36+
37+
# Get the current command line and convert into a string
38+
$Command = $CommandAst.CommandElements
39+
$Command = "$Command"
40+
41+
__%[1]s_debug ""
42+
__%[1]s_debug "========= starting completion logic =========="
43+
__%[1]s_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition"
44+
45+
# The user could have moved the cursor backwards on the command-line.
46+
# We need to trigger completion from the $CursorPosition location, so we need
47+
# to truncate the command-line ($Command) up to the $CursorPosition location.
48+
# Make sure the $Command is longer then the $CursorPosition before we truncate.
49+
# This happens because the $Command does not include the last space.
50+
if ($Command.Length -gt $CursorPosition) {
51+
$Command=$Command.Substring(0,$CursorPosition)
52+
}
53+
__%[1]s_debug "Truncated command: $Command"
54+
55+
$ShellCompDirectiveError=%[3]d
56+
$ShellCompDirectiveNoSpace=%[4]d
57+
$ShellCompDirectiveNoFileComp=%[5]d
58+
$ShellCompDirectiveFilterFileExt=%[6]d
59+
$ShellCompDirectiveFilterDirs=%[7]d
60+
61+
# Prepare the command to request completions for the program.
62+
# Split the command at the first space to separate the program and arguments.
63+
$Program,$Arguments = $Command.Split(" ",2)
64+
$RequestComp="$Program %[2]s $Arguments"
65+
__%[1]s_debug "RequestComp: $RequestComp"
66+
67+
# we cannot use $WordToComplete because it
68+
# has the wrong values if the cursor was moved
69+
# so use the last argument
70+
if ($WordToComplete -ne "" ) {
71+
$WordToComplete = $Arguments.Split(" ")[-1]
72+
}
73+
__%[1]s_debug "New WordToComplete: $WordToComplete"
74+
75+
76+
# Check for flag with equal sign
77+
$IsEqualFlag = ($WordToComplete -Like "--*=*" )
78+
if ( $IsEqualFlag ) {
79+
__%[1]s_debug "Completing equal sign flag"
80+
# Remove the flag part
81+
$Flag,$WordToComplete = $WordToComplete.Split("=",2)
82+
}
83+
84+
if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) {
85+
# If the last parameter is complete (there is a space following it)
86+
# We add an extra empty parameter so we can indicate this to the go method.
87+
__%[1]s_debug "Adding extra empty parameter"
88+
`+" # We need to use `\"`\" to pass an empty argument a \"\" or '' does not work!!!"+`
89+
`+" $RequestComp=\"$RequestComp\" + ' `\"`\"' "+`
90+
}
91+
92+
__%[1]s_debug "Calling $RequestComp"
93+
#call the command store the output in $out and redirect stderr and stdout to null
94+
# $Out is an array contains each line per element
95+
Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null
96+
97+
98+
# get directive from last line
99+
[int]$Directive = $Out[-1].TrimStart(':')
100+
if ($Directive -eq "") {
101+
# There is no directive specified
102+
$Directive = 0
103+
}
104+
__%[1]s_debug "The completion directive is: $Directive"
105+
106+
# remove directive (last element) from out
107+
$Out = $Out | Where-Object { $_ -ne $Out[-1] }
108+
__%[1]s_debug "The completions are: $Out"
109+
110+
if (($Directive -band $ShellCompDirectiveError) -ne 0 ) {
111+
# Error code. No completion.
112+
__%[1]s_debug "Received error from custom completion go code"
113+
return
114+
}
115+
116+
$Longest = 0
117+
$Values = $Out | ForEach-Object {
118+
#Split the output in name and description
119+
`+" $Name, $Description = $_.Split(\"`t\",2)"+`
120+
__%[1]s_debug "Name: $Name Description: $Description"
121+
122+
# Look for the longest completion so that we can format things nicely
123+
if ($Longest -lt $Name.Length) {
124+
$Longest = $Name.Length
125+
}
126+
127+
# Set the description to a one space string if there is none set.
128+
# This is needed because the CompletionResult does not accept an empty string as argument
129+
if (-Not $Description) {
130+
$Description = " "
131+
}
132+
@{Name="$Name";Description="$Description"}
133+
}
134+
135+
136+
$Space = " "
137+
if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) {
138+
# remove the space here
139+
__%[1]s_debug "ShellCompDirectiveNoSpace is called"
140+
$Space = ""
141+
}
142+
143+
if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) {
144+
__%[1]s_debug "ShellCompDirectiveNoFileComp is called"
145+
146+
if ($Values.Length -eq 0) {
147+
# Just print an empty string here so the
148+
# shell does not start to complete paths.
149+
# We cannot use CompletionResult here because
150+
# it does not accept an empty string as argument.
151+
""
152+
return
153+
}
154+
}
155+
156+
if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or
157+
(($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) {
158+
__%[1]s_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported"
159+
160+
# return here to prevent the completion of the extensions
161+
return
162+
}
163+
164+
$Values = $Values | Where-Object {
165+
# filter the result
166+
$_.Name -like "$WordToComplete*"
82167
83-
var subCommandCases bytes.Buffer
84-
generatePowerShellSubcommandCases(&subCommandCases, c, "")
85-
fmt.Fprintf(buf, powerShellCompletionTemplate, c.Name(), c.Name(), subCommandCases.String())
168+
# Join the flag back if we have a equal sign flag
169+
if ( $IsEqualFlag ) {
170+
__%[1]s_debug "Join the equal sign flag back to the completion value"
171+
$_.Name = $Flag + "=" + $_.Name
172+
}
173+
}
174+
175+
# Get the current mode
176+
$Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function
177+
__%[1]s_debug "Mode: $Mode"
178+
179+
$Values | ForEach-Object {
180+
181+
# store temporay because switch will overwrite $_
182+
$comp = $_
183+
184+
# Powershell supports three different completion modes
185+
# - TabCompleteNext (default windows style - on each key press the next option is displayed)
186+
# - Complete (works like bash)
187+
# - MenuComplete (works like zsh)
188+
# You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function <mode>
189+
190+
# CompletionResult Arguments:
191+
# 1) CompletionText text to be used as the auto completion result
192+
# 2) ListItemText text to be displayed in the suggestion list
193+
# 3) ResultType type of completion result
194+
# 4) ToolTip text for the tooltip with details about the object
195+
196+
switch ($Mode) {
197+
198+
# bash like
199+
"Complete" {
200+
201+
if ($Values.Length -eq 1) {
202+
__%[1]s_debug "Only one completion left"
86203
204+
# insert space after value
205+
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
206+
207+
} else {
208+
# Add the proper number of spaces to align the descriptions
209+
while($comp.Name.Length -lt $Longest) {
210+
$comp.Name = $comp.Name + " "
211+
}
212+
213+
# Check for empty description and only add parentheses if needed
214+
if ($($comp.Description) -eq " " ) {
215+
$Description = ""
216+
} else {
217+
$Description = " ($($comp.Description))"
218+
}
219+
220+
[System.Management.Automation.CompletionResult]::new("$($comp.Name)$Description", "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)")
221+
}
222+
}
223+
224+
# zsh like
225+
"MenuComplete" {
226+
# insert space after value
227+
# MenuComplete will automatically show the ToolTip of
228+
# the highlighted value at the bottom of the suggestions.
229+
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
230+
}
231+
232+
# TabCompleteNext and in case we get something unknown
233+
Default {
234+
# Like MenuComplete but we don't want to add a space here because
235+
# the user need to press space anyway to get the completion.
236+
# Description will not be shown because thats not possible with TabCompleteNext
237+
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars), "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
238+
}
239+
}
240+
241+
}
242+
}
243+
`, name, compCmd,
244+
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
245+
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs))
246+
}
247+
248+
func (c *Command) genPowerShellCompletion(w io.Writer, includeDesc bool) error {
249+
buf := new(bytes.Buffer)
250+
genPowerShellComp(buf, c.Name(), includeDesc)
87251
_, err := buf.WriteTo(w)
88252
return err
89253
}
90254

91-
// GenPowerShellCompletionFile generates PowerShell completion file.
92-
func (c *Command) GenPowerShellCompletionFile(filename string) error {
255+
func (c *Command) genPowerShellCompletionFile(filename string, includeDesc bool) error {
93256
outFile, err := os.Create(filename)
94257
if err != nil {
95258
return err
96259
}
97260
defer outFile.Close()
98261

99-
return c.GenPowerShellCompletion(outFile)
262+
return c.genPowerShellCompletion(outFile, includeDesc)
263+
}
264+
265+
// GenPowerShellCompletionFile generates powershell completion file without descriptions.
266+
func (c *Command) GenPowerShellCompletionFile(filename string) error {
267+
return c.genPowerShellCompletionFile(filename, false)
268+
}
269+
270+
// GenPowerShellCompletion generates powershell completion file without descriptions
271+
// and writes it to the passed writer.
272+
func (c *Command) GenPowerShellCompletion(w io.Writer) error {
273+
return c.genPowerShellCompletion(w, false)
274+
}
275+
276+
// GenPowerShellCompletionFileWithDesc generates powershell completion file with descriptions.
277+
func (c *Command) GenPowerShellCompletionFileWithDesc(filename string) error {
278+
return c.genPowerShellCompletionFile(filename, true)
279+
}
280+
281+
// GenPowerShellCompletionWithDesc generates powershell completion file with descriptions
282+
// and writes it to the passed writer.
283+
func (c *Command) GenPowerShellCompletionWithDesc(w io.Writer) error {
284+
return c.genPowerShellCompletion(w, true)
100285
}

powershell_completions.md

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,3 @@
11
# Generating PowerShell Completions For Your Own cobra.Command
22

3-
Cobra can generate PowerShell completion scripts. Users need PowerShell version 5.0 or above, which comes with Windows 10 and can be downloaded separately for Windows 7 or 8.1. They can then write the completions to a file and source this file from their PowerShell profile, which is referenced by the `$Profile` environment variable. See `Get-Help about_Profiles` for more info about PowerShell profiles.
4-
5-
*Note*: PowerShell completions have not (yet?) been aligned to Cobra's generic shell completion support. This implies the PowerShell completions are not as rich as for other shells (see [What's not yet supported](#whats-not-yet-supported)), and may behave slightly differently. They are still very useful for PowerShell users.
6-
7-
# What's supported
8-
9-
- Completion for subcommands using their `.Short` description
10-
- Completion for non-hidden flags using their `.Name` and `.Shorthand`
11-
12-
# What's not yet supported
13-
14-
- Command aliases
15-
- Required, filename or custom flags (they will work like normal flags)
16-
- Custom completion scripts
3+
Please refer to [Shell Completions](shell_completions.md#powershell-completions) for details.

0 commit comments

Comments
 (0)