Scripting with Swift Argument Parser
July 27, 2021Need 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 runswift 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, likemyFile.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!