The Ultimate Guide To Swift Executables πŸš€

Every macOS/iOS developer uses tools such as fastlane and SwiftLint to automate tasks as much as possible:
in this article we're going to dive into how we can build our own tools using Swift.

Why Swift?

There are multiple advantages of using Swift: - we know the language - building a script is similar to building an app or a library - everybody in the team can read, understand, and edit what any script does

In fact, everything is so familiar that we're going to build a script with the Swift Package Manager!

Creating An Executable

Creating a new package in Xcode defaults into library. Instead, we're going to use the command line to create an executable, please open your favorite terminal and fire the following commands:

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

Once the last command completes, a bunch of new files have been created, and our new executable is ready to run! Let's take a look at what we have first.

The Package Structure

Before diving into the package structure, it is important to know what a Package Target is.

Package Target(s)

A Package Target is a basic building block of any Swift Package, you can think of a target as a module or a library, its representation is a folder:
all files within that folder belongs to that specific target, and every file can access to all other declarations within that folder.

A Target can depend on other targets, said targets can be within the same package or come from external packages, we will cover both cases in this article.

There are two kinds of targets: those that are compiled into a module, and those that are compiled into a test suite (more on this soon).

The Structure

When we run $ swift package init --type executable, the basic structure of our package is automatically generated:

β”œβ”€β”€ Package.swift
β”œβ”€β”€ README.md
β”œβ”€β”€ Sources
β”‚   └── hello
β”‚       └── main.swift
└── Tests
    β”œβ”€β”€ helloTests
    β”‚   β”œβ”€β”€ helloTests.swift
    β”‚   └── XCTestManifests.swift
    └── LinuxMain.swift

This structure is very similar to a library package:

  • The Tests folder is where our tests are, we will have a folder for each test target.
  • The Sources folder is where our script code will live, we will have one folder for each module target. The compiler distinguishes executable modules from library modules thanks to the presence, or absence, of a main.swift file in the target folder.
  • The README.md is for us to describe the purpose of the package.
  • Lastly, we have the most important file: the Package.swift manifest, we will look at this next.

From the file structure above we can see that we have two targets:
- a helloTests test target. - a hello module target, which we now know is an executable module, as it contains a main.swift file.

While this is all clear to us now, nothing really works until it's declared in the Package.swift manifest file.

The Package Manifest

Package.swift is the entry point of every package, it showcases all there is to know about it:

// swift-tools-version:5.1

import PackageDescription

let package = Package(
    name: "hello",
    dependencies: [
    ],
    targets: [
        .target(
            name: "hello",
            dependencies: []),
        .testTarget(
            name: "helloTests",
            dependencies: ["hello"]),
    ]
)

The generated Package.swift file.

We can double-click on this file to open the whole package in Xcode.

The first line always declares the Swift tools version, which lets the compiler know the minimum version of the Swift tools (such as the PackageDescription module) and Swift language (compatibility) version to use in order to process the manifest file.

Without this line, the compiler can't know if a package has been written in Swift 3, 6 or else:
therefore this line is required, and we wouldn't be able to compile the package without it.

We then import the PackageDescription module, which defines all the APIs available for us to use when declaring a Package.

Lastly, we have our package definition, in here we find:

  • The package name which defaults to the folder name where we ran the $ swift package init command.
  • The package external dependencies there are none at the moment.
  • The package targets the generated package has two targets, a module target, .target, and a test target, .testTarget.

If not specified otherwise, the folder of each target has the same name as the target, and all .target declarations are to be found in the Source folder, while the .testTarget targets are to be found in the Tests folder.

Note how each target can have its own dependencies, these dependencies can come from within the package itself, like in our .testTarget case, or from external packages (we will see how to do so later on in this article).

Now that we understand the package structure and its declaration, it's time to look at the content of hello target folder: the main.swift file.

main.swift

print("Hello, world!")

That's it! The whole body is one print statement ready to be run, let's do so!

Build, Run, And Test A Package

$ swift build
$ swift run
$ swift test

Building A Package

swift build will resolve, download, and compile any dependency mentioned in the package manifest file, it will then build the specified target dependencies (if any) and finally the target itself.

Since our package only has one target, it is inferred, and we can omit its name in the command.
The complete command would be $ swift build --target hello.

Running A Package

As for the build phase, since we only have one target, we can omit its name, however the complete command is $ swift run hello.

With this, we should see the following in the terminal:

$ swift run 
> Hello, world!

Congratulations on running your first executable!

We don't have to build every time before running:$ swift run will automatically build the target when necessary.

Testing A Package

We can run all the package tests via $ swift test.

It's best to always run all the tests. If we'd like to run only a subset, the swift test command offers a --filter flag that does exactly that.

For example, we can run all the tests in helloTests with $ swift test --filter helloTests.

Common Patterns

There are a few very common use cases in the scripting world, let's cover them!

Exit Codes & System Modules

All scripts have an exit status (or code) when complete, the most common are 0 for success and 1 for failure. While those are the de facto standards, I prefer to avoid writing magic numbers in code:

this is why you'll see me importing the Darwin module in some examples below: Darwin defines the two values for a successful exit, EXIT_SUCCESS, and unsuccessful exit, EXIT_FAILURE.

Darwin is Apple’s UNIX-based core of every Apple OS, I like to think of it as the foundation of the Foundation framework (Foundation imports Darwin) but really Darwin is much more than that.

Here's a silly example, please replace the main.swift content with the following:

import Darwin

// Flip a coin.
if Bool.random() {
  exit(EXIT_SUCCESS) // exit(0)
} else {
  exit(EXIT_FAILURE) // exit(1)
}

While this script does absolutely nothing, it will exit with success only 50% of the time.

Hide this script in your company project build phases to see your colleagues lose their minds πŸ˜†

This script showcases another important aspect of Swift Packages: we do not need to declare system dependencies.
All system modules (such as Foundation and Darwin) can be freely imported in our scripts without having to add such dependency in our Package.swift manifest.

Lastly, if the script ends because it completes its execution, it automatically ends with a success state: we don't need to call exit(EXIT_SUCCESS) at the bottom of every script.

Launch Arguments

Most scripts need some input before running, for example a path to a folder, a meaningful value, etc.

This kind of input can be read via the CommandLine object, which holds the parameters passed to the script at launch.

To test it out, please replace the current main.swift content with the following:

import Darwin

// We drop the first argument, which is the script execution path.
let arguments: [String] = Array(CommandLine.arguments.dropFirst())

guard let name: String = arguments.first else { 
  exit(EXIT_FAILURE) 
}

print("Hello \(name)")

And here's how you can run:

$ swift run hello Swift
> Hello Swift
$ swift run hello Federico
> Hello Federico

The first argument of CommandLine.arguments is always the script execution path, while the following arguments are the user inputs, for example:

$ swift run hello 1 2 3

Results in a CommandLine.arguments String array of four elements:

["<execution-path-here>", "1", "2", "3"]

Interactive Scripts

Sometimes scripts need more user input after launch, for such cases we can use readLine():

import Darwin

print("What`s your name?")

guard let name = readLine(), !name.isEmpty else {
  exit(EXIT_FAILURE)
}

print("Hello \(name)")

readLine() is a synchronous call that waits for the user to type something (until the return key is pressed) before proceeding, here's how you can run it:

$ swift run hello
> What`s your name?
> Federico
> Hello Federico

The third line, Federico, is something that we type in the console.

Environment Variables

A fundamental aspect of many automation/CI tools is having access to the inherited shell environment, in Swift this couldn't be easier:

import Foundation

let environment: [String: String] = ProcessInfo.processInfo.environment

if let secret = environment["MYSECRET"] {
  print(secret)
}

ProcessInfo is part of Foundation.

$ MYSECRET=FiVeStArStOkEn swift run hello
> FiVeStArStOkEn

Pipeline Messages

One of the most powerful features of scripts is the concept of pipeline messages: this concept lets us chain multiple scripts, where a script input is the output of the previous script.

In order to support that, we can use Foundation's FileHandle:

import Foundation

let standardInput: FileHandle = .standardInput

if let input = String(data: standardInput.availableData, encoding: .utf8) {
  print(input)
}

This script expects data at launch: if there's no data, it's going to wait until some is given.

FileHandle manages data associated with files, sockets, pipes, and devices:
in our case we use it to read the available data from the standardInput terminal.

Here's an example where we use our new script in a pipeline with the list command ls:

$ ls -1 | swift run hello
> Package.swift
> README.md
> Sources
> Tests

In this case our script reads the pipeline input and prints it out.This is a similar behavior to the concatenate command cat.

Similarly, here's how we'd use it for our script to pass data to the next script in the pipeline:

import Foundation

let standardOutput: FileHandle = .standardOutput

if let outputData = "Five stars".data(using: .utf8) {
  standardOutput.write(outputData)
}

In this example we take our script output and pass it to cat:

$ swift run | cat
> Five stars

cat reads the pipeline input and prints it out.

Asynchronous Calls

In our apps it's completely fine and even encouraged to do work asynchronously: when we pass a block to a DispatchQueue, we know that our block will run at some point in the future.

In the executables world, our scripts life ends as soon as we reach the end of the main.swift file:
if we dispatch something, it's likely that our script ends before the dispatched block had a chance to execute.

Does it mean that we cannot do any asynchronous work in our scripts? Absolutely not.
There are multiple ways to achieve this, I suggest using either RunLoop.current.run() or dispatchMain(), both of them do a similar thing: - the former puts the script loop on hold and waits for input data to process. - the latter starts a loop on the main queue which waits for blocks to execute.

What matters is that both stop the script from terminating, this way: - we can do as much (asynchronous) work as we like - once the work is complete, we must send an exit signal to terminate our execution

As an example, here's how we can make an asynchronous fetch request:

import Foundation

let url = URL(string: "https://api.github.com/users/zntfdr")!
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, _, error in
  if let data = data {
    let responseText = String(data: data, encoding: .utf8)!
    print(responseText)
    exit(EXIT_SUCCESS)
  } else {
    print(error!.localizedDescription)
    exit(EXIT_FAILURE)
  }
}.resume()

RunLoop.current.run() // or dispatchMain()

Replace the main.swift content with this to try it out!

Common Patterns Standards

Certain patterns are repeated across many scripts:
wouldn't it be great if there was a common standard for all the scripts to conform to?

The incredible team behind the Swift Package Manager has released two packages that do just that:
please welcome ArgumentParser and swift-tools-support-core.

More specifically, we're going to use the SwiftToolsSupport product of the latter, which consists of two libraries: TSCUtility and TSCBasic (TSC = Tools Support Core).

This package was previously part of the original swift-package-manager package, and these two libraries were previously known as SPMUtility and Basic.

As we will see shortly, these two modules offer powerful abstractions for common operations.

Even if these packages are released and maintained by people at Apple, they're not system modules, therefore we need to add them as a dependency to our own package, let's see how to do so!

Adding a Package Dependency

We've seen that Package.swift lists the package dependencies, let's add our new one there:

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/apple/swift-tools-support-core.git",
                 from: "0.0.1"),
        .package(url: "https://github.com/apple/swift-argument-parser",
                 from: "0.0.1"),
    ],
    targets: [
        ...
    ]
)

If you'd like to find out the latest official version of any package, use the following link: https://github.com/AUTHOR/PACKAGE/releases/latest, for example https://github.com/apple/swift-tools-support-core/releases/latest.

Our package now depends on both ArgumentParser and swift-tools-support-core, next we need to declare which targets want to use a product of these packages, to do so, please update the hello target dependencies as seen below:

let package = Package(
    ...
    dependencies: [
        ...
    ],
    targets: [
        .target(
            name: "hello",
            dependencies: ["ArgumentParser", "SwiftToolsSupport"]),
        .testTarget(...),
    ]
)

Our hello target can now use the libraries offered by both ArgumentParser and SwiftToolsSupport, let's try them out!

Parse Input Arguments

This section has been rewritten to use the new Argument Parser, If you'd like to see how we got there, please see my deep dive into TSCUtility's ArgumentParser here.

The ArgumentParser library has an incredible documentation section, in this article I'm going to make a small example: make sure to bookmark the official documentation for many more examples and insights into the library.

If you'd like to know more about the inner workings of the ArgumentParser package, please see this deep dive.

We've already seen how we can read launch arguments via the CommandLine, so why would we need to use ArgumentParser for this? Let me give you a few reasons:

  • Previously we were manually reading an array of strings and then parse/convert them to something meaningful: this is equivalent of fetching a JSON object and then manually parse its contents instead of using JSONDecoder.
  • Scripts might be launched with more than just raw values: we might expect a combination of flags (for example ls -la, ls -l -a, ls -al, ls -a -l are all valid commands and do the same thing), or a mix of flags and values.
  • Good scripts offer auto completion for their arguments.
  • Good scripts also come with documentation: wouldn't it be great if all our scripts follow the same pattern?

...and this is why we use ArgumentParser, which ticks all the boxes (beside autocompletion, for now), and more.

In this example we will require a flag --name with an associated value: after implementing it, the scripts will expect to be launched with something like $ swift run hello --name YourName:

  1. ArgumentParser requires us to define a type conforming to ParsableCommand: `swift struct Hello: ParableCommand { } `
  1. Then we declare one or more expected arguments via one of the four ArgumentParser's' property wrappers: `swift struct Hello: ParableCommand { @Option(help: "Specify your name.") var name: String } `
  1. To complete the definition, we need to add our custom logic in the ParsableCommand's run() method:
struct Hello: ParsableCommand {
  // Declare expected launch argument(s).
  @Option(help: "Specify your name.")
  var name: String

  // Our custom logic goes here.
  func run() throws {
    print("Hello \(name)")
  }
}

run() executes only when all the declared launch arguments have been assigned. If an argument has a mistmatch or is missing, the script will rise and error and our logic won't run.

We then can trigger Swift to both do the parsing and execute our logic via a static ParsableCommand function:

Hello.main()

Here's the complete code:

import ArgumentParser

// Define our parser.
struct Hello: ParsableCommand {
  // Declare expected launch argument(s).
  @Option(help: "Specify your name.")
  var name: String

  func run() throws {
    print("Hello \(name)")
  }
}

// Run the parser.
Hello.main()

Replace the main.swift content with this to try it out!

And here are a few examples of the script in action:

$ swift run hello --name Federico
> Hello Federico

$ swift run hello Federico
> Error: Unexpected argument 'Federico'
> Usage: hello --name <name>

$ swift run hello --name
> Error: Missing value for '--name <name>'
> Usage: hello --name <name>

$ swift run hello --help
> USAGE: hello --name <name>
>
> OPTIONS:
>   --name <name>           Specify your name.
>   -h, --help              Show help information.

This is how the swift command line tool works as well: if you run $ swift --help, or $ swift run --help, etc you will see exactly the same format as our new script.

Progress State

In our apps we never want to block the UI while we're doing some work.
In scripts this is not always the case, because we might need to finish the current work before the user can continue its flow.

While this is completely fine and acceptable, leaving the terminal frozen while we're doing so is not the best user experience: the user might think that the script is stuck.

To address this issue, we can use one of the progress animations that TSCUtility gives us, here's an example:

Adding an animation is pretty straight forward:

  1. First we initialize the animation itself, in this case we're using PercentProgressAnimation: `swift let animation = PercentProgressAnimation( stream: stdoutStream, header: "Loading Awesome Stuff ✨" ) `
  1. Then we need to let the animation know when to update: `swift animation.update(step: i, total: 100, text: "Loading..") ` > Note how we don't need to worry about the presentation at all: we only pass the current step, the number ot total steps, and then TSCUtility will take care of the rest for us.
  1. Lastly, we call animation complete when the work is done: `swift animation.complete(success: true) `

And this is the final code:

import Darwin
import TSCBasic
import TSCUtility

let animation = PercentProgressAnimation(
  stream: stdoutStream,
  header: "Loading Awesome Stuff ✨")

for i in 0..<100 {
  let second: Double = 1_000_000
  usleep(UInt32(second * 0.05))
  animation.update(step: i, total: 100, text: "Loading..")
}

animation.complete(success: true)
print("Done! πŸš€")

Replace the main.swift content with this to try it out!

Colors

The progress state that we just talked about had something new that we haven't touched before: colors! Let's see how we can add colors to our scripts.

  1. First of all, we need to create a TerminalController, this object helps us control a terminal: for example it allows operations such as cursor movement and colored text output: `swift let terminalController = TerminalController(stream: stdoutStream) `
  1. Then we must choose which TerminalController.Color variation we would like to use, as of the current release, these are the options: noColor, red, green, yellow, cyan, white, black, and grey.
  1. Lastly, we print our message in the command line: `swift terminalController?.write("Hello World", inColor: .yellow, bold: true) `
  1. With the TerminalController we have more control over a simple print command, therefore the command write alone doesn't end the line: we must end the line ourselves by calling endLine(). `swift terminalController?.endLine() `

Here's the example:

import TSCBasic

let terminalController = TerminalController(stream: stdoutStream)

let colors: [TerminalController.Color] = [
  .noColor, .red, .green, .yellow, .cyan, .white, .black, .grey
]

for color in colors {
  terminalController?.write("Hello World", inColor: color, bold: true)
  terminalController?.endLine()
}

Replace the main.swift content with this to try it out!

Releasing A Script

We now have everything we need to build a great script. There's a final step that we must take in order to complete our journey: ship it!

So far we've ran the script by using $ swift run hello, but this works only if we are in the hello/ folder: how can we run our script from anywhere?

Two steps:

  1. Generate our script binary in release mode. `shell $ swift build --configuration release ` Until now we've used the default debug build strategy when building our script, this works great while developing it, however it's no longer necessary when we want to release it. This configuration flag will create a new executable in the .build/release/ folder.
    1. Copy the new binary to the user binary folder. `shell $ cp .build/release/hello /usr/local/bin/hello ` /usr/local/bin/ is a folder know as user binary folder which contains plenty of executable binaries: anything placed there can be launched from the terminal by using its name, which means that we can now run our script with: `shell $ hello `

    Here are the two command lines for easy copy-pasting:

    $ swift build -c release
    $ cp .build/release/hello /usr/local/bin/hello
    

    That's it! We can now run our script from anywhere!

    Conclusions

    In this article we've started from exploring how a Swift Executable is structured, how we can read its contents from the package manifest file, and then we've moved into common script patterns and how we can use Apple's swift-tools-support-core in order to achieve high quality scripts without having to do all the work ourselves.

    I hope this reference guide helps you kick-start your Swift scripting journey, and I would love to know what scripts you're going to build! Please let me know on Twitter!

    Thank you for reading and stay tuned for more articles! πŸš€

    β­‘β­‘β­‘β­‘β­‘

    Further Reading

    Explore Swift

    Browse all