Building a command line tool using the Swift Package Manager

This week, I’d like to take you on a bit of a “behind the scenes” of Marathon (a command line tool that enables you to easily run & handle Swift scripts), and provide a step-by-step tutorial on the setup I used to build it it using the Swift Package Manager.

I personally really like the Swift Package Manager (which I will from now on refer to as ‘SPM’ to save me some typing 😅 ) and how easy it is to use once you get up and running. But it does have somewhat of a learning curve. So hopefully this post will make it easier for anyone looking to build their own tooling using Swift.

Getting started

To start building a command line tool, make a new directory and initialize it using SPM:

$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable

The type executable above tells SPM that you want to build a command line tool, rather than a framework.

What’s in the box?

After initializing your directory, it will have the following contents:

  • A Package.swift file, which defines your package (even command line tools are packages) and its dependencies.
  • A Sources folder, where you put your source code. It will initially contain a main.swift file, which is the entry point to your command line tool (you can’t rename that file).
  • A Tests folder, where you put your testing code.
  • A .gitignore file, which will make SPM’s build folder (.build) and any Xcode projects you’ll generate ignored by source control.

Splitting your code up into a framework and an executable

One thing I’d recommend that you do right away is to create two modules for your sources — one framework and one executable. This will make testing a lot easier, and will also (and this is really cool) enable your command line tool to also be used as a dependency in other tools.

For Marathon, I split it up into Marathon (executable) and MarathonCore (framework). The Marathon module only contains the main.swift file, while MarathonCore contains all of the tool’s actual functionality.

To make this happen, first, create two folders in Sources, one for the executable and one for the framework, like this:

In Swift 3

$ cd Sources
$ mkdir CommandLineTool
$ mkdir CommandLineToolCore
$ mv main.swift CommandLineTool/main.swift

In Swift 4

$ cd Sources
$ mkdir CommandLineToolCore

One really nice aspect of SPM is that it uses the file system as its “source of truth”, so simply creating new folders enables you to define new modules.

Next, update Package.swift to define two targets — one for the CommandLineTool module and one for CommandLineToolCore:

In Swift 3

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    targets: [
        Target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        Target(name: "CommandLineToolCore")
    ]
)

In Swift 4

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    targets: [
        .target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(name: "CommandLineToolCore")
    ]
)

As you can see above, we make the executable depend on the framework.

Using Xcode

In order to get proper code completion and be able to easily run/debug your command line tool — you probably want to use Xcode. The good news is that SPM can easily generate an Xcode project for you based on the file system. And, since the Xcode project is git ignored, you don’t have to deal with conflicts and updates to it — you simply just re-generate it when needed.

To generate an Xcode project, run the following command in the root folder:

$ swift package generate-xcodeproj

You’ll get a warning when running the above, but just ignore that for now, we’re about to fix it! 🙂

Defining a programmatic entry point

In order to be able to easily run your tool both from the command line and from your tests, it’s a good idea to not put too much functionality into main.swift, and instead enable your tool to be programmatically invoked.

To do this, create a CommandLineTool.swift file in your framework module (Sources/CommandLineToolCore) and add the following contents:

import Foundation

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        print("Hello world")
    }
}

Next, simply call the above run() method from main.swift:

import CommandLineToolCore

let tool = CommandLineTool()

do {
    try tool.run()
} catch {
    print("Whoops! An error occurred: \(error)")
}

Hello world

Let’s take our command line tool for a spin! First, we’ll need to compile it. This is done by calling swift build in the root folder of our package, and then executing the compiled binary:

$ swift build
$ .build/debug/CommandLineTool
> Hello world

Adding dependencies

Unless you are building something a bit more trivial, you’re probably going to find yourself in need of adding some dependencies to your command line tool. Any Swift package can be added as a dependency, simply by specifying it in Package.swift:

In Swift 3

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    targets: [
        Target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        Target(name: "CommandLineToolCore")
    ],
    dependencies: [
        .Package(
            url: "https://github.com/johnsundell/files.git",
            majorVersion: 1
        )
    ]
)

In Swift 4

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    dependencies: [
        .package(
            url: "https://github.com/johnsundell/files.git",
            from: "1.0.0"
        )
    ],
    targets: [
        .target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(
            name: "CommandLineToolCore",
            dependencies: ["Files"]
        )
    ]
)

Above I add Files, which is a framework that enables you to easily handle files and folders in Swift. We will use Files to enable our command line tool to create a file in the current folder.

Installing dependencies

Once you’ve declared any new dependencies, simply ask SPM to resolve your dependencies and install them, and then re-generate the Xcode project:

$ swift package update
$ swift package generate-xcodeproj

Accepting arguments

Let’s modify CommandLineTool.swift, to instead of printing Hello world, now creating a file with the name taken from the arguments passed to the command line tool:

import Foundation
import Files

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        guard arguments.count > 1 else {
            throw Error.missingFileName
        }
        // The first argument is the execution path
        let fileName = arguments[1]

        do {
            try FileSystem().createFile(at: fileName)
        } catch {
            throw Error.failedToCreateFile
        }
    }
}

public extension CommandLineTool {
    enum Error: Swift.Error {
        case missingFileName
        case failedToCreateFile
    }
}

As you can see above, we wrap the call to FileSystem.createFile() in our own do, try, catch in order to provide a unified error API to our users.

Writing tests

We’re almost ready to ship our amazing new command line tool, but before we do — let’s ensure that it actually works as intended by writing some tests.

The good news is that, since we split up our tool into a framework and an executable early, testing it becomes very easy. All we have to do is to run it programmatically, and assert that it created a file with the name that was specified.

Create a folder called CommandLineToolTests in the Tests folder. This folder defines your test module. Then, create a CommandLineToolTests.swift file in that folder:

$ mkdir Tests/CommandLineToolTests
$ echo "" > Tests/CommandLineToolTests/CommandLineToolTests.swift

If you're using Swift 4, you also need to add the new test module to your Package.swift file by adding the following in your targets array:

.testTarget(
    name: "CommandLineToolTests",
    dependencies: ["CommandLineToolCore", "Files"]
)

Finally, re-generate your Xcode project:

$ swift package generate-xcodeproj

Open the Xcode project again, jump over to CommandLineToolTests.swift and add the following contents:

import Foundation
import XCTest
import Files
import CommandLineToolCore

class CommandLineToolTests: XCTestCase {
    func testCreatingFile() throws {
        // Setup a temp test folder that can be used as a sandbox
        let fileSystem = FileSystem()
        let tempFolder = fileSystem.temporaryFolder
        let testFolder = try tempFolder.createSubfolderIfNeeded(
            withName: "CommandLineToolTests"
        )

        // Empty the test folder to ensure a clean state
        try testFolder.empty()

        // Make the temp folder the current working folder
        let fileManager = FileManager.default
        fileManager.changeCurrentDirectoryPath(testFolder.path)

        // Create an instance of the command line tool
        let arguments = [testFolder.path, "Hello.swift"]
        let tool = CommandLineTool(arguments: arguments)

        // Run the tool and assert that the file was created
        try tool.run()
        XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
    }
}

It’s also a good idea to add tests to verify that the proper errors were thrown when a file name wasn’t given, or if the file creation failed.

To run your tests, simply run swift test on the command line.

Installing your command line tool

Now that we’ve built and tested our command line tool, let’s install it to enable it to be run from anywhere on the command line. To do that, build the tool using the release configuration, and then move the compiled binary to /usr/local/bin:

$ swift build -c release -Xswiftc -static-stdlib
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool

You might be wondering why -Xswiftc -static-stdlib is used above. This is to statically link the Swift standard library, so that the command line tool is not bound to the specific version of Swift that it was built with. It’s a good idea to add this when doing a release build, especially if you’re going to distribute it to other people.

Done! 🎉

I hope you enjoyed this post. It’s the first tutorial-ish post that I’ve ever written, so would love your feedback on it. Also, let me know if you have any comments or questions, along with requests for any future weekly blog posts on Twitter @johnsundell.

Thanks for reading! Can’t wait to see what awesome tools you’ll build! 🚀

Time traveling in Swift unit tests

Handling non-optional optionals in Swift