Swift/UnwrapDiscover about Swift, iOS and architecture patterns

Scripting with Swift Argument Parser

July 27, 2021

Need automating tasks? Write a script! But which language should you use? Until now people used to either use bash or Ruby (at least in iOS environment). But now come a third contender: Swift Argument Parser, an Apple open-source parsing command line library. Let's see what we can do with it.

Swift Argument Parser

Let's say we have an application where we have to import localizations from a CSV file. We don't want to do it manually though so let's use Swift Argument Parser to automate the task.

Let's first open our Package.swift and add it as dependency.

Don't already have a Package.swift file? Just run swift package init --type executable to initiate a new library.

In the meantime, let's add a CSV parsing library and define a Import target which will be responsible to import localizations.

import PackageDescription

let package = Package(
    name: "Scripts", // the name here don't really matter
    products: [
        .executable(name: "import", targets: ["Import"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "0.3.2")),
        .package(url: "https://github.com/yaslab/CSV.swift", .upToNextMajor(from: "2.4.3"))
    ],
    targets: [
        .target(name: "Import",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "CSV", package: "CSV.swift")
            ]
        )
    ]
)

We can now start implementing our command in Sources/Import/main.swift file.

import ArgumentParser

struct Import: ParsableCommand {
    func run() throws {
        let filePath = "myFile.csv"
        let dstPath = "MyApp/Resources"
        let csvReader = try CSVReader(stream: InputStream(fileAtPath: filepath)!, hasHeaderRow: true)

        let content = generateLocalization(from: csvReader)

        FileManager.default.createFile(
            atPath: dstPath.appendingComponentPath("Localization.strings"),
            contents: localization,
            attributes: [:]
        )
    }

    func generateLocalizations(from reader: CSVReader) -> String {
        // we read CSV and transform it into a .string compatible format
    }
}

Import.main()

Xcode has native support for Swift packages. Just open Package.swift with it and voila! 💪

That's great but all our values are hardcoded. Let's add some parameters to make it more powerful.

Property wrappers

Swift Argument Parser provide three property wrappers to parse command line parameters:

  • Argument which is a plain value from the command line, like myFile.csv
  • Option, a "key=value" parameter. For instance --source=myFile.csv
  • and finally Flag which is key only, i.e --verbose

All these property wrappers are deeply explained in Swift Argument Parser documentation.

Let's apply these property wrappers to our code to define two parameters: the CSV source file path and the location where we want to store generated content.

struct Import: ParsableCommand {
    @Argument(help: "csv file path to load")
    var source = "localizations.csv"

    @Option(help: "filepath where to store generated .strings files")
    var destinationPath = "MyProject/Resources/Localization.strings"

    func run() throws {
        let csvReader = try CSVReader(stream: InputStream(fileAtPath: source)!, hasHeaderRow: true)

        let content = generateLocalizations(from: csvReader)

        FileManager.default.createFile(
            atPath: destinationPath,
            contents: content,
            attributes: [:]
        )
    }
}

With only these few lines we can now invoke our command to import localizations:

$ swift run import
$ swift run import someFile.csv
$ swift run import someFile.csv --destinationPath=/Users/swiftunwrap/i18n.strings
$ swift run import --destinationPath=/Users/swiftunwrap/i18n.strings

Interacting with the app

What's really great about Swift Argument Parser is that we can actually interact with our app code.

Let's say we have a SupportedLocale type in our app. Each time we add a new locale to the app we also want to make sure that we import its translations.

// file: MyProject/Domain/SupportedLocale.swift

enum SupportedLocale: String, CaseIterable {
    case english
    case french
    case portugese
}

To make that check let's add a new target in our Package.swift:

let package = Package(
    name: "Scripts",
    products: [
        .executable(name: "import", targets: ["Import"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "0.3.2")),
        .package(url: "https://github.com/yaslab/CSV.swift", .upToNextMajor(from: "2.4.3"))
    ],
    targets: [
        .target(name: "Domain", dependencies: [], path: "MyProject/Domain"),
        .target(name: "Import",
                dependencies: [
                    "Domain",
                    .product(name: "ArgumentParser", package: "swift-argument-parser"),
                    .product(name: "CSV", package: "CSV.swift")
                ]
        )
    ]
)

By adding Domain target and declaring it as Import dependency we now have access to our app models right from Import. We can now check that each CSV translation is available in all languages:

import ArgumentParser
import Domain

struct Import: ParsableCommand {
    @Argument(help: "csv file path to load")
    var source = "localizations.csv"

    @Option(help: "filepath where to store generated .strings files")
    var destinationPath = "MyProject/Resources/Localization.strings"

    func run() throws {
        let csvReader = try CSVReader(stream: InputStream(fileAtPath: source)!, hasHeaderRow: true)

        let content = try generateLocalizations(from: csvReader)

        FileManager.default.createFile(
            atPath: destinationPath,
            contents: localization,
            attributes: [:]
        )
    }

    func generateLocalizations(from reader: CSVReader) -> String throws {
        while csvReader.next() != nil {
                guard let row = csvReader.currentRow, row.first(where: { !$0.isEmpty }) != nil else {
                    continue
                }

                // to simplify example we'll just use rawValue
                if let missingLocale = SupportedLocale.allCases.first(where: { row[$0.rawValue] == nil }) {
                    throw LocalizationError.missingLocale(missingLocale)
                }
                
                // treat the translation   
            }
        }
    }
}

It might not be the best example in the world but I hope you get the point: you can reuse code from your application right from your scripts!

Conclusion

Swit Argument Parser is a very handy tool when needing to write scripts for your projects.

Not only does it provide a safer environment than Bash but it also bring ability to share code between your scripts and your applications. It can open the door to whole new interesting usage!