Swift Command-Line Tool
Updated for Swift 5.7 (Xcode 14)
In this post I will walk through the steps of creating a simple command-line tool with Swift Package Manager.
The finished app will print out information about files and directories on the filesystem.
Setup
Let’s first create a Swift package with the executable
template:
1
2
3
4
5
6
7
$ mkdir MyApp && cd MyApp
$ swift package init --type executable
$ swift run
Building for debugging...
[3/3] Linking MyApp
Build complete! (0.82s)
Hello, World!
I’m going to use John Sundell’s Files
package for filesystem interactions, adding it as a dependency in Package.swift
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "MyApp",
dependencies: [
.package(url: "https://github.com/JohnSundell/Files", from: "4.2.0"),
],
targets: [
.executableTarget(
name: "MyApp",
dependencies: ["Files"]),
.testTarget(
name: "MyAppTests",
dependencies: ["MyApp"]),
]
)
Sources/MyApp/MyApp.swift
is the executable
template’s entry-point. For now, we’ll replace the Hello, world!
sample with some simple logic to print out the current directory’s listing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Files
@main
public struct MyApp {
public static func main() throws {
try MyApp().printFiles()
}
func printFiles() throws {
for file in try Folder(path: ".").files {
print(file.name)
}
}
}
Notice that the source files for our executable target reside in
Sources/TARGET_NAME/*
.
If you’d like to have them somewhere else, you need to explicitly specify thepath
parameter for yourexecutableTarget
inPackage.swift
Let’s take it for a spin:
1
2
3
4
$ swift run
Package.resolved
Package.swift
README.md
That was really easy and already very exciting. 😎
Swift Argument Parser
Something that arguably every command-line app needs, are arguments that the user can pass to it. Rather than implement them from scratch, I’m going to use Apple’s open-source package Swift Argument Parser (I’m referring to this package as ArgumentParser
below).
If you’d rather roll your own argument parsing solution, you can do so by manually reading them from
CommandLine.arguments.dropFirst()
Setup
We add .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.4"),
1 to Package.swift
’s dependencies
in the same way as for Files
above, but need to specify the exact product we want to add as a dependency for our executableTarget
, because the product ArgumentParser
has a different name than the package swift-argument-parser
and can’t be automatically inferred by SPM.
1
2
3
4
5
6
7
8
// ...
.executableTarget(
name: "MyApp",
dependencies: [
"Files",
.product(name: "ArgumentParser", package: "swift-argument-parser")
]),
// ...
We import the module and conform our struct to ParsableCommand
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ArgumentParser
import Files
@main
struct MyApp: ParsableCommand {
mutating func run() throws {
try printFiles()
}
func printFiles() throws {
for file in try Folder(path: ".").files {
print(file.name)
}
}
}
ArgumentParser
’sParsableCommand
s require arun()
method that acts as the entry point for your command instead of the staticmain()
Arguments, options and flags
ArgumentParser
uses property wrappers to declare its parsable parameters.
@Argument
for positional command-line argument@Option
for named parameters (with--
or-
prefixes)@Flag
for boolean command-line flags (also with--
or-
)
Furthermore, a --help
(-h
) documentation for your command-line application is automatically generated.
For our finished example, it will look something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ swift run MyApp -h
OVERVIEW: A neat little tool to list files and directories
USAGE: my-app list [<path>] [--directories] [--include-hidden] [--filter <filter>]
ARGUMENTS:
<path> Path of the directory to be listed (default: .)
OPTIONS:
-d, --directories Include directories
-i, --include-hidden Include hidden files/directories
-f, --filter <filter> Filter the list of files and directories
--version Show the version.
-h, --help Show help information.
Use
swift run AppName
from a terminal in the root directory of your Swift package to quickly debug the arguments and parameters of your application.
We want to pass a filesystem path to our tool, so that we can list files in other directories, too. This argument should have the current directory as a default:
1
2
@Argument(help: "Path of the directory to be listed")
var path: String = "."
We’ll also update our code to use this new variable and print out the name of the current folder.
1
2
3
4
5
6
7
8
9
10
mutating func run() throws {
print("-- \(try Folder(path: path).name) --")
try printFiles()
}
func printFiles() throws {
for file in try Folder(path: path).files {
print(file.name)
}
}
1
2
3
$ swift run MyApp Sources/MyApp
-- MyApp --
MyApp.swift
We’ll also add a parameter to display directories in addition to files in our list.
1
2
@Flag(help: "Include directories")
var includeDirectories: Bool = false
If you want to use a different parameter label than the variable name, you can specify this via the property wrapper’s name
property in various ways. For example, our --include-directories
flag (ArgumentParser
will turn it into kebab-case by default) could be set like this:
1
2
@Flag(name: [.short, .long, .customLong("dir")])
var includeDirectories = false
In order to make the following commands available to the user:
1
2
3
$ swift run MyApp --include-directories
$ swift run MyApp --dir
$ swift run MyApp -d
Again, swift run MyApp --help
will show these options automatically:
1
2
OPTIONS:
-d, --include-directories, --dir
We’ll add one more @Flag
to toggle whether hidden files and directories should be listed.
1
2
@Flag(name: [.long, .short], help: "Include hidden files/directories")
var includeHidden: Bool = false
Let’s also include the third type of command-line parameter, by creating a option to filter the list:
1
2
@Option(name: .shortAndLong, help: "Filter the list of files and directories")
var filter: String?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@main
struct MyApp: ParsableCommand {
@Argument(help: "Path of the directory to be listed")
var path: String = "."
@Flag(name: [.customLong("directories"), .customShort("d")], help: "Include directories")
var includeDirectories: Bool = false
@Flag(name: .shortAndLong, help: "Include hidden files/directories")
var includeHidden: Bool = false
@Option(name: .shortAndLong, help: "Filter the list of files and directories")
var filter: String?
mutating func run() throws {
print("-- \(try Folder(path: path).name) --")
if includeDirectories {
try printDirectories()
}
try printFiles()
}
}
private extension MyApp {
private func printFiles() throws {
var files = try Folder(path: path).files
if includeHidden {
files = files.includingHidden
}
for file in files {
if let filter, !file.name.contains(filter) {
continue
}
print(file.name)
}
}
private func printDirectories() throws {
var folders = try Folder(path: path).subfolders
if includeHidden {
folders = folders.includingHidden
}
for folder in folders {
if let filter, !folder.name.contains(filter) {
continue
}
print("[\(folder.name)]")
}
}
}
We have some unnecessary duplication, but the code serves our purpose and the results are looking good:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ swift run MyApp -di
-- MyApp --
[.build]
[.git]
[.swiftpm]
[Sources]
[Tests]
.gitignore
Package.resolved
Package.swift
README.md
$ swift run MyApp -di -f git
-- MyApp --
[.git]
.gitignore
There’s also @OptionGroup
which is used to compile multiple parameters into a struct (conforming to ParsableArguments
) for reusability, e.g. across multiple Subcommand
s.
We look at subcommands in the next section and make use of @OptionGroup
there.
Subcommands & Configuration
For more complex applications, the ArgumentParser package lets you define Subcommands as part of the applications configuration.
E.g. having an additional parameter-like keyword after the application’s name, allowing you to group utilities within your application, like:
1
$ MyApp subcommand --parameter
This is controlled via a CommandConfiguration
object defined as a static property on your base ParsableCommand
.
Just for illustration, I’ll add a second command name-length
to our existing tool, which will simply print out the character count of the names of files and directories in our list instead of the actual names.
1
2
3
4
5
6
7
8
9
@main
struct MyApp: ParsableCommand {
static var configuration = CommandConfiguration(
abstract: "A neat little tool to list files and directories",
version: "1.2.3",
subcommands: [List.self, NameLength.self],
defaultSubcommand: List.self
)
}
With this, we just have to move our previous implementation to a newly created List
type and can also utilized the aforementioned @OptionGroup
to reuse all our arguments for the second command in NameLength
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct GlobalOptions: ParsableArguments {
@Argument(help: "Path of the directory to be listed")
var path: String = "."
@Flag(name: [.customLong("directories"), .customShort("d")], help: "Include directories")
var includeDirectories: Bool = false
@Flag(name: .shortAndLong, help: "Include hidden files/directories")
var includeHidden: Bool = false
@Option(name: .shortAndLong, help: "Filter the list of files and directories")
var filter: String?
}
extension MyApp {
struct List: ParsableCommand {
@OptionGroup var args: GlobalOptions
// ...
}
struct NameLength: ParsableCommand {
@OptionGroup var args: GlobalOptions
// ...
}
I’ve shortened the sample code here a bit, because the implementation of NameLength
is almost identical to List
.
1
2
3
4
5
6
7
8
$ swift run MyApp name-length
Building for debugging...
[3/3] Linking MyApp
Build complete! (0.73s)
-- MyApp --
16
13
9
Parsing into complex types
Date https://forums.swift.org/t/support-for-date/34797
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func parseDate(_ formatter: DateFormatter) -> (String) throws -> Date {
{ arg in
guard let date = formatter.date(from: arg) else {
throw ValidationError("Invalid date")
}
return date
}
}
let shortFormatter = DateFormatter()
shortFormatter.dateStyle = .short
// .....later
@Argument(transform: parseDate(shortFormatter))
var date: Date
Tests
The package template already created a test target MyAppTests
in Package.swift
for us. It contains an example of a functional test case for the template’s Hello, world!
output.
In Xcode 14 Swift 5.7 the package struct and test setup has changed slighly.
However the new test case seems more like a unit test than a functional test, to be honest…
I can recommend to have a look at the TestHelpers
of the ArgumentParser
Repository. Especially AssertExecuteCommand(command:, expected:)
, which simplifies creating functional tests that execute a command and check for its expected output.
1
2
3
4
5
6
func test_output() throws {
try AssertExecuteCommand(
command: "MyApp",
expected: "Hello, world!"
)
}
It’s not possible to add the helpers as a dependency from the official package repository, because Apple isn’t “ready to maintain that module as public API yet”. I’m using a personal fork with the helper product enabled (which I’m maintaining on a best-effort basis 😅).
To test our tool in a functional way, let’s create a temporary directory as a test environment.
We will need to access the products directory where the unit tests are executed:
1
2
3
4
5
6
7
8
9
10
11
12
extension XCTest {
var productsDirectory: URL {
#if os(macOS)
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
return bundle.bundleURL.deletingLastPathComponent()
}
fatalError("couldn't find the products directory")
#else
return Bundle.main.bundleURL
#endif
}
}
And then setup a test directory with some dummy files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final class MyAppTests: XCTestCase {
private var testFolder: Folder!
override func setUpWithError() throws {
try super.setUpWithError()
let productsFolder = try Folder(path: productsDirectory.path)
testFolder = try productsFolder.createSubfolderIfNeeded(withName: "TestDirectory")
try testFolder.empty()
try testFolder.createSubfolder(at: "Subfolder")
try testFolder.createSubfolder(at: ".hiddenFolder")
try testFolder.createFile(at: ".hiddenFile")
try testFolder.createFile(at: "MyApp.swift")
try testFolder.createFile(at: "README.md")
}
override func tearDownWithError() throws {
try? testFolder.delete()
try super.tearDownWithError()
}
Now we can test our tool’s arguments in various combinations against the expected terminal output.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
func test_list_directoriesAndHidden() throws {
let expected = """
-- TestDirectory --
[.hiddenFolder]
[Subfolder]
.hiddenFile
MyApp.swift
README.md
"""
try AssertExecuteCommand(command: cmd + "-di " + testFolder.path, expected: expected)
try AssertExecuteCommand(command: cmd + "--directories --include-hidden " + testFolder.path, expected: expected)
}
You can browse the complete source code for this sample app here.
Distribution
Distributing your app is also surprisingly easy with the help of Mint, a package manager for executable Swift packages.
If you have Mint installed, all you need to do is publish your package in a public repository with the same name on GitHub and run your tool with:
1
2
mint install YOUR_ACCOUNT/APP_NAME
mint run APP_NAME
Appendix
Swift 5.5 and below: Migrate from main.swift
to @main
In the generated template, the swift compiler uses our source file named main.swift
as an entry point and simply runs the top-level code it finds within.
If you want to write more structured code and have a struct
represent the entry point, an easy way to do so is as follows:
- Mark the
struct
with@main
to tell the compiler that this is the new entry point for our application - Rename the file to something other than
main.swift
1
2
3
4
5
6
7
@main
struct MyApp {
static func main() throws {
print("Hello")
}
}
Sample Implementation using main.swift
without @main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Darwin
struct CommandLineApp {
mutating func run() throws {
print("Hello")
}
static func main() {
var cmd = Self()
do {
try cmd.run()
} catch {
print(error)
Darwin.exit(0)
}
}
}
CommandLineApp.main()
References
- Apple Documentation (SPM)
- Creating a Package (CLI tool, etc.)
- https://github.com/apple/swift-argument-parser
- https://github.com/JohnSundell/Files