Marco Eidinger
Swifty Tech by Marco Eidinger

Swifty Tech by Marco Eidinger

Develop a command-line tool using Swift Concurrency

Swift Argument Parser and async/await

Marco Eidinger's photo
Marco Eidinger
·Jan 18, 2022·

6 min read

Develop a command-line tool using Swift Concurrency

Subscribe to my newsletter and never miss my upcoming articles

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.

Did you find this article valuable?

Support Marco Eidinger by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this