The Interactive Swift Argument Parser Guide - Part III: Options, Validation & Exiting

Published: May 2, 2024

Welcome to the third and final part of this series, the interactive guide to the Swift Argument Parser.

After the first part covered basics and argument, and the second explored flags and name specifications, this post will explain options, option groups, input validation, and finally how to perform exit.

Options

While arguments do not contain an associated name and are parsed by their order in a command, and flags work like “switches”, turning on or off a specific behavior, options are different. An option is a named value, where the name and the value are passed together, such as --channel slack, or --depth 4.

Similar to arguments and flags, they use a property wrapper (the @Option in this case), and can also be optional, required, or have a default value in case none is passed.

To illustrate, here is a program that could send a message:

@main
struct SendMessage: ParsableCommand {
    @Option var message: String
    @Option var retries = 3
    @Option var title: String?

    // implement run
}

Notice the following things:

  • The message is always required
  • Although retries is not an optional Int, the user can omit it as it has a default value
  • Title is optional (String?), and therefore is not required

Option Basics: Interactive Example

You can play with the program the code above generates to understand the basics of @Option, trying the following commands in the terminal simulator below.

  • send-message
  • send-message --message "I found this on the web!" or any other message (don’t forget the double quotes)
  • send-message --title "Build succeeded" --message "Archived and submitted to the store"
  • Add a number for retries, --retries 2, and then check what happens if you pass a string instead
Welcome to the Swift Toolkit Terminal Simulator

Customizing with ExpressibleByArgument

Sometimes you might want to constrain an option variable to a specific set of values. You have guessed it right - enums are perfect for that!

Continuing on the example above, your tool can offer specific channels to send your message through. Imagine you want to add two options, Slack and Telegram:

enum Channel: String {
    case slack
    case telegram
}

Now, if you try to do add an option to a variable of type Channel, the compiler will show you the following error:

A default value must be provided unless the value type conforms to ExpressibleByArgument

This means that the argument parser is not able to know how to map between a given command line value (a string) to a Channel, even though the enum raw value type is a string. To solve this exact issue, the argument parser provides the ExpressibleByArgument protocol.

By the protocol definition, ExpressibleByArgument requires an initializer with a string parameter, but fortunately the argument parser provides a default implementation for enums that are RawRepresentable by a string (and actually, also by an integer, a float, a double, or a bool). This is perfect for our use case.

By conforming Channel to ExpressibleByArgument, the implementation comes for free. And another freebie, when conforming also to CaseIterable, the argument parser is able to tell the user all the possible string values that Channel accepts.

enum Channel: String, ExpressibleByArgument, CaseIterable {
    case slack
    case telegram
}

@main
struct SendMessage: ParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "Learn how ExpressibleByArgument can improve your Options"
    )

    @Option var channel: Channel
    @Option var message: String = "Build succeeded"
    
    // implement run
}

Option + ExpressibleByArgument: Interactive Example

The code from the previous example was modified a bit, and uses the sample code you see right above here.

  • send-message
  • send-message --channel whatsapp
  • send-message --channel slack
Welcome to the Swift Toolkit Terminal Simulator

Using Arrays

Both Argument and Option support arrays as their types. Following the previous example:

@Argument var messages: [String]
@Option var channels: [Channel]

Name Specification

In the previous part of these series, we saw that Flags can use a single character as key, such as -v and -d as alternatives for --verbose and --debug respectively. In the same way, Option also accepts a NameSpecification parameter in its initializer.

A classic example is using -i and -o as shorthands for --input and --output:

struct ImageFilter: ParsableCommand {
    @Option(name: .shortAndLong)
    var input: String
    
    @Option(name: .shortAndLong)
    var output: String
}

Options with Short Names: Interactive Example

The code above represents a tool that could take an image from an input path, apply a filter, and save it to an output path. Try the following commands below to understand how options short names can be used:

  • image-filter
  • image-filter --input "input.png" --output "output.png"
  • image-filter -i "input.png" -o "output.png"
Welcome to the Swift Toolkit Terminal Simulator

Option Groups

When creating multiple commands or subcommands, you might end up duplicating some arguments, flags and options. When a set of arguments can be reused across commands, the OptionGroup property wrapper can be very useful.

Imagine you have the following arguments, and a few different subcommands that require them:

@Option(name: .shortAndLong)
var logLevel: LogLevel

@Flag(name: .shortAndLong)
var clearDerivedData = false

One option would be to use both of these properties in all the commands. For one, two, or even three commands, that’s fine. But here’s a better way to reuse them.

First, declare a struct to contain these properties. Then, make it conform to the ParsableArguments protocol:

struct SharedOptions: ParsableArguments {
    @Option(name: .shortAndLong)
    var logLevel: LogLevel

    @Flag(name: .shortAndLong)
    var clearDerivedData = false
}

Now, in order to access them in your commands, use the OptionGroup property wrapper:

@main
struct Builder: ParsableCommand {
    static let configuration = CommandConfiguration(
        subcommands: [BuildCommand.self, RunCommand.self, ArchiveCommand.self]
    )
}

struct Run: ParsableCommand {
    @OptionGroup var options: SharedOptions
    
    // implement run
}

// same for other commands (Build and Archive)

Now, both properties are accessible in Build, Run, and Archive under the options property.

Option Groups: Interactive Example

The code above represents a builder tool that builds, runs or archives your app. The following commands below can help you understand how :

  • builder
  • builder run --help
  • builder archive -l info -c
  • builder build -l warning
Welcome to the Swift Toolkit Terminal Simulator

Exiting - With or Without Errors

What subject would be a better fit for finishing this series, other than exiting your tool? Exiting earlier can happen in two conditions:

  • If something went wrong before or while running a command (such as a given file path that is invalid), it should throw an error - leading to a proper message to be displayed and an exit code different than zero.
  • In the other hand, even when no errors happen, the command might be able to stop earlier. For example, if all a command does is to download a file, but this file is already cached in the disk, it can exit early. In such cases, the status code 0 is used.

For both cases, the argument parser provides a few different options.

Throwing Errors or CleanExit

The argument parser wraps your command’s run() function in a do/catch block, and any errors thrown there are handled and forwarded to the exit() function. There, it prepares the error message, prints it to the standard error and exits the program with the given error code.

In a smart move, to allow early exits to use the throw mechanism, the argument parser provides a type called CleanExit. Although it conforms to the Error protocol, it’s not treated at such for the exit code and stderr purposes - an associated messages is printed regularly to the standard output, and an exit code of 0 is used.

Imagine a tool that downloads a file:

struct DownloadFileCommand: AsyncParsableCommand {
    func run() throws {
        guard isAuthenticated else {
            // user is not authenticated
            throw MyToolError.loginRequired
        }

        guard !fileExists else {
            // file is already in disk
            throw CleanExit.message("File was already downloaded")
        }

        try await downloadFile()
    }
}

ExitCode

In cases where your tool takes care of printing error (or regular) messages, there’s another useful Error the argument parser provides. By throwing an ExitCode, your program will just exit with the correct code, without printing any message. Changing the example above slightly:

struct DownloadFileCommand: AsyncParsableCommand {
    func run() throws {
        // isAuthenticated() prints a message if the user is not logged in
        guard isAuthenticated() else {
            throw ExitCode.failure
        }

        // fileExists() prints a message if file is already on disk
        guard !fileExists() else {
            throw ExitCode.success
        }

        try await downloadFile()
    }
}

validate() and ValidationError

Until now, we saw examples of errors that can happen while your command is running. The argument parser, however, gives you an opportunity to validate the user input before running, in the validate() function.

This function allows you throwing errors whenever the command properties do not match its expectations, and when that’s the case, it will avoid running the command.

@Option var message: String = "Hello, reader"
@Option var count: Int

func validate() throws {
    if message.isEmpty {
       throw ValidationError("message cannot be empty")
    }

    guard count > 0, count <= 5 else {
        throw ValidationError("count must be in higher than 0, up to 5")
    }
}

Validation: Interactive Example

The validate() function in the example above is for a tool named echo-message. Use the following commands or your variations to see the output of each one:

  • echo-message
  • echo-message --count 7
  • echo-message --count 3 --message ""
  • echo-message --count 3
Welcome to the Swift Toolkit Terminal Simulator

Customizing Error Messages

In many situations, you might end up creating your own Error types, instead of relying on ValidationError, ExitCode, or CleanExit. For such cases, the argument parser leverages existing protocols from Swift’s Foundation to make your tool display better error messages:

Errors that conform to CustomStringConvertible or LocalizedError provide the best experience for users.

Both options require implementing only one property, and you can choose whatever feels best:

struct DownloadError: Error {
    let underlyingError: Error
}

// Using CustomStringConvertible, implement the `description` property:
extension DownloadError: CustomStringConvertible {
    var description: String {
        "Error downloading file: (underlyingError)"
    }
}

// Using LocalizedError, implement the `errorDescription` property:
extension DownloadError: CustomStringConvertible {
    var errorDescription: String? {
        "Error downloading file: (underlyingError)"
    }
}

Explore Further

If you reached until here - congratulations, and thank you!. If you have any comments, questions or suggestions, please don’t hesitate to ping SwiftToolkit at X or Mastodon.

You can check out the sample code used for the examples in this repo.

Finally, you might also read the Swift Argument Parser docs on the following topics covered in this part:

You can keep exploring the documentation to find some gems that we didn’t cover here, such as command completions, and arguments and options initializers with a transform parameter.

See you at the next post. Have a good one!
Pure human brain content, no Generative AI added
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024