Command-line argument parsing using Swift Package Manager's SPMUtility module Nov 21 2019

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 SPMUtility 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

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).

Alright, but how can we define an argument for our parser?

Argument definition

An argument has the following properties:

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 SPMUtility 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.5.0"),
    ],
    targets: [
        .target(
            name: "ap",
            dependencies: ["SPMUtility"]),
        .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 SPMUtility, it was renamed from Utility, and I think it might change to TSCUtility. 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:

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 SPMUtility

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 Basic      // For Basic.stdoutStream
import SPMUtility

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 = Basic.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


** There is no comment system yet, but you can send me a message on twitter @rderik or send me an email: derik[at]rderik[dot]com.