SwiftArgumentParser: A Swift mod
During the WWDC 2021, Apple promoted for the first time its new SwiftArgumentParser library. It aims to help creating command-line tools in Swift.
In just a few lines of code, we can build a fully documented command-line tool. Like in this example from its tiny (I’m pretty sure they made it tiny on purpose) README file:
We express our CLI as a graceful command tree while SwiftArgumentParser
takes care of all the implementation details: it parses the command-line arguments, instantiates the target command, fulfills its arguments based on their names and finally runs it. It can even generate a built-in help description if a help option is passed:
Apple brands its library as straightforward.
This achievement is big: even if you have never used the library, chances are you can already tell what the code does and how you could use it to create your own CLI command out of it.
Have you ever seen anything as straightfordward in Swift?
SwiftArgumentParser
should remind you of SwiftUI
. To create a new CLI option, you declare an @Option
property without writing any parsing code. An approach that is similar to the declarative syntax of SwiftUI
. In each case, thanks to this high level of abstraction, we only need to state what our code should do rather than writing it explicitly.
However, because Swift’s heavy focus on types, straightfordwardness
is usually not the first quality that comes to mind. Contrary to dynamic languages, such as Ruby, Swift strongly relies on compilation-time validations, making it a safer, but significantly less flexible, language.
In fact, even after pondering on this for a bit, I have no idea how to implement such a behavior using Swift. SwiftArgumentParser
is not a regular Swift library.
To corroborate, in the previous example, if instead of the main
method, we used the autogenerated empty initializer of Repeat
:
This state-of-the-art, Swift library would just… crash! (In this case, Argument
was initialized with only a basic help description, how could phrase
return a string out of it?)
It seems Apple has to bypass some Swift compiler safety checks to offer such an API. It decided to do it even if it could obviously lead to serious codebase issues. Something we, thorough Swift developers, are not used to doing when targeting a production environment. Something we are not used to seeing from Apple.
Starting from this point, I knew the SwiftArgumentParser
repository would contain some cryptic but powerful Swift usages. I had to deep dive inside it and figure out how Apple managed to make the magic happen.
Recreating the smallest SwiftArgumentParser API
There are a lot of blogposts out there on how to use SwiftArgumentParser
. In this one, I will instead highlight the main implementation tricks Apple used to provide such beautiful API and what is the cost of it.
To that purpose, I developed a basic version of the library from scratch: FBSwiftArgumentParser.
The library only reproduces the following key features:
- An
FBParsableCommand
protocol with amain
entry point able to parse the command line arguments and execute the program’s logic. - An
FBOption
String property wrapper to express the String options of the CLI. It supports default values. - A builtin support for help.
Similarly to the SwiftArgumentParser
, an example of FBArgumentParser
in a README
file would look like this:
And its usage like that:
Let’s see how to implement it step by step while keeping it straightforward.
Defining the main FBParsableCommand
protocol
Of course, our implementation starts with the library’s main protocol: FBParsableCommand
.
It is a very basic version of the ParsableCommand
. For example it does not support nesting commands nor contains those fancy properties to customize its help description. But that’s enough for now. As the original one, it will be used by the users of the library to declare their program logic.
We also define the same static main
entry point in an extension (the users are not supposed to override it) as a static method:
The execution steps of the main
method are described in the SwiftArgumentParser
’s README
file:
You kick off execution by calling your type’s static main() method. The SwiftArgumentParser library parses the command-line arguments (#1), instantiates your command type (#2), and then either executes your run() method or exits with a useful message (#3).
Let’s write those steps.
First, we create a FBCommandParser
object to encapsulate the command line arguments parsing code.
As we only support CLI option type arguments (“–option value” or “-o value”), we store those key-value arguments in a basic dictionary. We would need something more sophisticated if more argument types were supported, but this will do for now.
Then, we execute the run
method of the created instance and neatly exit the program if an error occured:
We purposedly left a huge TODO
in FBCommandParser
. We basically still have to implement the core of the program: instantiating the target command and filling its properties based on the parsed CLI arguments dictionary.
Instantiating client commands
FBParsableCommand
obviously needs an initiliazer constraint: main
is a static method and run
an instance one.
Adding a basic init(arguments: [String])
would work from an implementation perspective:
But it would require a lot work for the library’s users to implement it. The user would have to manually fulfill its properties based on the given dictionary. Our library should do all the heavy lifting.
This is where SwiftArgumentParser
shined for the first time. It used a very clever technic:
It uses
Decodable
to make the compiler generate all the argument parsing code on the client side in one fell swoop
In fact, if we consider the [String: String]
argument array as a basic formatted data, we can consider FBParsableCommand
as a custom data type. Thus if we can make the FBParsableCommand
inherit from Decodable
:
All the decoding code can be generated at compilation time right inside the command implementation!
Let’s see it in action:
We first define a basic custom Decoder
object able to return the value associated to the given argument property.
It is almost straightforward as the value associated to a CLI option is already mapped to its name by our FBCommandParser.parseArguments
method. We just need to use the decoding key as a search key in the parsed dictionary.
Then, we can use our custom Decoder
to instanciate the target command, thanks to its conformance to Decodable
:
Now, as long as the client command properties implement Decodable
:
The Swift compiler will automatically generate the appropriate Decoder
method calls to fulfill all the properties of the target command.
Notice though that FBRepeat
does not manage default values correctly yet. If we set a default value to a property:
It will be ignored by the generated implementation of the Decodable
protocol:
It seems like a small problem but it would force users to provide a custom implementation of Decodable
in order to add default argument. We do not want that. We need to provide the users with an easy way to customize the decoding of its command.
You’ve guessed it, it is time to introduce property wrappers!
Using property wrappers as argument descriptions
In SwiftArgumentParser
the information that we need to collect from the command line is defined using property wrappers. Each one of them matches to a type of argument (@Flag
, @Option
, @Argument
etc) and provides initiliazers to describe them. We can specify how to parse the argument, its potential default value, its help description etc.
Let’s add a similar declarative API in FBSwiftArgumentParser
and see how we can use it to tweak the parsing behavior.
We start with a basic FBOption
:
It has two public initializers so clients can declare two kinds of arguments:
FBOption.init(wrappedValue:)
describes an optional option with a default valueFBOption.init()
describes a required option
Its conformance to Decodable
is straightforward, thanks to singleValueContainer, so we keep the previous benefits provided by the Swift compiler:
As expected, our code is still as broken as before though:
When no value is specified, FBCommand.main
method still tries to decode the command using FBOption.init(from:)
without being able to provide a proper value for the phrase
argument. The declared default “Hello” value is ignored.
Let’s adapt our parsing code and take into account the potential default values provided by the property wrappers.
The two faces of the command property wrappers
This is probably the most confusing aspect of the implementation.
SwiftArgumentParser
property wrappers have two usages depending on their initiliazation
Because of their declarative interfaces, when decoded, a property wrapper provides a value that can be used by the user during the execution of the command. Otherwise, it only provides a description of itself, used internally, that describes how to decode it.
Let’s highlight those two aspects in FBOption
;
init()
andinit(wrappedValue:)
, used by the library’s clients, produces anFBOption
that describes how to parse its underlying String valueinit(from:)
, used internally, produces a decodedFBOption
with a valid wrapped value
In code, we can represent the two cases using an enum:
In the first case, FBOption
only wraps a FBOptionDefinition
:
In the other case, FBOption
stores its actual value:
As a result, FBOption
can only return a value if it is decoded:
This is the issue we faced in the introduction and obviously a tradeoff: FBOption
has two usages but only one non optional public API.
However, as long as the library clients rely on the main
method to execute their program, we can ensure only the autogenerated Decodable.init(from:)
initializer of the parsable command will be used to call the run
method. Thus each property wrappers will be decoded and return a value when the client tries to read a potential CLI option.
Furthermore, when a user declares a command, it now implicitly declares a list of FBArgumentDefinition
:
Let’s see how we can use them in our decoding code to ensure the optional values provided will be used when the decoding occurs.
Using property wrappers as reflective markers
Even if it seems at odds with its heavy focus on compile-time validation, Swift enables us to inspect, and work with, the members of a type — dynamically, at runtime. It is by using this uncommon capacity of Swift that Apple implemented the decoding of the property wrappers.
SwiftArgumentParser
uses metaprogramming
Let’s see how it works.
We first need a new constraint on the protocol:
This way, we force the user to provide a way to instantiate a command in a proper definition state, using the definition given by each property’s wrapper.
In fact, we could be confident that the users will only use descriptive wrapper initializers in this case:
Seeing the use of a decoding one, even if it compiles, would be pretty unusual:
Thanks to this assumption, as long as the user respects the contract, by instanciating a blank version of the target command, we can use FBOption
as a reflective marker and retrieve all its argument definitions:
Notice that we need to parse the property wrapper names to match them with their corresponding key, each property wrapper starts with an underscore prefix.
We can now easily retrieve all the potential default values of a command with their corresponding arguments:
By merging them with the initial CLI arguments dictionary, we can thus easily fill the potential gaps when initializing the target command, using this time its Decoding.init(from:)
initializer:
Thus when an optional option is used:
The @FBOption
wrapper will be decoded with its default value:
The decoding is fully functional!
Let’s implement the final feature: generating a help description when requested.
Generating command help
Thanks to the mirror inspection already in place, it will be easy. We just have to add some final touches to our API.
We add a FBCommandConfiguration
structure so the clients can provide a description of their commands:
And a help
parameter in each argument:
Based on the static definition of a command, we can now easily generate a detailed error anytime we catch a help argument:
And here we go! FBSwiftArgumentParser
is able to generate a description of our command when a help option is specified:
The drawback of the SwiftParserArgument implementation
Our basic version of SwiftArgumentParser
is small but it highlighted the main key tricks to create such a straightforward Swift library. Apple used a clever interlacing of operations executed at both compilation time and runtime.
At compilation time, all the command decoding code is generated on the client side thanks to the automatic conformance to Decodable
. At runtime, SwiftArgumentParser
parses the static definition of each command and adapts the decoding strategy consequently. This last step is permitted by an extended usage of property wrappers: it allows Apple to encapsulate and inject logic right inside the client objects declaration. I really liked it.
However, to use the API of SwiftArgumentParser
, clients have to follow an unusual list of agreements to ensure the program runs smoothly:
- All the properties of a command have to be property wrappers provided by the library
- We must not override the default decoding behavior of the commands
- We must not instantiate commands ourself
Such agreements can not be guaranteed by the Swift compiler. In fact, SwiftArgumentParser
counts more than twenty fatalError in its codebase. That is more than twenty manual checks that will lead to a crash if you are not respecting the usage contract.
That is why its implementation is hard to understand. In a regular codebase, targetting a production environment, we prefer to rely on the Swift compiler and therefore stay in the arguably safer environment it creates for us even if it means a more verbose code.
A Swift mod
With SwiftArgumentParser
, Apple obviously made a tradeoff and decided that straightforwardness takes precedence over safety when writing CLI tools.
The fact that SwiftArgumentParser
will always be at the top of any programming stack surely influenced the decision.
It reminds me of SwiftUI
. It has a beautiful Swift API but it hides a lot of unexpected behaviors.
Like SwiftUI
, SwiftArgumentParser
adds its own programming paradigm on top of Swift. By establishing rules that go way beyond traditional Swift library usage restrictions, like “commands should not use regular properties”, it extends the language itself, creating sort of a mod of Swift and raising our code to new heights.
Of course, as the Swift compiler only knows Swift principles, those libraries have to validate their own rules at runtime with technics using metaprogramming as reflection. It inevitably leads to unsafe misuses.
In 2021, Apple had to demystify SwiftUI. By revealing a bit of the SwiftUI implementation, Apple somehow agreed its usage of Swift can be confusing. We actually could not write such libraries using regular Swift - at least, using Swift as Apple wants us to use it, in a safe manner.
Still, chances are, those libraries are pioneers in the language features development rather than classic unsafe implementations. I will be not be suprised to see new Swift mods from Apple in the future.
As a side note, it is interesting to see how, after putting so much effort in building a safe language, Apple allows itself to bypass some of the compiler checks and write “flexible” Swift code, close to the good old dynamic typing of Objective-C.
What’s next?
You can find all the code in a dedicated repository alongside a more detailed version of it.
I discovered SwiftArgumentParser
when developing xcresource. You can take a look at it! It aims to facilitate downloading Xcode templates or snippets from git repositories.
Thanks for reading!