Develop a command-line tool using Swift Concurrency
Swift Argument Parser and async/await
Updated for ArgumentParser 1.1.0 and its native support of async/await
Apple's ArgumentParser
library makes developing a command-line tool in Swift significant easier. The library parses the command-line arguments, instantiates your command type, and then either executes your run() method or exits with a useful message.
Straightforward, type-safe argument parsing for Swift
import ArgumentParser
@main
struct Repeat: ParsableCommand {
@Flag(help: "Include a counter with each repetition.")
var includeCounter = false
@Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.")
var count: Int?
@Argument(help: "The phrase to repeat.")
var phrase: String
mutating func run() throws {
let repeatCount = count ?? .max
for i in 1...repeatCount {
if includeCounter {
print("\(i): \(phrase)")
} else {
print(phrase)
}
}
}
}
You might want to use modern Swift Concurrency and await an asynchronous function in your command-line program. Trying so will result in a compilation error.
Maybe changing the definition of the run
function to add the async
keyword?
Nope, because this particular function is needed "as-is" in the struct according to its ParsableCommand
protocol requirement.
Does the ArgumentParser framework support other functions for async/await?
1.1.0 and up
Swift ArgumentParser 1.1.0
brings out-of-the-box support for async/await.
Example
import ArgumentParser
@main
struct Repeat: ParsableCommand, AsyncParsableCommand {
@Flag(help: "Include a counter with each repetition.")
var includeCounter = false
@Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.")
var count: Int?
@Argument(help: "The phrase to repeat.")
var phrase: String
// A command's run() method now supports async/await when the command conforms to AsyncParsableCommand. (#404)
mutating func run() async throws {
let repeatCount = count ?? .max
await asyncRepeat(phrase: phrase, repeatCount: repeatCount)
}
func asyncRepeat(phrase: String, repeatCount: Int) async {
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) // dummy use of an aysnc operation .. wait 5 seconds :)
for i in 1...repeatCount {
if includeCounter {
print("\(i): \(phrase)")
} else {
print(phrase)
}
}
}
}
The secret is to conform your command to AsyncParsableCommand
protocol and then use the keyword async
in the run
function declaration.
Before 1.0.0
At the time I wrote this blog post originally (beginning of January 2022) there was no out-of-the-box support by the ArgumentParser framework.
Lucky for us the issue contains a code snippet from Sergio CampamΓ‘ how to add support for async/await by ourself!
I am using this pattern and adopt the official Repeat example from Apple, share my lessons learned and which pitfalls to avoid.
First, in my program I define a new protocol to indicate that commands need to perform asynchronous work.
protocol AsyncParsableCommand: ParsableCommand {
mutating func runAsync() async throws
}
Every command conforming to AsyncParsableCommand
needs to implement the asynchronous function named runAsync
.
Similar to what the ArgumentParser does to invoke the run
function for an ParsableCommand
I create an extension on ParsableCommand and define a static async main() function.
extension ParsableCommand {
static func main() async {
do {
var command = try parseAsRoot(nil) /// `parseAsRoot` uses the program's command-line arguments when passing `nil`
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.runAsync()
} else {
try command.run()
}
} catch {
exit(withError: error)
}
}
}
In the implementation I reuse the public function parseAsRoot
from the ArgumentParser framework. It will return the main command (here: the Repeat struct from the example).
If the command implements the AsyncParsableCommand
protocol then let's await the runAsync
function. Otherwise simply call the command's run
function.
By the way: you do not see any conditional statements despite that Swift Concurrency is only available starting from specific platform versions. The reason for this is that I restricted the platform to macOS 10.15 and above in my Swift Package manifest.
import PackageDescription
let package = Package(
name: "RepeatCommandLineTool",
platforms: [.macOS(.v10_15)],
products: [
.executable(name: "repeat", targets: ["RepeatCommandLineTool"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "RepeatCommandLineTool",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
.testTarget(
name: "RepeatCommandLineToolTests",
dependencies: ["RepeatCommandLineTool"]),
]
)
You have to rename your main.swift
file if you haven't done that already. Otherwise you will run in the error
'main' attribute cannot be used in a module that contains top-level code
This has nothing to do with Swift Concurrency but because the @main
attribute is used. Speaking of it ... I move the @main
attribute, to indicate the top-level entry point for program, to a new enum which has an async static function which will await the newly async main function of the ParsableCommand
.
@main
enum CLI {
static func main() async {
await Repeat.main()
}
}
Now I can finally adopt the example
struct Repeat: ParsableCommand, AsyncParsableCommand {
@Flag(help: "Include a counter with each repetition.")
var includeCounter = false
@Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.")
var count: Int?
@Argument(help: "The phrase to repeat.")
var phrase: String
mutating func runAsync() async throws {
let repeatCount = count ?? .max
await asyncRepeat(phrase: phrase, repeatCount: repeatCount)
}
func asyncRepeat(phrase: String, repeatCount: Int) async {
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) // dummy use of an aysnc operation .. wait 5 seconds :)
for i in 1...repeatCount {
if includeCounter {
print("\(i): \(phrase)")
} else {
print(phrase)
}
}
}
}
Finally I can await the asyncRepeat
function which waits for 5 seconds before repeating the phrase passed as argument to the program :)
All the code shared is stored in the following GitHub repository.
In addition to this blog post I created a YouTube Video
Bonus Tip
Do you struggle with the error dyld: Library not loaded: @rpath/libswift_Concurrency.dylib
when running your command-line tool from within Xcode?
Here is my advice on how to avoid the error. I was able to successfully test this on my local machine running macOS 11 and Xcode 13.2.
Add to the Package.swift
file a new import statement on the top.
import Foundation
At the end of the file add the following code:
// IMPORTANT: enable the following function call if you encounter the error
// `dyld: Library not loaded: @rpath/libswift_Concurrency.dylib`
//hookInternalSwiftConcurrency()
func hookInternalSwiftConcurrency() {
let isFromTerminal = ProcessInfo.processInfo.environment.values.contains("/usr/bin/swift")
if !isFromTerminal {
package.targets.first?.addLinkerSettingUnsafeFlagRunpathSearchPath()
}
}
extension PackageDescription.Target {
func addLinkerSettingUnsafeFlagRunpathSearchPath() {
linkerSettings = [linkerSetting]
}
private var linkerSetting: LinkerSetting {
guard let xcodeFolder = Executable("/usr/bin/xcode-select")("-p") else {
fatalError("Could not run `xcode-select -p`")
}
let toolchainFolder = "\(xcodeFolder.trimmed)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.5/macosx"
return .unsafeFlags(["-rpath", toolchainFolder])
}
}
extension String {
var trimmed: String { trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) }
}
private struct Executable {
private let url: URL
init(_ filePath: String) {
url = URL(fileURLWithPath: filePath)
}
func callAsFunction(_ arguments: String...) -> String? {
let process = Process()
process.executableURL = url
process.arguments = arguments
let stdout = Pipe()
process.standardOutput = stdout
process.launch()
process.waitUntilExit()
return stdout.readStringToEndOfFile()
}
}
extension Pipe {
func readStringToEndOfFile() -> String? {
let data: Data
if #available(OSX 10.15.4, *) {
data = (try? fileHandleForReading.readToEnd()) ?? Data()
} else {
data = fileHandleForReading.readDataToEndOfFile()
}
return String(data: data, encoding: .utf8)
}
}
Uncomment line // hookInternalSwiftConcurrency()
which should solve the problem. In case the command-line tool is executed not from the terminal (= Xcode) then additional linker settings get added to the target. In particular the Runpath Search Path is set so that the dynamic linker is able to find libswift_Concurrency.dylib
in your installed toolchain.