Skip to content

Commit a97f2d8

Browse files
committed
Properly complete fish positionals.
Fix custom completion of empty argument. Only associate a description with a fish completion if it's a flag/option or a subcommand. Signed-off-by: Ross Goldberg <[email protected]>
1 parent 0933ef2 commit a97f2d8

File tree

3 files changed

+284
-166
lines changed

3 files changed

+284
-166
lines changed

Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift

Lines changed: 157 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,43 +17,39 @@ extension [ParsableCommand.Type] {
1717
// - first is guaranteed non-empty in the one place where this computed var is used.
1818
let commandName = first!._commandName
1919
return """
20-
# A function which filters options which starts with "-" from $argv.
21-
function \(commandsAndPositionalsFunctionName)
22-
set -l results
23-
for i in (seq (count $argv))
24-
switch (echo $argv[$i] | string sub -l 1)
25-
case '-'
26-
case '*'
27-
echo $argv[$i]
20+
function \(commandsAndPositionalsFunctionName) -S
21+
switch $POSITIONALS[1]
22+
\(commandCases)\
2823
end
24+
case '*'
25+
set COMMANDS $POSITIONALS[1]
26+
set -e POSITIONALS[1]
2927
end
3028
end
3129
32-
function \(usingCommandFunctionName)
33-
set -gx \(CompletionShell.shellEnvironmentVariableName) fish
34-
set -gx \(CompletionShell.shellVersionEnvironmentVariableName) "$FISH_VERSION"
35-
set -l commands_and_positionals (\(commandsAndPositionalsFunctionName) (commandline -opc))
36-
set -l expected_commands (string split -- '\(separator)' $argv[1])
37-
set -l subcommands (string split -- '\(separator)' $argv[2])
38-
if [ (count $commands_and_positionals) -ge (count $expected_commands) ]
39-
for i in (seq (count $expected_commands))
40-
if [ $commands_and_positionals[$i] != $expected_commands[$i] ]
41-
return 1
42-
end
43-
end
44-
if [ (count $commands_and_positionals) -eq (count $expected_commands) ]
45-
return 0
46-
end
47-
if [ (count $subcommands) -gt 1 ]
48-
for i in (seq (count $subcommands))
49-
if [ $commands_and_positionals[(math (count $expected_commands) + 1)] = $subcommands[$i] ]
50-
return 1
51-
end
52-
end
53-
end
54-
return 0
30+
function \(commandsAndPositionalsFunctionName)_helper -S -a argparse_options -a option_specs
31+
set -a COMMANDS $POSITIONALS[1]
32+
set -e POSITIONALS[1]
33+
if test -z $argparse_options
34+
argparse -n (string join -- '\(separator)' $COMMANDS) (string split -- '\(separator)' $option_specs) -- $POSITIONALS 2> /dev/null
35+
set POSITIONALS $argv
36+
else
37+
argparse (string split -- '\(separator)' $argparse_options) -n (string join -- '\(separator)' $COMMANDS) (string split -- '\(separator)' $option_specs) -- $POSITIONALS 2> /dev/null
38+
set POSITIONALS $argv
5539
end
56-
return 1
40+
end
41+
42+
function \(usingCommandFunctionName) -a expected_commands
43+
set COMMANDS
44+
set POSITIONALS (commandline -opc)
45+
\(commandsAndPositionalsFunctionName)
46+
test "$COMMANDS" = $expected_commands
47+
end
48+
49+
function \(positionalIndexFunctionName)
50+
set POSITIONALS (commandline -opc)
51+
\(commandsAndPositionalsFunctionName)
52+
math (count $POSITIONALS) + 1
5753
end
5854
5955
function \(completeDirectoriesFunctionName)
@@ -63,38 +59,51 @@ extension [ParsableCommand.Type] {
6359
printf '%s\\n' $subdirs
6460
end
6561
62+
function \(customCompletionFunctionName)
63+
set -x \(CompletionShell.shellEnvironmentVariableName) fish
64+
set -x \(CompletionShell.shellVersionEnvironmentVariableName) $FISH_VERSION
65+
66+
set tokens (commandline -op)
67+
if test -z (commandline -ot)
68+
set index (count (commandline -opc))
69+
set tokens $tokens[..$index] \\'\\' $tokens[$(math $index + 1)..]
70+
end
71+
command $tokens[1] $argv $tokens
72+
end
73+
6674
complete -c \(commandName) -f
6775
\(completions.joined(separator: "\n"))
6876
"""
6977
}
7078

71-
private var completions: [String] {
72-
guard let type = last else {
73-
fatalError()
74-
}
75-
var subcommands = type.configuration.subcommands
76-
.filter { $0.configuration.shouldDisplay }
77-
78-
if count == 1 {
79-
subcommands.addHelpSubcommandIfMissing()
80-
}
79+
private var commandCases: String {
80+
let subcommands = subcommands
81+
// swift-format-ignore: NeverForceUnwrap
82+
// Precondition: last is guaranteed to be non-empty
83+
return """
84+
case '\(last!._commandName)'
85+
\(commandsAndPositionalsFunctionName)_helper '\(
86+
subcommands.isEmpty ? "" : "-s"
87+
)' '\(completableArguments.compactMap(\.optionSpec).map { "\($0)" }.joined(separator: separator))'\(
88+
subcommands.isEmpty ? "" : "\n switch $POSITIONALS[1]")
89+
\(subcommands.map { (self + [$0]).commandCases }.joined(separator: ""))
90+
""".indentingEachLine(by: 4)
91+
}
8192

93+
private var completions: [String] {
8294
// swift-format-ignore: NeverForceUnwrap
8395
// Precondition: first is guaranteed to be non-empty
8496
let commandName = first!._commandName
85-
var prefix = """
97+
let prefix = """
8698
complete -c \(commandName)\
8799
-n '\(usingCommandFunctionName)\
88100
"\(map { $0._commandName }.joined(separator: separator))"
89101
"""
90-
if !subcommands.isEmpty {
91-
prefix +=
92-
" \"\(subcommands.map { $0._commandName }.joined(separator: separator))\""
93-
}
94-
prefix += "'"
95102

96-
func complete(suggestion: String) -> String {
97-
"\(prefix) \(suggestion)"
103+
let subcommands = subcommands
104+
105+
func complete(suggestion: String, extraTests: [String] = []) -> String {
106+
"\(prefix)\(extraTests.map { ";\($0)" }.joined())' \(suggestion)"
98107
}
99108

100109
let subcommandCompletions: [String] = subcommands.map { subcommand in
@@ -104,11 +113,26 @@ extension [ParsableCommand.Type] {
104113
)
105114
}
106115

116+
var positionalIndex = 0
117+
107118
let argumentCompletions =
108-
argumentsForHelp(visibility: .default)
109-
.compactMap { argumentSegments($0) }
110-
.map { $0.joined(separator: separator) }
111-
.map { complete(suggestion: $0) }
119+
completableArguments
120+
.map { (arg: ArgumentDefinition) in
121+
complete(
122+
suggestion: argumentSegments(arg).joined(separator: separator),
123+
extraTests: arg.isPositional
124+
? [
125+
"""
126+
and test (\(positionalIndexFunctionName)) \
127+
-eq \({
128+
positionalIndex += 1
129+
return positionalIndex
130+
}())
131+
"""
132+
]
133+
: []
134+
)
135+
}
112136

113137
let completionsFromSubcommands = subcommands.flatMap { subcommand in
114138
(self + [subcommand]).completions
@@ -118,23 +142,49 @@ extension [ParsableCommand.Type] {
118142
completionsFromSubcommands + argumentCompletions + subcommandCompletions
119143
}
120144

121-
private func argumentSegments(_ arg: ArgumentDefinition) -> [String]? {
122-
guard arg.help.visibility.base == .default
123-
else { return nil }
145+
private var subcommands: Self {
146+
guard
147+
let command = last,
148+
ArgumentSet(command, visibility: .default, parent: nil)
149+
.filter(\.isPositional).isEmpty
150+
else {
151+
return []
152+
}
153+
var subcommands = command.configuration.subcommands
154+
.filter { $0.configuration.shouldDisplay }
155+
if count == 1 {
156+
subcommands.addHelpSubcommandIfMissing()
157+
}
158+
return subcommands
159+
}
160+
161+
private var completableArguments: [ArgumentDefinition] {
162+
argumentsForHelp(visibility: .default).compactMap { arg in
163+
switch arg.completion.kind {
164+
case .default where arg.names.isEmpty:
165+
return nil
166+
default:
167+
return
168+
arg.help.visibility.base == .default
169+
? arg
170+
: nil
171+
}
172+
}
173+
}
124174

175+
private func argumentSegments(_ arg: ArgumentDefinition) -> [String] {
125176
var results: [String] = []
126177

127178
if !arg.names.isEmpty {
128179
results += arg.names.map { $0.asFishSuggestion }
129-
}
130-
131-
if !arg.help.abstract.isEmpty {
132-
results += ["-d '\(arg.help.abstract.fishEscapeForSingleQuotedString())'"]
180+
if !arg.help.abstract.isEmpty {
181+
results += [
182+
"-d '\(arg.help.abstract.fishEscapeForSingleQuotedString())'"
183+
]
184+
}
133185
}
134186

135187
switch arg.completion.kind {
136-
case .default where arg.names.isEmpty:
137-
return nil
138188
case .default:
139189
break
140190
case .list(let list):
@@ -170,9 +220,7 @@ extension [ParsableCommand.Type] {
170220
case .custom:
171221
results += [
172222
"""
173-
-rfka '(\
174-
set command (commandline -op)[1];command $command \(arg.customCompletionCall(self)) (commandline -op)\
175-
)'
223+
-rfka '(\(customCompletionFunctionName) \(arg.customCompletionCall(self)))'
176224
"""
177225
]
178226
}
@@ -192,11 +240,54 @@ extension [ParsableCommand.Type] {
192240
"_swift_\(first!._commandName)_using_command"
193241
}
194242

243+
private var positionalIndexFunctionName: String {
244+
// swift-format-ignore: NeverForceUnwrap
245+
// Precondition: first is guaranteed to be non-empty
246+
"_swift_\(first!._commandName)_positional_index"
247+
}
248+
195249
private var completeDirectoriesFunctionName: String {
196250
// swift-format-ignore: NeverForceUnwrap
197251
// Precondition: first is guaranteed to be non-empty
198252
"_swift_\(first!._commandName)_complete_directories"
199253
}
254+
255+
private var customCompletionFunctionName: String {
256+
// swift-format-ignore: NeverForceUnwrap
257+
// Precondition: first is guaranteed to be non-empty
258+
"_swift_\(first!._commandName)_custom_completion"
259+
}
260+
}
261+
262+
extension ArgumentDefinition {
263+
fileprivate var optionSpec: String? {
264+
guard let shortName = name(.short) else {
265+
guard let longName = name(.long) else {
266+
return nil
267+
}
268+
return optionSpecRequiresValue(longName)
269+
}
270+
guard let longName = name(.long) else {
271+
return optionSpecRequiresValue(shortName)
272+
}
273+
return optionSpecRequiresValue("\(shortName)/\(longName)")
274+
}
275+
276+
private func name(_ nameType: Name.Case) -> String? {
277+
names.first(where: {
278+
$0.case == nameType
279+
})?
280+
.valueString
281+
}
282+
283+
private func optionSpecRequiresValue(_ optionSpec: String) -> String {
284+
switch update {
285+
case .unary:
286+
return "\(optionSpec)="
287+
default:
288+
return optionSpec
289+
}
290+
}
200291
}
201292

202293
extension Name {

0 commit comments

Comments
 (0)