Skip to main content

Develop a command-line tool using Swift Concurrency

Swift Argument Parser and async/await

Updated 6 min readMarkdown
Develop a command-line tool using Swift Concurrency

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.

Compilation error when awaiting an async function in a function which has no async keyword

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.

About the author

  • I am a Software Engineer working on open source and enterprise mobile SDKs for iOS and MacOS developers written in Swift. From 馃嚛馃嚜 and happily living in 馃嚭馃嚫

No comments yet. Be the first to comment.

More from this blog