I have introduced the Swift Argument Parser package before. I have been using this package in many of my tools, such as swift-prefs, utiluti, emoji-list and, just recently, translate.
Argument Parser is a Swift package library which adds complex argument, option, and flag parsing to command line tools built with Swift. The documentation is generally quite thorough, but a few subjects are a little, well…, under-documented. This may be because the implementations are obvious to the developers and maintainers.
One subject which confused me, was how to exit early out of the tool’s logic, and how to do so in case of an error. Now that I understand how it works, I really think the solution is quite brilliant, but it is not immediately obvious.
Our example
Swift error handling use values of the Error type that are thrown in the code. Surrounding code can catch the error or pass it to higher level code. Argument Parser takes errors thrown in your code and exits your code early.
An example: in my 2024 MacSysAdmin presentation “Swift – The Eras Tour (Armin’s Version),” I built a command line tool to set the macOS wallpaper tool live on stage. To replicate that, follow these steps:
In terminal, cd to a location where you to store the project and create a project folder:
$ mkdir wallpapr
$ cd wallpapr
Then use the swift package command to create a template command line tool project that uses Argument Parser:
$ swift package init --type tool
The project will look like this:
📝 Package.swift
📁 Sources
📁 wallpapr
📝 wallpapr.swift
Xcode can handle Swift Package Manager projects just fine. You can open the project in Xcode from the command line with:
$ xed .
Or you can open Sources/wallpapr/wallpapr.swift in your favored text editor.
Replace the default template code with this:
import Foundation
import ArgumentParser
import AppKit
@main
struct wallpapr: ParsableCommand {
@Argument(help: "path to the wallpaper file")
var path: String
mutating func run() throws {
let url = URL(fileURLWithPath: path)
for screen in NSScreen.screens {
try NSWorkspace.shared.setDesktopImageURL(url, for: screen)
}
}
}
This is even more simplified than what I showed in the presentation, but will do just fine for our purposes.
You can build and run this command with:
$ swift run wallpaper /System/Library/CoreServices/DefaultDesktop.heic
Building for debugging...
[1/1] Write swift-version--58304C5D6DBC2206.txt
Build of product 'wallpapr' complete! (0.16s)
(I’ll be cutting the SPM build information for brevity from now on.)
This is the path to the default wallpaper image file. You can of course point the tool to another image file path.
Just throw it
When you enter a file path that doesn’t exist, the following happens:
swift run wallpapr nosuchfile
Error: The file doesn’t exist.
This is great, but where does that error come from? The path argument is defined as a String. ArgumentParser will error when the argument is missing, but it does not really care about the contents.
The NSWorkspace.shared.setDesktopURL(,for:), however, throws an NSError when it cannot set the wallpaper, though. That NSError has a errorDescription property, which ArgumentParser picks up and displays, with a prefixed Error:.
This is useful. By just marking the run() function of ParsableCommand as throws and adding the try to functions and methods which throw, we get pretty decent error handling in our command line tool with no extra effort.
Custom errors and messages
Not all methods and functions will throw errors, though. If they do, the error messages might not be helpful or too generic for the context. In more complex tools (and, honestly, nearly everything will be more complex than this simple example) you want to provide custom messages and custom error handling, so you will need custom errors.
Since we have seen that ArgumentParser deals very nicely with thrown errors, let’s define our own.
Add this custom error enum to the wallpaper.swift (anywhere, either right after the import statements or at the very end):
enum WallpaprError: Error {
case fileNotFound
}
Then add this extra guard statement at the beginning of the run() function:
guard FileManager.default.fileExists(atPath: path) else {
throw WallpaprError.fileNotFound
}
The code checks that the file path given in the path argument actually exists, instead of relying on the functionality in the setDesktopURL(,for:) method. When you run this code, we get our custom error:
$ swift run wallpapr nosuchfile
Error: fileNotFound
This is nice, but fileNotFound is a good name to use in the code, but not very descriptive. We could add more description with a print just before throw statement, but we already saw that the NSError thrown by setDesktopURL() had a detailed description. How do we add one of those to our custom error?
Turns out there are two ways. Either the custom error conforms to LocalizedError and implements errorDescription (which is what NSError does) or you implement CustomStringConvertible and implements description (or both).
There are many good reasons to implement CustomStringConvertible on your types anyway, since description is also used in the debugger and the print statement. There are also situations where you might want a different message for the error description, so it is good to have options. For our example, we just going to implement CustomStringConvertible. Change the code for the WallpaprError enum to:
enum WallpaprError: Error, CustomStringConvertible {
case fileNotFound
var description: String {
switch self {
case .fileNotFound: "no file exists at that path!"
}
}
}
And when you run again, you see the custom message:
$ swift run wallpapr nosuchfile
Error: no file exists at that path!
Note that the error message is written to standard error.
Clean exits
In some workflows, you may want to exit the script early, but without an error. (An exit code of 0.) When you try to use exit(0), you will get an error since ArgumentParser overloads that function. Instead, ArgumentParser provides a CleanExit error that you can throw:
throw CleanExit.message("set wallpaper to \(path)")
Generally it is best to just let the run() function complete for a successful exit, but there are situations where this comes in handy.
Custom Exit Codes
ArgumentParser generally does the right thing and returns a 0 exit code upon successful completion of the tool and an exit code of 1 (non-zero represents failure) when an error is thrown. It also returns an exit code of 64 when it cannot parse the arguments. According to sysexits this represents a bad entry of options and arguments.
(You can customize your shell prompt to show the exit code of the previous command.)
In complex tools, you may want to return other exit codes mentioned in that man page, or custom errors for certain situations. ArgumentParser does have a built-in option: you can throw the ExitCode() error with a custom code. For example, we can replace our custom error with
throw ExitCode(EX_NOINPUT)
This will return an exit code of 66, but now we have lost the custom error message. This is long standing missing feature of ArgumentParser (see discussion in this forum thread), but it is fairly easy to provide a workaround.
Add this extension to your tool:
extension ParsableCommand {
func exit(_ message: String, code: Int32 = EXIT_FAILURE) throws -> Never {
print(message)
throw ExitCode(code)
}
}
And then you can use this to get a custom message and a custom exit code.
try exit("no file exists at that path!", EX_NOINPUT)
