Skip to content

Commit 04ba818

Browse files
committed
WIP: use toolinfo for completion gen
1 parent 9c5edf8 commit 04ba818

File tree

10 files changed

+299
-130
lines changed

10 files changed

+299
-130
lines changed

Examples/color/Color.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212

1313
import ArgumentParser
1414

15+
struct Foo: ParsableArguments {
16+
@Argument(
17+
help: .init(valueName: "MyDoooo"),
18+
completion: .custom({
19+
_ in [1, 2, 3].map(String.init)
20+
}))
21+
var doo: Int
22+
}
23+
1524
@main
1625
struct Color: ParsableCommand {
1726
@Option(help: "Your favorite color.")
@@ -20,6 +29,17 @@ struct Color: ParsableCommand {
2029
@Option(help: .init("Your second favorite color.", discussion: "This is optional."))
2130
var second: ColorOptions?
2231

32+
@Argument
33+
var x: Int
34+
35+
@OptionGroup
36+
var foo: Foo
37+
38+
@Argument(completion: .custom({
39+
_ in [4, 5, 6].map(String.init)
40+
}))
41+
var x2: Int = 0
42+
2343
func run() {
2444
print("My favorite color is \(fav.rawValue)")
2545
if let second {

Examples/count-lines/CountLines.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Foundation
1717
struct CountLines: AsyncParsableCommand {
1818
@Argument(
1919
help: "A file to count lines in. If omitted, counts the lines of stdin.",
20-
completion: .file(), transform: URL.init(fileURLWithPath:))
20+
completion: .file(extensions: ["txt"]), transform: URL.init(fileURLWithPath:))
2121
var inputFile: URL? = nil
2222

2323
@Option(help: "Only count lines with this prefix.")

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+
}

0 commit comments

Comments
 (0)