Skip to content

Commit d66a07b

Browse files
committed
Refactor Bash completions to use ToolInfo
Rebases the implementation of the BashCompletionGenerator to use ToolInfo from ArgumentParserToolInfo instead of digging through the command structure. This helps us decouple the implementation of Argument parsing from the generation of supplemental content such as docs, man-pages, completion scripts, help menus and more.
1 parent d3630e3 commit d66a07b

File tree

9 files changed

+197
-121
lines changed

9 files changed

+197
-121
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ var package = Package(
9595
.testTarget(
9696
name: "ArgumentParserExampleTests",
9797
dependencies: ["ArgumentParserTestHelpers"],
98+
exclude: ["Snapshots"],
9899
resources: [.copy("CountLinesTest.txt")]),
99100
.testTarget(
100101
name: "ArgumentParserGenerateDoccReferenceTests",

[email protected]

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ var package = Package(
9696
.testTarget(
9797
name: "ArgumentParserExampleTests",
9898
dependencies: ["ArgumentParserTestHelpers"],
99+
exclude: ["Snapshots"],
99100
resources: [.copy("CountLinesTest.txt")]),
100101
.testTarget(
101102
name: "ArgumentParserGenerateDoccReferenceTests",

Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift

Lines changed: 114 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,47 +9,59 @@
99
//
1010
//===----------------------------------------------------------------------===//
1111

12+
import ArgumentParserToolInfo
13+
1214
struct BashCompletionsGenerator {
1315
/// Generates a Bash completion script for the given command.
1416
static func generateCompletionScript(_ type: ParsableCommand.Type) -> String {
17+
return ToolInfoV0(commandStack: [type]).bashCompletionScript()
18+
}
19+
}
20+
21+
extension ToolInfoV0 {
22+
fileprivate func bashCompletionScript() -> String {
1523
// TODO: Add a check to see if the command is installed where we expect?
16-
let initialFunctionName = [type].completionFunctionName().makeSafeFunctionName
1724
return """
18-
#!/bin/bash
25+
#!/bin/bash
26+
27+
\(self.command.bashCompletionFunction())
28+
29+
complete -F \(self.command.bashCompletionFunctionName()) \(self.command.commandName)
30+
"""
31+
}
32+
}
1933

20-
\(generateCompletionFunction([type]))
34+
extension CommandInfoV0 {
35+
fileprivate func bashCommandContext() -> [String] {
36+
return (self.superCommands ?? []) + [self.commandName]
37+
}
2138

22-
complete -F \(initialFunctionName) \(type._commandName)
23-
"""
39+
fileprivate func bashCompletionFunctionName() -> String {
40+
return "_" + self.bashCommandContext().joined(separator: "_").makeSafeFunctionName
2441
}
2542

26-
/// Generates a Bash completion function for the last command in the given list.
27-
fileprivate static func generateCompletionFunction(_ commands: [ParsableCommand.Type]) -> String {
28-
let type = commands.last!
29-
let functionName = commands.completionFunctionName().makeSafeFunctionName
30-
43+
/// Generates a Bash completion function.
44+
fileprivate func bashCompletionFunction() -> String {
45+
let functionName = self.bashCompletionFunctionName()
46+
3147
// The root command gets a different treatment for the parsing index.
32-
let isRootCommand = commands.count == 1
48+
let isRootCommand = (self.superCommands ?? []).count == 0
3349
let dollarOne = isRootCommand ? "1" : "$1"
3450
let subcommandArgument = isRootCommand ? "2" : "$(($1+1))"
3551

3652
// Include 'help' in the list of subcommands for the root command.
37-
var subcommands = type.configuration.subcommands
38-
.filter { $0.configuration.shouldDisplay }
39-
if !subcommands.isEmpty && isRootCommand {
40-
subcommands.append(HelpCommand.self)
41-
}
53+
let subcommands = (self.subcommands ?? [])
54+
.filter { $0.shouldDisplay }
4255

4356
// Generate the words that are available at the "top level" of this
4457
// command — these are the dash-prefixed names of options and flags as well
4558
// as all the subcommand names.
46-
let completionWords = generateArgumentWords(commands)
47-
+ subcommands.map { $0._commandName }
48-
59+
let completionKeys = self.bashCompletionKeys() + subcommands.map { $0.commandName }
60+
4961
// Generate additional top-level completions — these are completion lists
5062
// or custom function-based word lists from positional arguments.
51-
let additionalCompletions = generateArgumentCompletions(commands)
52-
63+
let additionalCompletions = self.bashPositionalCompletions()
64+
5365
// Start building the resulting function code.
5466
var result = "\(functionName)() {\n"
5567

@@ -69,7 +81,7 @@ struct BashCompletionsGenerator {
6981

7082
// Start by declaring a local var for the top-level completions.
7183
// Return immediately if the completion matching hasn't moved further.
72-
result += " opts=\"\(completionWords.joined(separator: " "))\"\n"
84+
result += " opts=\"\(completionKeys.joined(separator: " "))\"\n"
7385
for line in additionalCompletions {
7486
result += " opts=\"$opts \(line)\"\n"
7587
}
@@ -84,7 +96,7 @@ struct BashCompletionsGenerator {
8496

8597
// Generate the case pattern-matching statements for option values.
8698
// If there aren't any, skip the case block altogether.
87-
let optionHandlers = generateOptionHandlers(commands)
99+
let optionHandlers = self.bashOptionCompletions().joined(separator: "\n")
88100
if !optionHandlers.isEmpty {
89101
result += """
90102
case $prev in
@@ -100,8 +112,8 @@ struct BashCompletionsGenerator {
100112
result += " case ${COMP_WORDS[\(dollarOne)]} in\n"
101113
for subcommand in subcommands {
102114
result += """
103-
(\(subcommand._commandName))
104-
\(functionName)_\(subcommand._commandName) \(subcommandArgument)
115+
(\(subcommand.commandName))
116+
\(functionName)_\(subcommand.commandName) \(subcommandArgument)
105117
return
106118
;;
107119
@@ -120,77 +132,100 @@ struct BashCompletionsGenerator {
120132

121133
return result +
122134
subcommands
123-
.map { generateCompletionFunction(commands + [$0]) }
124-
.joined()
135+
.map { $0.bashCompletionFunction() }
136+
.joined()
125137
}
126138

127139
/// Returns the option and flag names that can be top-level completions.
128-
fileprivate static func generateArgumentWords(_ commands: [ParsableCommand.Type]) -> [String] {
129-
commands
130-
.argumentsForHelp(visibility: .default)
131-
.flatMap { $0.bashCompletionWords() }
140+
fileprivate func bashCompletionKeys() -> [String] {
141+
var result = [String]()
142+
for argument in self.arguments ?? [] {
143+
// Skip hidden arguments.
144+
guard argument.shouldDisplay else { continue }
145+
result.append(contentsOf: argument.bashCompletionKeys())
146+
}
147+
return result
132148
}
133149

134150
/// Returns additional top-level completions from positional arguments.
135151
///
136152
/// These consist of completions that are defined as `.list` or `.custom`.
137-
fileprivate static func generateArgumentCompletions(_ commands: [ParsableCommand.Type]) -> [String] {
138-
ArgumentSet(commands.last!, visibility: .default, parent: nil)
139-
.compactMap { arg -> String? in
140-
guard arg.isPositional else { return nil }
141-
142-
switch arg.completion.kind {
143-
case .default, .file, .directory:
144-
return nil
145-
case .list(let list):
146-
return list.joined(separator: " ")
147-
case .shellCommand(let command):
148-
return "$(\(command))"
149-
case .custom:
150-
return """
151-
$("${COMP_WORDS[0]}" \(arg.customCompletionCall(commands)) "${COMP_WORDS[@]}")
152-
"""
153-
}
154-
}
153+
fileprivate func bashPositionalCompletions() -> [String] {
154+
var result = [String]()
155+
for argument in self.arguments ?? [] {
156+
// Skip hidden arguments.
157+
guard argument.shouldDisplay else { continue }
158+
// Only select positional arguments.
159+
guard argument.kind == .positional else { continue }
160+
// Skip if no completions.
161+
guard let completionValues = argument.bashPositionalCompletionValues(command: self) else { continue }
162+
result.append(completionValues)
163+
}
164+
return result
155165
}
156166

157167
/// Returns the case-matching statements for supplying completions after an option or flag.
158-
fileprivate static func generateOptionHandlers(_ commands: [ParsableCommand.Type]) -> String {
159-
ArgumentSet(commands.last!, visibility: .default, parent: nil)
160-
.compactMap { arg -> String? in
161-
let words = arg.bashCompletionWords()
162-
if words.isEmpty { return nil }
163-
164-
// Flags don't take a value, so we don't provide follow-on completions.
165-
if arg.isNullary { return nil }
166-
167-
return """
168-
\(arg.bashCompletionWords().joined(separator: "|")))
169-
\(arg.bashValueCompletion(commands).indentingEachLine(by: 4))
168+
fileprivate func bashOptionCompletions() -> [String] {
169+
var result = [String]()
170+
for argument in self.arguments ?? [] {
171+
// Skip hidden arguments.
172+
guard argument.shouldDisplay else { continue }
173+
// Flags don't take a value, so we don't provide follow-on completions.
174+
guard argument.kind != .flag else { continue }
175+
// Skip if no keys.
176+
let keys = argument.bashCompletionKeys()
177+
guard !keys.isEmpty else { continue }
178+
// Skip if no completions.
179+
guard let completionValues = argument.bashOptionCompletionValues(command: self) else { continue }
180+
result.append("""
181+
\(keys.joined(separator: "|")))
182+
\(completionValues.indentingEachLine(by: 4))
170183
return
171184
;;
172-
"""
173-
}
174-
.joined(separator: "\n")
185+
""")
186+
}
187+
return result
175188
}
176189
}
177190

178-
extension ArgumentDefinition {
191+
extension ArgumentInfoV0 {
179192
/// Returns the different completion names for this argument.
180-
fileprivate func bashCompletionWords() -> [String] {
181-
return help.visibility.base == .default
182-
? names.map { $0.synopsisString }
183-
: []
193+
fileprivate func bashCompletionKeys() -> [String] {
194+
return (self.names ?? []).map { $0.commonCompletionSynopsisString() }
195+
}
196+
197+
// FIXME: determine if this can be combined with bashOptionCompletionValues
198+
fileprivate func bashPositionalCompletionValues(
199+
command: CommandInfoV0
200+
) -> String? {
201+
precondition(self.kind == .positional)
202+
203+
switch self.completionKind {
204+
case .none, .file, .directory:
205+
// FIXME: this doesn't work
206+
return nil
207+
case .list(let list):
208+
return list.joined(separator: " ")
209+
case .shellCommand(let command):
210+
return "$(\(command))"
211+
case .custom:
212+
// Generate a call back into the command to retrieve a completions list
213+
return #"$("${COMP_WORDS[0]}" \#(self.commonCustomCompletionCall(command: command)) "${COMP_WORDS[@]}")"#
214+
}
184215
}
185216

186217
/// Returns the bash completions that can follow this argument's `--name`.
187218
///
188219
/// Uses bash-completion for file and directory values if available.
189-
fileprivate func bashValueCompletion(_ commands: [ParsableCommand.Type]) -> String {
190-
switch completion.kind {
191-
case .default:
192-
return ""
193-
220+
fileprivate func bashOptionCompletionValues(
221+
command: CommandInfoV0
222+
) -> String? {
223+
precondition(self.kind == .option)
224+
225+
switch self.completionKind {
226+
case .none:
227+
return nil
228+
194229
case .file(let extensions) where extensions.isEmpty:
195230
return """
196231
if declare -F _filedir >/dev/null; then
@@ -203,7 +238,7 @@ extension ArgumentDefinition {
203238
case .file(let extensions):
204239
var safeExts = extensions.map { String($0.flatMap { $0 == "'" ? ["\\", "'"] : [$0] }) }
205240
safeExts.append(contentsOf: safeExts.map { $0.uppercased() })
206-
241+
207242
return """
208243
if declare -F _filedir >/dev/null; then
209244
\(safeExts.map { "_filedir '\($0)'" }.joined(separator:"\n "))
@@ -224,22 +259,16 @@ extension ArgumentDefinition {
224259
COMPREPLY=( $(compgen -d -- "$cur") )
225260
fi
226261
"""
227-
262+
228263
case .list(let list):
229264
return #"COMPREPLY=( $(compgen -W "\#(list.joined(separator: " "))" -- "$cur") )"#
230-
265+
231266
case .shellCommand(let command):
232267
return "COMPREPLY=( $(\(command)) )"
233-
268+
234269
case .custom:
235270
// Generate a call back into the command to retrieve a completions list
236-
return #"COMPREPLY=( $(compgen -W "$("${COMP_WORDS[0]}" \#(customCompletionCall(commands)) "${COMP_WORDS[@]}")" -- "$cur") )"#
271+
return #"COMPREPLY=( $(compgen -W "$("${COMP_WORDS[0]}" \#(self.commonCustomCompletionCall(command: command)) "${COMP_WORDS[@]}")" -- "$cur") )"#
237272
}
238273
}
239274
}
240-
241-
extension String {
242-
var makeSafeFunctionName: String {
243-
self.replacingOccurrences(of: "-", with: "_")
244-
}
245-
}

Sources/ArgumentParser/Completions/CompletionsGenerator.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
//
1010
//===----------------------------------------------------------------------===//
1111

12+
import ArgumentParserToolInfo
13+
1214
/// A shell for which the parser can generate a completion script.
1315
public struct CompletionShell: RawRepresentable, Hashable, CaseIterable {
1416
public var rawValue: String
@@ -139,3 +141,41 @@ extension Sequence where Element == ParsableCommand.Type {
139141
.joined(separator: "_")
140142
}
141143
}
144+
145+
extension String {
146+
var makeSafeFunctionName: String {
147+
self.replacingOccurrences(of: "-", with: "_")
148+
}
149+
}
150+
151+
extension ArgumentInfoV0 {
152+
/// Returns a string with the arguments for the callback to generate custom
153+
/// completions for this argument.
154+
func commonCustomCompletionCall(command: CommandInfoV0) -> String {
155+
let commandContext = (command.superCommands ?? []) + [command.commandName]
156+
let subcommandNames = commandContext.dropFirst().joined(separator: " ")
157+
158+
let argumentName: String
159+
switch self.kind {
160+
case .positional:
161+
let index = (command.arguments ?? [])
162+
.filter { $0.kind == .positional }
163+
.firstIndex(of: self)!
164+
argumentName = "positional@\(index)"
165+
default:
166+
argumentName = self.preferredName!.commonCompletionSynopsisString()
167+
}
168+
return "---completion \(subcommandNames) -- \(argumentName)"
169+
}
170+
}
171+
172+
extension ArgumentInfoV0.NameInfoV0 {
173+
func commonCompletionSynopsisString() -> String {
174+
switch self.kind {
175+
case .long:
176+
return "--\(self.name)"
177+
case .short, .longWithSingleDash:
178+
return "-\(self.name)"
179+
}
180+
}
181+
}

Sources/ArgumentParser/Parsing/ArgumentSet.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,17 @@ extension ArgumentSet {
191191
}
192192

193193
func firstPositional(
194-
withKey key: InputKey
194+
withKey key: InputKey,
195195
) -> ArgumentDefinition? {
196-
first(where: { $0.help.keys.contains(key) })
196+
return first(where: { $0.help.keys.contains(key) })
197+
}
198+
199+
func positional(
200+
at index: Int
201+
) -> ArgumentDefinition? {
202+
let arguments = self.content.filter { $0.isPositional }
203+
guard arguments.count > index else { return nil }
204+
return arguments[index]
197205
}
198206
}
199207

0 commit comments

Comments
 (0)