Post

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 the path parameter for your executableTarget in Package.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’s ParsableCommands require a run() method that acts as the entry point for your command instead of the static main()

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 Subcommands.
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:

  1. Mark the struct with @main to tell the compiler that this is the new entry point for our application
  2. 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


Footnotes

  1. Latest at time of writing. See Releases 

This post is licensed under CC BY 4.0 by the author.