A Different Approach Using the Swift Argument Parser
The standard way to use the Swift Argument Parser consists of basically two steps: creating the commands conforming to ParsableCommand
(or its async version), followed by implementing the run()
method. This is one of the reasons many developers like using this package: it is very straightforward and does the heavy lifting of parsing, validating, and running a command, including displaying errors.
In some more advanced use cases, however, you might feel a bit more limited by this regular approach. One example is when you want to add styles to the output, such as displaying errors in red. Another scenario could be having your commands implement a protocol of yours, and take control over the flow that calls this protocol methods.
This post will cover both cases, showing how you can take charge of the flow usually handled by the argument parser, and have more flexibility in your tools.
The Argument Parser Implementation
To understand how the argument parser allows you to implement only the run()
function of your @main
-marked entry point, one can check this default extension on AsyncParsableCommand
:
All this function does is to forward the call to another main
, that accepts optional arguments. This is useful if you want to test your commands, allowing the developer to override the command line arguments. In this case, it passes nil
, meaning that the command line arguments will be used.
Then, the real work is done in this method:
Pay attention to what this main
function does:
- It uses the
parseAsRoot
method to convert the array of arguments into a type that implements theParsableCommand
or theAsyncParsableCommand
protocol - It runs the command (or an async one)
- Both parsing and running can throw an error, so they are wrapped in a do-catch statement. In case there’s an error, the argument parser calls the
exit(withError:)
method.
Once these three steps are clear, it doesn’t require much work to emulate this behavior in your tool’s entry point itself, and manipulate the flow and the ouput according to your own needs.
Manually Handling & Displaying Errors
As explained in the introduction, one reason you might want to implement the main()
function by yourself, is to add styling to the output when there’s an error. To do so, the starting command in your tool should implement the main()
function.
static func main() async {
await main(nil)
}
static func main(_ arguments: [String]?) async {
do {
var command = try parseAsRoot(arguments)
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
guard error.shouldBeStyled else {
exit(withError: error)
}
let styledError = StyledError(error: error, color: .red)
exit(withError: styledError)
}
}
The differences between this implementation and the default one from the argument parser (in the previous section) resides in the catch statement. While there, the only call is to the exit(withError:)
method, here there is a bit more logic:
First, to check if this error should be styled or not, you can add an extension to Error
, and to it a get-only property shouldBeStyled
:
extension Error {
var shouldBeStyled: Bool {
if String(reflecting: self).hasPrefix("ArgumentParser") {
return false
}
return true
}
}
This logic means that only errors that are not thrown from the argument parser will receive styling. Errors from your own tool, or other frameworks, will be styled.
The second difference is the usage of a StyledError
, which applies a color to the error message. To do so, all it needs to do is to take the error, and return its description with a styled. This assumes your tool depends on ColorizeSwift:
@preconcurrency import ColorizeSwift
struct StyledError: Error, CustomStringConvertible {
let error: Error
let color: TerminalColor
var description: String {
String(describing: error).foregroundColor(color)
}
}
Notice how the implementation of CustomStringConvertible
is only a wrapper to the error description, and a call to foregroundColor()
from Colorizer.
This is how it could look like, when throwing an error in the run()
function:
Calling Other Methods in a Command
Besides styling the error output, to achieve more control over the flow of the calls, you can add another abstraction layer to your commands.
Imagine you are building a tool that makes a static website, and you want to provide two commands: preview and publish. They all perform very similar tasks, and accept the same group of options. In this case, you could create a protocol to unify them. Here, an OptionGroup
can be very handy:
struct SharedOptions: ParsableArguments {
@Option(transform: { Logger.Level(rawValue: $0) ?? .info })
var logLevel: Logger.Level?
@Option
var domainName: String?
}
These options allow specifying a log level, and a custom domain. Then, they can be added to a protocol, along with the methods that are relevant to both commands:
protocol Command: ParsableCommand {
var options: SharedOptions { get }
func prepare() async throws
func execute() async throws
func finalize() async throws
}
Constraining this protocol to ParsableCommand
will make that any type implementing it will get parsing for free.
For the sake of brevity, this example adds a default implementation to both prepare
and execute
methods, leaving only this as required:
struct Preview: Command {
@OptionGroup var options: SharedOptions
func finalize() async throws {
print("Preview built".foregroundColor(.green))
}
}
struct Publish: Command {
@OptionGroup var options: SharedOptions
func finalize() async throws {
print("Website published".foregroundColor(.green))
}
}
With these two commands in place, the last piece in this puzzle is to implement the root command (the entry point) and its main()
function. You start by declaring the struct, and the subcommands above:
@main
struct CustomFlow: ParsableCommand {
static var configuration: CommandConfiguration {
.init(subcommands: [
Preview.self,
Publish.self
])
}
}
Then, add the following main function:
static func main() async throws {
do {
//1
guard let command = try? parseAsRoot() as? Command else {
print("Available commands are: preview and publish".foregroundColor(.red))
exit(withError: ExitCode(1))
}
//2
print("Preparing (type(of: command)._commandName)...".foregroundColor(.orange1))
try await command.prepare()
//3
print("Starting (type(of: command)._commandName)...")
try await command.execute()
try await command.finalize()
} catch {
//4
// Handle failure as in the previous section
}
}
This is a bit longer. Breaking it apart helps understanding:
- Use the static
parseAsRoot
function onParsableCommand
to convert the command line input into one of the two subcommands - in this case, both implement theCommand
protocol we created a few paragraphs above. If that fails, print an error message. - Here the custom sequence of methods start. Print a message that the command is running its prepare function, and call it.
- Once that is done, call the other two methods.
- If any one of these 3 methods throws an error, handle it as shown in the example before.
Finally, this is what running this executable looks like:
Considerations
Having this level of control can be powerful, but it has its price. One point worth mentioning is, if there are significant changes to the default main()
implementation by the argument parser, you might need to update your tool’s main()
function to have a similar execution.
Additionally, when throwing ValidationError
s from the validate()
method, they won’t receive any styling. Even though this is a public struct, the logic for creating the message and the exit code is internal to the argument parser (see the MessageInfo
enum), as well as some types. Until they’re public, it won’t be possible to mimic the same behavior. For this reason, the shouldBeStyled
property we demonstrated above is too broad, and is not interested in styling any internal types from the argument parser (which includes printing help and version).
Using the package only for parsing arguments (without all the execution and run bits) is definitely an interesting approach. Having said that, it is good to know when to rely on it for the heavy lifting (parsing and executing), and when to take only other parts of it in a way that fits your tool needs.
Explore Further
If you are interested in learning more about building tools with the Swift Argument Parser, don’t forget to check out our 3-part interactive series about it, or click here to see all posts about it.
If you want to see real world cases where approaches like these are used:
- Tuist: TuistCommand.swift
- SwiftCloud: Project.swift, which heavily inspired the second example in this article
Feel free to reach us at X or Mastodon if you have any comments or questions.
See you at the next post. Have a good one!