Skip to content

Commit 76466cc

Browse files
authored
Improve zsh completion for repeatable options (#614)
Some commands allow an option to be repeated to provide multiple values. For example, ssh allows the -L flag to be repeated to establish multiple port forwardings. ArgumentParser supports this style when the Option value is an Array and the parsingStrategy is ArrayParsingStrategy.singleValue or .unconditionalSingleValue. Without this patch, ArgumentParser generates a zsh completion script that does not handle repeatable options correctly. The generated script suppresses completion of any option after that option's first use, even if the option is repeatable. With this patch, ArgumentParser generates a zsh completion script that allows a repeatable option to be completed each time it is used. The relevant zsh _arguments syntax is documented here: https://zsh.sourceforge.io/Doc/Release/Completion-System.html#Completion-Functions Specifically, a repeatable option's optspec needs to start with '*'. Furthermore, a repeatable argument must not list itself or its synonyms in its own parenthesized suppression list. (This is not clearly documented.) fixes #564 * Add test for repeated flag completions
1 parent c413809 commit 76466cc

File tree

2 files changed

+46
-6
lines changed

2 files changed

+46
-6
lines changed

Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ extension ArgumentDefinition {
137137
func zshCompletionString(_ commands: [ParsableCommand.Type]) -> String? {
138138
guard help.visibility.base == .default else { return nil }
139139

140-
var inputs: String
140+
let inputs: String
141141
switch update {
142142
case .unary:
143143
inputs = ":\(valueName):\(zshActionString(commands))"
@@ -150,13 +150,15 @@ extension ArgumentDefinition {
150150
case 0:
151151
line = ""
152152
case 1:
153+
let star = isRepeatableOption ? "*" : ""
153154
line = """
154-
\(names[0].synopsisString)\(zshCompletionAbstract)
155+
\(star)\(names[0].synopsisString)\(zshCompletionAbstract)
155156
"""
156157
default:
157158
let synopses = names.map { $0.synopsisString }
159+
let suppression = isRepeatableOption ? "*" : "(\(synopses.joined(separator: " ")))"
158160
line = """
159-
(\(synopses.joined(separator: " ")))'\
161+
\(suppression)'\
160162
{\(synopses.joined(separator: ","))}\
161163
'\(zshCompletionAbstract)
162164
"""
@@ -165,6 +167,19 @@ extension ArgumentDefinition {
165167
return "'\(line)\(inputs)'"
166168
}
167169

170+
/// - returns: `true` if I'm an option and can be tab-completed multiple times in one command line. For example, `ssh` allows the `-L` option to be given multiple times, to establish multiple port forwardings.
171+
private var isRepeatableOption: Bool {
172+
guard
173+
case .named(_) = kind,
174+
help.options.contains(.isRepeating)
175+
else { return false }
176+
177+
switch parsingStrategy {
178+
case .default, .unconditional: return true
179+
default: return false
180+
}
181+
}
182+
168183
/// Returns the zsh "action" for an argument completion string.
169184
func zshActionString(_ commands: [ParsableCommand.Type]) -> String {
170185
switch completion.kind {

Tests/ArgumentParserUnitTests/CompletionScriptTests.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ extension CompletionScriptTests {
2929
}
3030
}
3131

32-
enum Kind: String, ExpressibleByArgument, CaseIterable {
32+
enum Kind: String, ExpressibleByArgument, EnumerableFlag {
3333
case one, two, three = "custom-three"
3434
}
3535

@@ -48,6 +48,11 @@ extension CompletionScriptTests {
4848
@Option(completion: .list(["a", "b", "c"])) var path3: Path
4949

5050
@Flag(help: .hidden) var verbose = false
51+
@Flag var allowedKinds: [Kind] = []
52+
@Flag var kindCounter: Int
53+
54+
@Option() var rep1: [String]
55+
@Option(name: [.short, .long]) var rep2: [String]
5156

5257
struct SubCommand: ParsableCommand {
5358
static var configuration = CommandConfiguration(
@@ -164,6 +169,12 @@ _base-test() {
164169
'--path1:path1:_files'
165170
'--path2:path2:_files'
166171
'--path3:path3:(a b c)'
172+
'--one'
173+
'--two'
174+
'--three'
175+
'*--kind-counter'
176+
'*--rep1:rep1:'
177+
'*'{-r,--rep2}':rep2:'
167178
'(-h --help)'{-h,--help}'[Show help information.]'
168179
'(-): :->command'
169180
'(-)*:: :->arg'
@@ -231,7 +242,7 @@ _base_test() {
231242
cur="${COMP_WORDS[COMP_CWORD]}"
232243
prev="${COMP_WORDS[COMP_CWORD-1]}"
233244
COMPREPLY=()
234-
opts="--name --kind --other-kind --path1 --path2 --path3 -h --help sub-command help"
245+
opts="--name --kind --other-kind --path1 --path2 --path3 --one --two --three --kind-counter --rep1 -r --rep2 -h --help sub-command help"
235246
if [[ $COMP_CWORD == "1" ]]; then
236247
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
237248
return
@@ -269,6 +280,14 @@ _base_test() {
269280
COMPREPLY=( $(compgen -W "a b c" -- "$cur") )
270281
return
271282
;;
283+
--rep1)
284+
285+
return
286+
;;
287+
-r|--rep2)
288+
289+
return
290+
;;
272291
esac
273292
case ${COMP_WORDS[1]} in
274293
(sub-command)
@@ -370,12 +389,18 @@ function _swift_base-test_using_command
370389
end
371390
372391
complete -c base-test -n '_swift_base-test_using_command "base-test sub-command"' -s h -l help -d 'Show help information.'
373-
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l name -d 'The user\\\'s name.'
392+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l name -d 'The user\\'s name.'
374393
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l kind -r -f -k -a 'one two custom-three'
375394
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l other-kind -r -f -k -a '1 2 3'
376395
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l path1 -r -f -a '(for i in *.{}; echo $i;end)'
377396
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l path2 -r -f -a '(for i in *.{}; echo $i;end)'
378397
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l path3 -r -f -k -a 'a b c'
398+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l one
399+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l two
400+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l three
401+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l kind-counter
402+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -l rep1
403+
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -s r -l rep2
379404
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -s h -l help -d 'Show help information.'
380405
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -f -a 'sub-command' -d ''
381406
complete -c base-test -n '_swift_base-test_using_command "base-test" "sub-command help"' -f -a 'help' -d 'Show subcommand help information.'

0 commit comments

Comments
 (0)