Skip to content

Commit 280700d

Browse files
authored
Add completion script generation (#123)
Support for generating shell completion scripts for `ParsableCommand` types, with customization points for `ExpressibleByArgument` types and individual arguments and options. Zsh and Bash are supported in this initial release.
1 parent cdce71f commit 280700d

30 files changed

+1807
-158
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Completion Scripts
2+
3+
Generate customized completion scripts for your shell of choice.
4+
5+
## Generating and Installing Completion Scripts
6+
7+
Command-line tools that you build with `ArgumentParser` include a built-in option for generating completion scripts, with support for Bash and Z shell. To generate completions, run your command with the `--generate-completion-script` flag to generate completions for the autodetected shell, or with a value to generate completions for a specific shell.
8+
9+
```
10+
$ example --generate-completion-script bash
11+
#compdef example
12+
local context state state_descr line
13+
_example_commandname="example"
14+
typeset -A opt_args
15+
16+
_example() {
17+
integer ret=1
18+
local -a args
19+
...
20+
}
21+
22+
_example
23+
```
24+
25+
The correct method of installing a completion script depends on your shell and your configuration.
26+
27+
### Installing Zsh Completions
28+
29+
If you have [`oh-my-zsh`](https://ohmyz.sh) installed, you already have a directory of automatically loading completion scripts — `.oh-my-zsh/completions`. Copy your new completion script to that directory.
30+
31+
Without `oh-my-zsh`, you'll need to add a path for completion scripts to your function path, and turn on completion script autoloading. First, add these lines to `~/.zshrc`:
32+
33+
```
34+
fpath=(~/.zsh/completion $fpath)
35+
autoload -U compinit
36+
compinit
37+
```
38+
39+
Next, create a directory at `~/.zsh/completion` and copy the completion script to the new directory.
40+
41+
### Installing Bash Completions
42+
43+
If you have [`bash-completion`](https://github.com/scop/bash-completion) installed, you can just copy your new completion script to the `/usr/local/etc/bash_completion.d` directory.
44+
45+
Without `bash-completion`, you'll need to source the completion script directly. Copy it to a directory such as `~/.bash_completions/`, and then add the following line to `~/.bash_profile` or `~/.bashrc`:
46+
47+
```
48+
source ~/.bash_completions/example.bash
49+
```
50+
51+
## Customizing Completions
52+
53+
`ArgumentParser` provides default completions for any types that it can. For example, an `@Option` property that is a `CaseIterable` type will automatically have the correct values as completion suggestions.
54+
55+
When declaring an option or argument, you can customize the completions that are offered by specifying a `CompletionKind`. With this completion kind you can specify that the value should be a file, a directory, or one of a list of strings:
56+
57+
```swift
58+
struct Example: ParsableCommand {
59+
@Option(help: "The file to read from.", completion: .file())
60+
var input: String
61+
62+
@Option(help: "The output directory.", completion: .directory)
63+
var outputDir: String
64+
65+
@Option(help: "The preferred file format.", completion: .list(["markdown", "rst"]))
66+
var format: String
67+
68+
enum CompressionType: String, CaseIterable, ExpressibleByArgument {
69+
case zip, gzip
70+
}
71+
72+
@Option(help: "The compression type to use.")
73+
var compression: CompressionType
74+
}
75+
```
76+
77+
The generated completion script will suggest only file names for the `--input` option, only directory names for `--output-dir`, and only the strings `markdown` and `rst` for `--format`. The `--compression` option uses the default completions for a `CaseIterable` type, so the completion script will suggest `zip` and `gzip`.
78+
79+
You can define the default completion kind for custom `ExpressibleByArgument` types by implementing `static var defaultCompletionKind: CompletionKind`. For example, any arguments or options with this `File` type will automatically use files for completions:
80+
81+
```swift
82+
struct File: Hashable, ExpressibleByArgument {
83+
var path: String
84+
85+
init?(argument: String) {
86+
self.path = argument
87+
}
88+
89+
static var defaultCompletionKind: CompletionKind {
90+
.file()
91+
}
92+
}
93+
```
94+
95+
For even more control over the suggested completions, you can specify a function that will be called during completion by using the `.custom` completion kind.
96+
97+
```swift
98+
func listExecutables(_ arguments: [String]) -> [String] {
99+
// Generate the list of executables in the current directory
100+
}
101+
102+
struct SwiftRun {
103+
@Option(help: "The target to execute.", completion: .custom(listExecutables))
104+
var target: String?
105+
}
106+
```
107+
108+
In this example, when a user requests completions for the `--target` option, the completion script runs the `SwiftRun` command-line tool with a special syntax, calling the `listExecutables` function with an array of the arguments given so far.

Examples/math/main.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ extension Math.Statistics {
9393
abstract: "Print the average of the values.",
9494
version: "1.5.0-alpha")
9595

96-
enum Kind: String, ExpressibleByArgument {
96+
enum Kind: String, ExpressibleByArgument, CaseIterable {
9797
case mean, median, mode
9898
}
9999

@@ -126,7 +126,7 @@ extension Math.Statistics {
126126
let sorted = values.sorted()
127127
let mid = sorted.count / 2
128128
if sorted.count.isMultiple(of: 2) {
129-
return sorted[mid - 1] + sorted[mid] / 2
129+
return (sorted[mid - 1] + sorted[mid]) / 2
130130
} else {
131131
return sorted[mid]
132132
}
@@ -186,6 +186,12 @@ extension Math.Statistics {
186186
static var configuration = CommandConfiguration(
187187
abstract: "Print the quantiles of the values (TBD).")
188188

189+
@Argument(help: .hidden, completion: .list(["alphabet", "alligator", "branch", "braggart"]))
190+
var oneOfFour: String?
191+
192+
@Argument(help: .hidden, completion: .custom { _ in ["alabaster", "breakfast", "crunch", "crash"] })
193+
var customArg: String?
194+
189195
@Argument(help: "A group of floating-point values to operate on.")
190196
var values: [Double] = []
191197

@@ -199,6 +205,20 @@ extension Math.Statistics {
199205
@Option(help: .hidden)
200206
var testCustomExitCode: Int32?
201207

208+
// These args are for testing custom completion scripts:
209+
@Option(help: .hidden, completion: .file(extensions: ["txt", "md"]))
210+
var file: String?
211+
@Option(help: .hidden, completion: .directory)
212+
var directory: String?
213+
214+
@Option(
215+
help: .hidden,
216+
completion: .shellCommand("head -100 /usr/share/dict/words | tail -50"))
217+
var shell: String?
218+
219+
@Option(help: .hidden, completion: .custom(customCompletion))
220+
var custom: String?
221+
202222
func validate() throws {
203223
if testSuccessExitCode {
204224
throw ExitCode.success
@@ -219,4 +239,10 @@ extension Math.Statistics {
219239
}
220240
}
221241

242+
func customCompletion(_ s: [String]) -> [String] {
243+
return (s.last ?? "").starts(with: "a")
244+
? ["aardvark", "aaaaalbert"]
245+
: ["hello", "helicopter", "heliotrope"]
246+
}
247+
222248
Math.main()

Sources/ArgumentParser/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
add_library(ArgumentParser
2+
Completions/BashCompletionsGenerator.swift
3+
Completions/CompletionsGenerator.swift
4+
Completions/ZshCompletionsGenerator.swift
5+
26
"Parsable Properties/Argument.swift"
37
"Parsable Properties/ArgumentHelp.swift"
8+
"Parsable Properties/CompletionKind.swift"
49
"Parsable Properties/Errors.swift"
510
"Parsable Properties/Flag.swift"
611
"Parsable Properties/NameSpecification.swift"
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
struct BashCompletionsGenerator {
13+
/// Generates a Bash completion script for the given command.
14+
static func generateCompletionScript(_ type: ParsableCommand.Type) -> String {
15+
// TODO: Add a check to see if the command is installed where we expect?
16+
let initialFunctionName = [type].completionFunctionName()
17+
return """
18+
#!/bin/bash
19+
20+
\(generateCompletionFunction([type]))
21+
22+
complete -F \(initialFunctionName) \(type._commandName)
23+
"""
24+
}
25+
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()
30+
31+
// The root command gets a different treatment for the parsing index.
32+
let isRootCommand = commands.count == 1
33+
let dollarOne = isRootCommand ? "1" : "$1"
34+
let subcommandArgument = isRootCommand ? "2" : "$(($1+1))"
35+
36+
// Include 'help' in the list of subcommands for the root command.
37+
var subcommands = type.configuration.subcommands
38+
if !subcommands.isEmpty && isRootCommand {
39+
subcommands.append(HelpCommand.self)
40+
}
41+
42+
// Generate the words that are available at the "top level" of this
43+
// command — these are the dash-prefixed names of options and flags as well
44+
// as all the subcommand names.
45+
let completionWords = generateArgumentWords(commands)
46+
+ subcommands.map { $0._commandName }
47+
// FIXME: These shouldn't be hard-coded, since they're overridable
48+
+ ["-h", "--help"]
49+
50+
// Generate additional top-level completions — these are completion lists
51+
// or custom function-based word lists from positional arguments.
52+
let additionalCompletions = generateArgumentCompletions(commands)
53+
54+
// Start building the resulting function code.
55+
var result = "\(functionName)() {\n"
56+
57+
// The function that represents the root command has some additional setup
58+
// that other command functions don't need.
59+
if isRootCommand {
60+
result += """
61+
cur="${COMP_WORDS[COMP_CWORD]}"
62+
prev="${COMP_WORDS[COMP_CWORD-1]}"
63+
COMPREPLY=()
64+
65+
""".indentingEachLine(by: 4)
66+
}
67+
68+
// Start by declaring a local var for the top-level completions.
69+
// Return immediately if the completion matching hasn't moved further.
70+
result += " opts=\"\(completionWords.joined(separator: " "))\"\n"
71+
for line in additionalCompletions {
72+
result += " opts=\"$opts \(line)\"\n"
73+
}
74+
75+
result += """
76+
if [[ $COMP_CWORD == "\(dollarOne)" ]]; then
77+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
78+
return
79+
fi
80+
81+
"""
82+
83+
// Generate the case pattern-matching statements for option values.
84+
// If there aren't any, skip the case block altogether.
85+
let optionHandlers = generateOptionHandlers(commands)
86+
if !optionHandlers.isEmpty {
87+
result += """
88+
case $prev in
89+
\(optionHandlers.indentingEachLine(by: 4))
90+
esac
91+
""".indentingEachLine(by: 4) + "\n"
92+
}
93+
94+
// Build out completions for the subcommands.
95+
if !subcommands.isEmpty {
96+
// Subcommands have their own case statement that delegates out to
97+
// the subcommand completion functions.
98+
result += " case ${COMP_WORDS[\(dollarOne)]} in\n"
99+
for subcommand in subcommands {
100+
result += """
101+
(\(subcommand._commandName))
102+
\(functionName)_\(subcommand._commandName) \(subcommandArgument)
103+
return
104+
;;
105+
106+
"""
107+
.indentingEachLine(by: 8)
108+
}
109+
result += " esac\n"
110+
}
111+
112+
// Finish off the function.
113+
result += """
114+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
115+
}
116+
117+
"""
118+
119+
return result +
120+
subcommands
121+
.map { generateCompletionFunction(commands + [$0]) }
122+
.joined()
123+
}
124+
125+
/// Returns the option and flag names that can be top-level completions.
126+
fileprivate static func generateArgumentWords(_ commands: [ParsableCommand.Type]) -> [String] {
127+
ArgumentSet(commands.last!)
128+
.flatMap { $0.bashCompletionWords() }
129+
}
130+
131+
/// Returns additional top-level completions from positional arguments.
132+
///
133+
/// These consist of completions that are defined as `.list` or `.custom`.
134+
fileprivate static func generateArgumentCompletions(_ commands: [ParsableCommand.Type]) -> [String] {
135+
ArgumentSet(commands.last!)
136+
.compactMap { arg -> String? in
137+
guard arg.isPositional else { return nil }
138+
139+
switch arg.completion.kind {
140+
case .default, .file, .directory:
141+
return nil
142+
case .list(let list):
143+
return list.joined(separator: " ")
144+
case .shellCommand(let command):
145+
return "$(\(command))"
146+
case .custom:
147+
// Generate a call back into the command to retrieve a completions list
148+
let commandName = commands.first!._commandName
149+
let subcommandNames = commands.dropFirst().map { $0._commandName }.joined(separator: " ")
150+
// TODO: Make this work for @Arguments
151+
let argumentName = arg.preferredNameForSynopsis?.synopsisString
152+
?? arg.help.keys.first?.rawValue ?? "---"
153+
154+
return """
155+
$(\(commandName) ---completion \(subcommandNames) -- \(argumentName) "$COMP_WORDS")
156+
"""
157+
}
158+
}
159+
}
160+
161+
/// Returns the case-matching statements for supplying completions after an option or flag.
162+
fileprivate static func generateOptionHandlers(_ commands: [ParsableCommand.Type]) -> String {
163+
ArgumentSet(commands.last!)
164+
.compactMap { arg -> String? in
165+
let words = arg.bashCompletionWords()
166+
if words.isEmpty { return nil }
167+
168+
// Flags don't take a value, so we don't provide follow-on completions.
169+
if arg.isNullary { return nil }
170+
171+
return """
172+
\(arg.bashCompletionWords().joined(separator: "|")))
173+
\(arg.bashValueCompletion(commands).indentingEachLine(by: 4))
174+
return
175+
;;
176+
"""
177+
}
178+
.joined(separator: "\n")
179+
}
180+
}
181+
182+
extension ArgumentDefinition {
183+
/// Returns the different completion names for this argument.
184+
fileprivate func bashCompletionWords() -> [String] {
185+
names.map { $0.synopsisString }
186+
}
187+
188+
/// Returns the bash completions that can follow this argument's `--name`.
189+
fileprivate func bashValueCompletion(_ commands: [ParsableCommand.Type]) -> String {
190+
switch completion.kind {
191+
case .default:
192+
return ""
193+
194+
case .file(_):
195+
// TODO: Use '_filedir' when available
196+
// FIXME: Use the extensions array
197+
return #"COMPREPLY=( $(compgen -f -- "$cur") )"#
198+
199+
case .directory:
200+
return #"COMPREPLY=( $(compgen -d -- "$cur") )"#
201+
202+
case .list(let list):
203+
return #"COMPREPLY=( $(compgen -W "\#(list.joined(separator: " "))" -- "$cur") )"#
204+
205+
case .shellCommand(let command):
206+
return "COMPREPLY=( $(\(command)) )"
207+
208+
case .custom:
209+
// Generate a call back into the command to retrieve a completions list
210+
let commandName = commands.first!._commandName
211+
return #"COMPREPLY=( $(compgen -W "$(\#(commandName) \#(customCompletionCall(commands)) "$COMP_WORDS")" -- "$cur") )"#
212+
}
213+
}
214+
}

0 commit comments

Comments
 (0)