Useful APIs when writing scripts and tools in Swift
Discover page available: CombineWhen building apps, it’s really common to rely on various kinds of scripts and custom developer tools in order to build, ship, and debug a code base. From formatting or validating both code-level symbols and linked resources, to running different kinds of tests, and even generating code — there are so many different tasks that can be automated one way or another.
While Swift’s heavy focus on compile-time safety and strong types might initially make it seem like an odd choice for writing scripts and automation, it does bring a lot of quite unique advantages to the table — such as enabling us to share code between an app and the scripts used to develop it, or just the fact that there’s no context switching needed to jump between working on a tool and an app that uses it.
So this week, let’s take a look at a few key APIs that can come very much in handy when using Swift in the context of scripting and tooling — focusing on those that come built-in as part of either the Swift standard library, or macOS as a platform.
This article assumes that you are somewhat familiar with the Swift Package Manager, and won’t go into details when it comes to how to set up a Swift package for writing a script or custom developer tool, since that was covered in “Building a command line tool using the Swift Package Manager”.
Accepting arguments
Let’s start by taking a look at a few different ways of handling input within command line tools — with the most basic of which being to simply access the static arguments
property on the built-in CommandLine
type. That property contains an array of strings representing the various arguments that were passed when invoking our script or tool on the command line, and also includes the tool’s own execution path as the first element.
Here’s how we might use that API to extract a few parameters within a tool used to resize a given image:
let arguments = CommandLine.arguments
// Since the first element is our tool's execution path, we'll
// start extracting our arguments from index 1, rather than 0:
let imagePath = arguments[1]
let width = Double(arguments[2])
let height = Double(arguments[3])
Tip: When working on a script or tool in Xcode, we can specify what arguments to send to our program by editing its Run
scheme (which can be opened by pressing ⌥ + ⌘ + R
).
However, one problem with the above code is that it’ll cause a crash if fewer than three arguments were passed — which isn’t great — so let’s also add a guard
statement for handling those situations. If we received fewer arguments than expected, then we’ll use the exit
function to terminate our program with a given exit code, after printing an error message:
let arguments = CommandLine.arguments
guard arguments.count > 3 else {
print("""
Error: Expected 3 arguments: imagePath, width, and height.
""")
exit(1)
}
...
While using CommandLine.arguments
is a really convenient way of accessing all of a program’s arguments in a linear fashion, sometimes we might want to retrieve the value for a named argument — that is, two arguments that make up a key/value pair.
While we could of course iterate through each argument and create a key/value map manually, we can actually use Foundation’s UserDefaults
type to retrieve any named command line argument passed into our program — completely automatically:
// This will parse the values passed for "-width" and "-height" on
// the command line (that is, each key name prefixed with a dash):
let namedArguments = UserDefaults.standard
let width = namedArguments.double(forKey: "width")
let height = namedArguments.double(forKey: "height")
What’s great about using UserDefaults
to parse command line arguments (besides the fact that it’s a built-in API), is that we can choose whether we want it to return a specific type (such as Double
, like above), or an optional raw String
. Here’s how we might extract such a string, then transform it into a URL (using flatMap
), and finally map any non-nil
value into a downloadHTML
function:
// When calling a throwing function within the global scope of
// a command line tool, we can simply prefix it with 'try', without
// first having to wrap it within a 'do' clause (unless we want
// to customize exactly how an error gets presented to the user):
let html = try namedArguments
.string(forKey: "url")
.flatMap(URL.init)
.map(downloadHTML)
Although UserDefaults
also offers a url
API that returns a URL
value, those URLs are assumed to be local file URLs — so in case we’re looking to use remote web URLs, then extracting raw strings is typically a better option.
While this article is focused on APIs that ship as part of the system, it’s also worth pointing out that Apple offers a much more powerful framework for parsing command line arguments, called Swift Argument Parser, which is available as a Swift package on GitHub.
Networking and other asynchronous tasks
Next, let’s take a look at a few ways of implementing that downloadHTML
function that we called above.
Networking, and other tasks that are highly asynchronous, can at first seem a bit tricky to perform within the context of a script or tool, since — unlike apps — command line tools are executed synchronously from top to bottom, and then immediately terminated. So how can we ensure that our program keeps running while we wait for an asynchronous task to complete?
One way to do that is to use Grand Central Dispatch’s DispatchSempahore
API, which enables us to synchronously wait for an operation that’s being performed on a background queue. Using that, along with URLSession
and its new Combine-powered dataTaskPublisher
API, we could download a string over the network like this:
func downloadHTML(from url: URL) throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<String, Error>?
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.map { String(decoding: $0, as: UTF8.self) }
let cancellable = publisher.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
result = .failure(error)
case .finished:
break
}
// Calling 'signal' on our semaphore will cause our
// program to keep executing from the point at which
// 'wait' was called.
semaphore.signal()
},
receiveValue: { value in
result = .success(value)
}
)
_ = semaphore.wait(timeout: .now() + 20)
guard let html = try result?.get() else {
// If no result was received after 20 seconds, we'll
// consider our operation to have timed out, and will
// both cancel our publisher, and throw a custom error:
cancellable.cancel()
throw NetworkTimeoutError(url: url)
}
return html
}
Note that in order to use APIs that were introduced in macOS 10.15 Catalina, such as Combine, we need to add the platforms: [.macOS(.v10_15)]
argument to our Package.swift
manifest. Also worth noting is that Combine is not (yet) available on Linux, which might not be a problem, depending on how we’re planning to use the tool that we’re building.
While the above code works, there are very few things about it that are network-specific — so if we wanted to, we could also generalize it into a method that lets us await the result of any Combine Publisher
— like this:
extension Publisher {
private static var defaultTimeoutMessage: String {
"An async operation timed out"
}
func awaitOutput(
withTimeoutMessage timeoutMessage: String = defaultTimeoutMessage,
forTimeInterval timeInterval: TimeInterval = 20
) throws -> Output {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<Output, Failure>?
let cancellable = sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
result = .failure(error)
case .finished:
break
}
semaphore.signal()
},
receiveValue: { value in
result = .success(value)
}
)
_ = semaphore.wait(timeout: .now() + timeInterval)
guard let output = try result?.get() else {
cancellable.cancel()
throw TimeoutError(description: timeoutMessage)
}
return output
}
}
With the above in place, we can now drastically simplify our downloadHTML
function, which can now consist of a single Combine pipeline:
func downloadHTML(from url: URL) throws -> String {
try URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.map { String(decoding: $0, as: UTF8.self) }
.awaitOutput(withTimeoutMessage: """
Downloading HTML from '\(url)' timed out.
""")
}
Really cool! However, since we’re not just looking to perform this particular task, but rather our entire program, in a synchronous fashion — another option would be to simply initialize a Data
instance with the URL that we’re looking to load, and the system will then take care of the rest, completely synchronously:
func downloadHTML(from url: URL) throws -> String {
let data = try Data(contentsOf: url)
return String(decoding: data, as: UTF8.self)
}
We could even call the above init(contentsOf:)
initializer directly on String
, which would let us perform our entire HTML loading process inline within our tool’s global scope, without the need for any additional code — like this:
let html = try namedArguments
.string(forKey: "url")
.flatMap(URL.init)
.map(String.init)
Now it might seem like the Publisher
extension that we initially wrote is somewhat useless, given that we were able to completely remove it by using the built-in APIs that Data
and String
ship with, but that’s not really the case.
While the above synchronous APIs might be a better choice when writing simpler scripts that perform all of their operations in sequence — as soon as we want to perform multiple asynchronous operations in parallel, being able to await the result of any Combine pipeline can be incredibly useful.
Reading and writing files
Finally, let’s take a quick look at a few built-in APIs that let us read and write files within a Swift script or tool.
Going back to UserDefaults
for a moment — the url
method that we avoided earlier when handling remote web URLs is a really neat tool for extracting local file system URLs from our command line arguments — since it’ll automatically expand symbols, such as ~
, in order to give us an absolute file URL that can then be directly passed to the String
initializer we previously used to download HTML over the network:
let fileContents = try namedArguments
.url(forKey: "file")
.map(String.init)
If we’d rather accept a file path based on argument index, rather than using parameter names, then we could also perform the same “tilde expansion” that UserDefaults
performs, using Foundation’s NSString
type:
// This will expand the '~' symbol into the absolute path to
// the current user's home directory:
let path = NSString(string: CommandLine.arguments[3]).expandingTildeInPath
Not only does Swift’s Data
type let us read files by passing it a URL
, it also lets us do the same thing but for writing. All that we have to do to write a file is to initialize a Data
value (for example using a string’s underlying utf8
collection) and then call the write
method on it with the URL that we want to write our file to:
if let outputURL = namedArguments.url(forKey: "output") {
let data = Data(html.utf8)
try data.write(to: outputURL)
}
The above call to write
will either create a new file (if needed), or overwrite any existing one. Similar to how we explored multiple options for performing asynchronous tasks and networking, there are also several other built-in APIs that offer more customization options and power when it comes to file I/O (such as FileManager
), but for simpler write operations, the above might be all that we need.
Conclusion
Swift can definitely be a great choice for writing scripts and other kinds of command line tools that are used to build and distribute a Swift-based app. While it might not (currently) offer the same amount of dynamism or sheer number of libraries that more established scripting languages do — its got a solid foundation (no pun intended), and it gives us access to the same powerful suite of APIs that are used to build fully-featured Mac apps.
Hopefully, as Swift keeps maturing and its package ecosystem keeps expanding, more iOS and Mac developers will be able to quickly build their own, custom tools and augment their development process — using a language that they’re already familiar with. Because while Xcode might be a really capable IDE, and while there are numerous open source tools available to automate a wide range of tasks, sometimes a custom script or tool can be just what we need to make our development workflow faster and more smooth.
Got questions, comments, or feedback? Feel free to contact me either via Twitter or email.
Thanks for reading! 🚀