Command-line argument parsing using Swift Package Manager's TSCUtility module Nov 21 2019 Latest Update: Jul 7 2020
If you've used the Swift Package Manager, you have interacted with its handy command-line tool. When creating command-line tools, we strive to provide an easy to use interface. One of the main characteristics of a good CLI tool is how it handles parameters. In this post, I'll show you how to use Swift Package Manager's TSCUtility
module, and especially ArgumentParser
to parse arguments for your swift command-line tools.
Let's start by defining the common types of arguments we get in command-line tools:
**Note: You can find the full code on the GitHub Repository
**Note: If you want to read about other argument parser options I've written about:
- Understanding the Swift Argument Parser and working with STDIN
- Building a CLI tool using Swift and Vapor's Console module
Table of Contents
Argument types
Most non-trivial command-line tools work with arguments. Let's see some examples and classifications. I don't think there is a general naming standard so I'll name them as I know them (If you know about a well-defined standard let me know).
Value Arguments
These arguments come in the form:
1
$ command --arg_name value
The --arg_name
is usually referred to as name. There is a short name, which is the equivalent but only one letter.
1
$ command -n value
In the example, we are using the short name -n
to identify the argument.
The idea behind this is to have a descriptive name when the user is exploring the command. And give the power users a short name to save some typing (and shorten the length of command).
Flags or toggles
Flags or toggles don't require a value; their existence in the arguments give enough information to make them useful. For example:
1
$ ls -l
The -l
flag indicates that we want the long description applied to our ls
command.
Positional arguments
Sometimes it is not necessary to use a named parameter to pass values so we can receive an argument and identify it only by its position. For example, let's look at ls
synopsis:
1
ls [-ABCFGHLOPRSTUW@abcdefghiklmnopqrstuwx1%] [file ...]
This means that it can have all the optional flags and the last arguments are interpreted as the files to list.
List of arguments
We can pass one argument or a list of arguments. As you saw in the previous example of ls
, it supports multiple files. This is an example of a list of arguments. They usually appear a the end of the command to keep the interface simple to understand.
Ok, that was a brief explanation on argument types. Let's see how SPM's ArgumentParser
classifies them.
SPM ArgumentParser
's argument types
The ArgumentParser
models the arguments into two types OptionArgument
and PositionalArgument
. With those two types, we can model all the argument types we saw in the previous section (value arguments, flags, positional arguments, list of arguments).
- Option Argument: it's an optional argument that is not affected by the position. What identifies the argument is the name (e.g.
--name
) or the short name (e.g.-n
). After the name, it is followed by its value, unless it is a flag. - Positional Argument: After creating the parser, we add the arguments it supports. This type of argument will be expected to be passed to the command-line tool in the exact order that it was added to the parser.
Alright, but how can we define an argument for our parser?
Argument definition
An argument has the following properties:
name
: the name of the argument. (Used in the long format, e.g.--name
).shortName
: (optional) The short name of the argument.strategy
: the parsing strategy for lists of arguments.oneByOne
: If we have an argument that can appear multiple times we'll assume that the name of the argument appears every time before the value (e.g.-n name1 -n name2 -l otherArgument -n name3
).-
upToNextOption
: the argument list will come after the name of the argument and end up to the next option (e.g.-n name1 name2 name3 -l other argument
). remaining
: gets everything that comes after as part of the argument list, it doesn't matter if it could be other flags (e.g.-n name1 name2 -l other
will return an array["name1", "name2", "-l", "other"]
)
isOptional
: boolean that defines if the argument is optional.usage
: the text the describes the argument, used for generating the help message.completion
: the SPM supports completion for Bash and ZSH. It can generate the appropriate code for the completion. The default options are:none
: no completion needed.unspecified
: this will also not do any completion.values
: we can define certain options that completion script will show when matching this argument.filename
: will display matching filenames as completion options.function
: defines a custom function to generate the completion options.
Alright, enough theory, let's see all this in action.
Creating a command-line tool
Let's start by creating a new directory and initialising our project for our tool. We'll call it ap
(it stands for Argument Parser).
1
2
3
$ mkdir ap
$ cd ap
$ swift package init --type executable
First, we need to add the dependency to the SPM and SwiftPM
to our Package.swift
. Your Package.swift
will have the following content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift
// required to build this package.
import PackageDescription
let package = Package(
name: "ap",
dependencies: [
.package(url: "https://github.com/apple/swift-package-manager.git",
from: "0.6.0"),
],
targets: [
.target(
name: "ap",
dependencies: ["SwiftPM"]),
.testTarget(
name: "apTests",
dependencies: ["ap"]),
]
)
At the time of writing the latest release for SPM is 0.5.0
you can check the latest here:
https://github.com/apple/swift-package-manager/releases/latest
The module is called TSCUtility
, it was renamed from SPMUtility
on release 0.6.0
, and before that it was called Utility
. So be sure to check the correct name for the release version that you are using.
We are going to be working only on main.swift
, and the examples will show the following types of arguments:
- Value arguments
- Flags (or toggles)
- Positional arguments
- Lists of arguments
Before we look at the examples, remember that the parser throws exceptions if it finds any errors with the arguments. What this means is that you are in charge of catching them.
A general "catch-all" code will be the following:
1
2
3
4
5
6
7
8
9
10
11
do {
//your parser and argument code goes here
} catch ArgumentParserError.expectedValue(let value) {
print("Missing value for argument \(value).")
} catch ArgumentParserError.expectedArguments(let parser, let stringArray) {
print("Parser: \(parser) Missing arguments: \(stringArray.joined()).")
} catch {
print(error.localizedDescription)
}
We'll need to first start by creating the parser. In your main.swift
add the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
do {
let parser = ArgumentParser(commandName: "ap",
usage: "ap",
overview: "The command is used for argument parsing",
seeAlso: "getopt(1)")
let argsv = Array(CommandLine.arguments.dropFirst())
let parguments = try parser.parse(argsv)
} catch ArgumentParserError.expectedValue(let value) {
print("Missing value for argument \(value).")
} catch ArgumentParserError.expectedArguments(let parser, let stringArray) {
print("Parser: \(parser) Missing arguments: \(stringArray.joined()).")
} catch {
print(error.localizedDescription)
}
We obtain the arguments passed to our tool using CommandLine.arguments
, and we drop the first argument that is the name of the current script. There are times when it's useful to validate the name of the script, but we won't use it for this post's examples, so we'll drop it.
Then we run the parse
function and that it.
Ok, let's create some value arguments.
Value arguments
A value argument will have the following form:
1
$ command [--arg-name|-n][=]VALUE
Remember it will have a name and an optional short name. Let's create it and add it to our parser:
1
2
3
4
let input = parser.add(option: "--input", shortName: "-i",
kind: String.self,
usage: "A filename containing words",
completion: .filename)
This will allow us to support the following arguments:
1
2
3
4
$ ap -i filename
$ ap -i=filename
$ ap --input filename
$ ap --input=filename
When we generate our completion scripts and add them to our bash configuration, we'll be able to hit tab after that argument, and we'll get options with the files that match our argument.
To validate the existence of this argument, we can try to get it from the parsed arguments (in the variable we created named parguments
).
1
2
3
if let filename = parguments.get(input) {
print("Using filename: \(filename)")
}
Flags/Toggles
Let's create a flag. We are going to use this flag to generate our completion scripts for bash.
1
2
3
4
5
let generateBashCompletion = parser.add(option: "--generate-bash-completion",
shortName: "-g",
kind: Bool.self,
usage: "Generates bash completion script",
completion: ShellCompletion.none)
To declare a flag, we define the argument kind to be Bool
. This will allow us to have the following flag:
1
2
$ ap -g
$ ap --generate-bash-completion
To validate the existence of this argument, we can try to get it from the parsed arguments (in the variable we created named parguments
).
1
2
3
if let generate = parguments.get(generateBashCompletion), generate {
print("Generate bash completion script")
}
Positional arguments
A positional argument will be expected to appear in the order that we add it to the parser. For example, imagine we already added two arguments, if we add a positional argument now it'll expect it to be third. This gets a little bit confusing if you have optional values before it because they "might" not exist. But if there are many positional arguments, they'll be expected in that order. We'll add a message argument:
1
2
3
4
5
let message = parser.add(positional: "message",
kind: String.self,
optional: false,
usage: "This is what the message should say",
completion: ShellCompletion.none)
Another small detail is that you can only have one positional argument if the positional argument is optional. This is because it'll be hard to identify which argument it is.
Assuming we have the previous two arguments, we could expect the message
argument like the following:
1
2
3
$ ap -i filename MESSAGE
$ ap MESSAGE
$ ap MESSAGE -i filename
If we had another positional argument, it would have to come after message.
To verify that we have the message
argument, we could use the following code:
1
2
3
if let message = parguments.get(message) {
print("Using message: \(message)")
}
List of arguments
We can define an argument that can have multiple values. What this means is that it supports the following argument:
1
2
3
4
5
6
7
8
9
10
11
12
# One by one
$ command -n name1 -n name2 -n name3
# ["name1", "name2", "name3"]
# up to next option
$ command -n name1 name2 name3
# ["name1", "name2", "name3"]
# remaining (it stops parsing anything after)
$ command -n name1 name2 name3 --other-option value
# ["name1", "name2", "name3", "--other-option", "value"]
To define an argument as a one by one or as up to next option, we use the strategy
property.
The code for the different behaviours will be:
1
2
3
4
5
6
let names = parser.add(option: "--names",
shortName: "-n",
kind: [String].self,
strategy: .oneByOne,
usage: "Multiple names",
completion: ShellCompletion.none)
1
2
3
4
5
6
let names = parser.add(option: "--name",
shortName: "-n",
kind: [String].self,
strategy: .upToNextOption,
usage: "Multiple names",
completion: ShellCompletion.none)
1
2
3
4
5
6
let names = parser.add(option: "--name",
shortName: "-n",
kind: [String].self,
strategy: .remaining,
usage: "Multiple names",
completion: ShellCompletion.none)
To verify that we have the name
argument, we could use the following code:
1
2
3
if let multipleNames = parguments.get(names) {
print("Using names: \(multipleNames)")
}
So the full main.swift
will look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import TSCUtility
do {
let parser = ArgumentParser(commandName: "ap",
usage: "ap",
overview: "The command is used for argument parsing",
seeAlso: "getopt(1)")
let input = parser.add(option: "--input",
shortName: "-i",
kind: String.self,
usage: "A filename containing words",
completion: .filename)
let generateBashCompletion = parser.add(option: "--generate-bash-completion",
shortName: "-g",
kind: Bool.self,
usage: "Generates bash completion script",
completion: ShellCompletion.none)
let message = parser.add(positional: "message",
kind: String.self,
optional: false,
usage: "This is what the message should say",
completion: ShellCompletion.none)
let names = parser.add(option: "--names",
shortName: "-n",
kind: [String].self,
strategy: .oneByOne,
usage: "Multiple names",
completion: ShellCompletion.none)
let argsv = Array(CommandLine.arguments.dropFirst())
let parguments = try parser.parse(argsv)
if let filename = parguments.get(input) {
print("Using filename: \(filename)")
}
if let generate = parguments.get(generateBashCompletion), generate {
print("Generate bash completion script")
}
if let message = parguments.get(message) {
print("Using message: \(message)")
}
if let multipleNames = parguments.get(names) {
print("Using names: \(multipleNames)")
}
} catch ArgumentParserError.expectedValue(let value) {
print("Missing value for argument \(value).")
} catch ArgumentParserError.expectedArguments(let parser, let stringArray) {
print("Parser: \(parser) Missing arguments: \(stringArray.joined()).")
} catch {
print(error.localizedDescription)
}
You can run it with --help
and get the full description:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ swift run ap --help
OVERVIEW: The command is used for argument parsing
USAGE: ap ap
OPTIONS:
--generate-bash-completion, -g
Generates bash completion script
--input, -i A filename containing words
--names, -n Multiple names
--help Display available options
POSITIONAL ARGUMENTS:
message This is what the message should say
SEE ALSO: getopt(1)
Very pretty.
Now you can test and explore all the options. For example:
1
2
3
4
$ swift run ap --input inputFile.txt -n Juan MENSAJE -n Miguel -n Alicia -n Marta
Using filename: inputFile.txt
Using message: MENSAJE
Using names: ["Juan", "Miguel", "Alicia", "Marta"]
Ok, that's it hope you find it useful.
Final thoughts
The Swift Package Manager provides a lot of useful modules that we can use when building command-line tools. In a previous post, I wrote how to build a command-line tool using Vapor. For me, I think I prefer how SPM modules can be used individually very easily. But I encourage you to explore Vapor, give it a chance you might find it suits your coding preferences.
One word of caution, the SPM modules we used are not stable, they might be changed without notice on a future release, and you'll have to fix your code if you want to upgrade to the new version.
One handy function provided by the SPM is the ability to generate completion scripts for bash and zsh. I'll show you how to do it for bash.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import Foundation // For EXIT_SUCCESS
import TSCBasic // For TSCBasic.stdoutStream
import TSCUtility
do {
let parser = ArgumentParser(commandName: "ap",
usage: "ap",
overview: "The command is used for argument parsing",
seeAlso: "getopt(1)")
let input = parser.add(option: "--input",
shortName: "-i",
kind: String.self,
usage: "A filename containing words",
completion: .filename)
let generateBashCompletion = parser.add(option: "--generate-bash-completion",
shortName: "-g",
kind: Bool.self,
usage: "Generates bash completion script",
completion: ShellCompletion.none)
let message = parser.add(positional: "message",
kind: String.self,
optional: true,
usage: "This is what the message should say",
completion: ShellCompletion.none)
let names = parser.add(option: "--names",
shortName: "-n",
kind: [String].self,
strategy: .oneByOne,
usage: "Multiple names",
completion: ShellCompletion.none)
let lastNames = parser.add(option: "--last-names",
shortName: "-l",
kind: String.self,
usage: "List of last names",
completion: ShellCompletion.values([
("Ramirez","Like the dev Derik Ramirez"),
("Garcia", "Like the writer Gabriel Garcia Marquez"),
("Allende", "Like the Writer Isabel Allende")]))
let argsv = Array(CommandLine.arguments.dropFirst())
let parguments = try parser.parse(argsv)
if let generate = parguments.get(generateBashCompletion), generate {
let stdoutStream = TSCBasic.stdoutStream
stdoutStream <<< """
#!/bin/bash
_ap_wrapper()
{
declare -a cur prev
cur=\"${COMP_WORDS[COMP_CWORD]}\"
prev=\"${COMP_WORDS[COMP_CWORD-1]}\"
COMPREPLY=()
_ap
}
"""
parser.generateCompletionScript(for: .bash, on: stdoutStream)
stdoutStream <<< """
complete -F _ap_wrapper ap
"""
stdoutStream.flush()
exit(EXIT_SUCCESS)
}
if let filename = parguments.get(input) {
print("Using filename: \(filename)")
}
if let message = parguments.get(message) {
print("Using message: \(message)")
}
if let multipleNames = parguments.get(names) {
print("Using names: \(multipleNames)")
}
if let multipleLastNames = parguments.get(lastNames) {
print("Using last names: \(multipleLastNames)")
}
} catch ArgumentParserError.expectedValue(let value) {
print("Missing value for argument \(value).")
} catch ArgumentParserError.expectedArguments(let parser, let stringArray) {
print("Parser: \(parser) Missing arguments: \(stringArray.joined()).")
} catch {
print(error.localizedDescription)
}
I moved the generate
argument right after the argument parsing because I want only to generate the bash completion script. If we don't do that, we might get some output from the other cases. Also, I added another argument "Last names", it expects defined values.
The generateCompletionScript
function only returns the function for our parser, but we still need to do some set up. If you want to check other examples Completions+bash or Completion+zsh would be helpful to understand how to use generateCompeltionScript
.
You can now play with that completion script in you current shell by running the script:
1
2
$ swift run ap -g > ap_completions.sh
$ source ap_completions.sh
Now you can type:
1
2
3
4
5
6
7
8
$ ap -<tab>
--generate-bash-completion --last-names -g -l
--input --names -i -n
$ ap --last-names <tab>
Allende Garcia Ramirez
$ ap --last-names A<tab>
Allende Garcia Ramirez
$ ap --last-names Allende
This won't execute your command. It only helps your bash to autocomplete. You'll still need to add it to the PATH
. If you want to learn more about bash's autocomplete scripts read this article: Debian administration ORG - An introduction to bash completion.
I wish I had more time to write more about other SPM modules for command-line tool creation, but that's it for today. I hope you enjoy the article.
Let me know if you have any comments, and remember feedback is always welcome.
**Note: You can find the full code on the GitHub Repository
Related topics/notes of interest
- A nice article on Bash completion
- Our command-line tools must behave as expected by command-line users. I've written a few posts on Ruby scripts, that explain some important aspects of command-line tools. I encourage you to have a look:
- If you are curious about how we use to do Argument parsing in C, check this Stack Overflow answer.