A Look Into ArgumentParser
The Swift team has recently announced ArgumentParser
, a new parse command-line argument library.
In previous entries we've covered how we can parse command-line arguments manually, and with TSCUtility
:
ArgumentParser
comes with an extensive and very well written documentation, please give it a read for a complete overview of the library API.
With the basics covered, in this article we're going to dive into how things are implemented under the hood, like we did for the Swift Preview Package.
ParsableCommand Protocol
When following the instructions, the first task is create a type conforming to the ParsableCommand
protocol:
public protocol ParsableCommand: ParsableArguments {
static var configuration: CommandConfiguration { get }
static var _commandName: String { get }
func run() throws
}
Snippet from ArgumentParser's
ParsableCommand.swift
.
_commandName
, which represents our tool name, is marked as internal: it's not for us to implement.
All these methods are implemented for us already, in our type we only need to take care of any custom logic: mainly the run()
behavior and, if necessary, extra configurations such as abstract and subcommand declarations. Here is the default implementation:
extension ParsableCommand {
public static var _commandName: String {
configuration.commandName ??
String(describing: Self.self).convertedToSnakeCase(separator: "-")
}
public static var configuration: CommandConfiguration {
CommandConfiguration()
}
public func run() throws {
throw CleanExit.helpRequest(self)
}
}
Snippet from ArgumentParser's
ParsableCommand.swift
In this default implementation we discover the first magic trick used in ArgumentParser:
how our type name transforms from camelCase
into a kebab-case
command line tool name:
String(describing: Self.self).convertedToSnakeCase(separator: "-")
Self
refers to the type conforming to the protocol, and using String(describing: Self.self)
(or String(describing: self)
, since _commandName
is a static property) will return us our type name as a String
.
Once we have the name, ArgumentParser has a convertedToSnakeCase(separator:)
function that takes care of the rest.
ParsableArguments Protocol
The ParsableArguments
protocol requires our types to conform to the ParsableArguments
protocol as well, let's take a look at that next:
public protocol ParsableArguments: Decodable {
init()
mutating func validate() throws
}
Snippet from ArgumentParser's
ParsableArguments.swift
Its definition is A type that can be parsed from a program's command-line arguments, which also explains the required Decodable
conformation:
the library has its own decoders, ArgumentDecoder
and SingleValueDecoder
, which are used later on, to decode the command-line arguments into something meaningful.
Both the initializer and the Decodable
conformation are synthesized, the only requirement left is the validate()
implementation, however the library offers a default implementation for us already:
extension ParsableArguments {
public mutating func validate() throws {}
}
Snippet from ArgumentParser's
ParsableArguments.swift
As a reminder,
validate()
is here for us to make sure that the parsed arguments are valid, not type-wise, but logic-wise.
The Static Main
When following the ArgumentParser instructions, the final step is to call .main()
in our type. This static method is not required from the ParsableCommand
(nor from ParsableArguments
etc) and we haven't defined it, it turns out that it's implemented as a public extension of ParsableCommand
:
extension ParsableCommand {
public static func main(_ arguments: [String]? = nil) -> Never {
do {
let command = try parseAsRoot(arguments)
try command.run()
exit()
} catch {
exit(withError: error)
}
}
}
Snippet from ArgumentParser's
ParsableCommand.swift
The method does two things:
- parse and initialize our command via a
parseAsRoot(_:)
function. - run it
All the magic happens in the first step, step 2 executes our run()
implementation (or the default implementation we've seen above).
parseAsRoot
The purpose of this method is solely to return an instance of ParsableCommand
, more specifically an instance of our type (conforming to ParsableCommand
):
public static func parseAsRoot(
_ arguments: [String]? = nil
) throws -> ParsableCommand {
var parser = CommandParser(self)
let arguments = arguments ?? Array(CommandLine.arguments.dropFirst())
var result = try parser.parse(arguments: arguments).get()
do {
try result.validate()
} catch {
throw CommandError(
commandStack: parser.commandStack,
parserError: ParserError.userValidationError(error))
}
return result
}
Snippet from ArgumentParser's
ParsableCommand.swift
We first obtain the input arguments via CommandLine.arguments
, and then we parse them via a new CommandParser
entity:
if the parse is successful, we validate the command line inputs (with ParsableArguments
's validate()
) and then return the new instance (of our ParsableCommand
type), ready to run.
CommandParser
CommandParser
structures our command as a tree: a command can contain zero or more subcommands, which can contain subcommands, which can contain subcommands, which... there's no limit set by ArgumentParser.
struct CommandParser {
let commandTree: Tree<ParsableCommand.Type>
var currentNode: Tree<ParsableCommand.Type>
var parsedValues: [(type: ParsableCommand.Type, decodedResult: ParsableCommand)] = []
var commandStack: [ParsableCommand.Type] {
let result = parsedValues.map { $0.type }
if currentNode.element == result.last {
return result
} else {
return result + [currentNode.element]
}
}
init(_ rootCommand: ParsableCommand.Type) {
self.commandTree = Tree(root: rootCommand)
self.currentNode = commandTree
// A command tree that has a depth greater than zero gets a `help`
// subcommand.
if !commandTree.isLeaf {
commandTree.addChild(Tree(HelpCommand.self))
}
}
}
Snippet from ArgumentParser's
CommandParser.swift
In the initializer we can see how every command line tool gets a
HelpCommand
.
Let's look at the parse(_:)
method, called by the ParsableCommand
's static main()
:
mutating func parse(arguments: [String]) -> Result<ParsableCommand, CommandError> {
var split: SplitArguments
do {
split = try SplitArguments(arguments: arguments)
} catch {
...
}
do {
try descendingParse(&split)
let result = try extractLastParsedValue(split)
...
return .success(result)
} catch {
...
}
}
Snippet from ArgumentParser's
CommandParser.swift
parse(arguments:)
can be split in three steps:
- Create a new instance of
SplitArguments
, a new entity, out of the input string arguments. - Translate the
SplitArguments
instance into aParsableCommand
instance. - Return it.
Step 1
This step turns the input string arguments into an instance of SplitArguments
:
struct SplitArguments {
var elements: [(index: Index, element: Element)]
var originalInput: [String]
}
Snippet from ArgumentParser's
SplitArguments.swift
This step has no knowledge about our command line tool definition.
SplitArguments
translates the input array of strings into something more meaningful for a command line tool input: options and values. Options are anything with a dash in the front, values are strings without a dash in front.
Here's the definition of SplitArguments
's Element
(with comments):
enum Element: Equatable {
case option(ParsedArgument) // something with a dash in the front
case value(String) // values
case terminator // --, special character
}
Snippet from ArgumentParser's
SplitArguments.swift
Where we introduce ParsedArgument
:
enum ParsedArgument: Equatable, CustomStringConvertible {
case name(Name) /// `--foo` or `-f`
case nameWithValue(Name, String) // `--foo=bar`
}
Snippet from ArgumentParser's
SplitArguments.swift
Lastly, we have the Index
definition:
struct Index: Hashable, Comparable {
var inputIndex: InputIndex
var subIndex: SubIndex
}
Snippet from ArgumentParser's
SplitArguments.swift
Each Index has an inputIndex
, which is the argument index in the original input, and a subIndex
: for example $ tool -af
has both -a
and -f
at the same index, but subindex of 0 and 1 respectively.
If you'd like to see the actual parsing, please see here.
Step 2
At this point we have our complete SplitArguments
instance and its time to run CommandParser
's descendingParse
:
internal mutating func descendingParse(_ split: inout SplitArguments) throws {
while true {
try parseCurrent(&split)
// Look for next command in the argument list.
if let nextCommand = consumeNextCommand(split: &split) {
currentNode = nextCommand
continue
}
// Look for the help flag before falling back to a default command.
try checkForHelpFlag(split)
// No command was found, so fall back to the default subcommand.
if let defaultSubcommand = currentNode.element.configuration.defaultSubcommand {
guard let subcommandNode = currentNode.firstChild(equalTo: defaultSubcommand) else {
throw ParserError.invalidState
}
currentNode = subcommandNode
continue
}
// No more subcommands to parse.
return
}
}
Snippet from ArgumentParser's
CommandParser.swift
As a reminder, CommandParser
thinks of our command line tool as a tree structure (where each node is a ParsableCommand
type): in descendingParse
we are building said tree based on the parsed SplitArguments
.
We do not create the whole tree structure, but only the relevant branches based on the given SplitArguments
instance.
Let's have a look at parseCurrent(_:)
next:
fileprivate mutating func parseCurrent(_ split: inout SplitArguments) throws {
// Build the argument set (i.e. information on how to parse):
let commandArguments = ArgumentSet(currentNode.element)
// Parse the arguments into a ParsedValues:
let parsedResult = try commandArguments.lenientParse(split)
let values: ParsedValues
switch parsedResult {
case .success(let v):
values = v
case .partial(let v, let e):
values = v
if currentNode.isLeaf {
throw e
}
}
// Decode the values from ParsedValues into the ParsableCommand:
let decoder = ArgumentDecoder(values: values, previouslyParsedValues: parsedValues)
var decodedResult: ParsableCommand
do {
decodedResult = try currentNode.element.init(from: decoder)
} catch {
...
}
// Decoding was successful, so remove the arguments that were used
// by the decoder.
split.removeAll(in: decoder.usedOrigins)
// Save this decoded result to add to the next command.
parsedValues.append((currentNode.element, decodedResult))
}
Snippet from ArgumentParser's
CommandParser.swift
First we create a new ArgumentSet
instance, which is based on the ParsableCommand
type associated with the current node in the tree (if this is the first iteration, we're still at the root):
struct ArgumentSet {
var content: Content
var kind: Kind // Used to generate help text.
}
Snippet from ArgumentParser's
ArgumentSet.swift
:
Where we define a Content
:
enum Content {
case arguments([ArgumentDefinition]) // A leaf list of arguments.
case sets([ArgumentSet]) // A node with additional `[ArgumentSet]`
}
Snippet from ArgumentParser's
ArgumentSet.swift
:
ArgumentSet
helps us create yet again a tree structure of our command line tool, however the emphasis here is on the command line arguments instead of the command line sub/commands.
Going back to parseCurrent(_:)
, here's the ArgumentSet
initializer that we use:
extension ArgumentSet {
init(_ type: ParsableArguments.Type) {
let a: [ArgumentSet] = Mirror(reflecting: type.init())
.children
.compactMap { child in
guard
var codingKey = child.label,
let parsed = child.value as? ArgumentSetProvider
else { return nil }
// Property wrappers have underscore-prefixed names
codingKey = String(codingKey.first == "_" ? codingKey.dropFirst(1) : codingKey.dropFirst(0))
let key = InputKey(rawValue: codingKey)
return parsed.argumentSet(for: key)
}
self.init(additive: a)
}
}
Snippet from ArgumentParser's
ParsableArguments.swift
This initializer is where a second magic trick happens: 1. we first initialize our ParsableArguments
type with ParsableArguments
's required init()
2. just to get the instance Mirror representation 3. and extract its children
4. which are the properties defined in our type, a.k.a. the ones with one of the four ArgumentParser property wrappers (@Argument
, @Option
, @Flag
, and @OptionGroup
).
This is to say, ArgumentSet
initialization is where our ParsableCommand
type properties auto-magically transform into parsable input arguments that can be read from the command line input.
Once we have the ArgumentSet
instance, we proceed with our parseCurrent(_:)
execution by matching the contents of SplitArguments
(which uses as input the input arguments from the command line) with ArgumentSet
(which uses as input our type definition).
This match result is then used by the ArgumentDecoder
to really instantiate our command line and set all the (property wrappers) values.
The CommandParser
's descendingParse(_:)
continues its execution until all the arguments have been consumed:
once this is completed, we go back to CommandParser
's parse(arguments:)
, which then extracts and returns the last parsed ParsableCommand
instance. Completing the last two steps of CommandParser
's parse(_:)
.
Wrapping Up The Static Main
At this point we are back to the ParsableCommand
's parseAsRoot(_:)
method, with our ParsableCommand
instance and all its properties set. There's one last step that we need to take before finally running: do our (custom and optional) ParsableArguments
's input validation.
Once the validation passes, we finally run our tool, which completes the whole journey.
Conclusions
Clarity at the point of use is the first fundamental in Swift's API Design Guidelines, what the Swift team has achieved with ArgumentParser goes well beyond that: it's ease at the point of use.
The more we dig into this library the more we can appreciate how much complexity is hidden behind a protocol and four property wrappers:
with this article I hope to have given you a small glimpse into the tremendous work the Swift team put into the library, and hope you can now also appreciate how elegant this library API truly is.
Thank you for reading and please don't hesitate to let me know of any other library with such powerful and elegant API 😃
Subscribe and follow me on Twitter for more insights into Swift and all things around the language! Until next time 👋🏻