Command Line in Swift: the Different Alternatives
When building a command line tool using Swift, there are a few different approaches you can choose. Although you can migrate from one to another with some refactoring, the goal of this post is to compare the pros and cons of each solution, to help you decide what’s the best fit for your use case.
The most common paths you can choose are:
- a script as a single Swift, without dependencies
- a script, but with dependencies using swift sh
- an executable target via SPM (with or without the argument parser)
The following sections will bring arguments (pun intended 🥁) with the advantages and disadvantages of each one, as well as how to run or build.
script.swift
The easiest way to get started with a command line tool in Swift is by writing a single file:
import Foundation
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
print(dateFormatter.string(from: Date()))
This code will print the current date and time, with your machine’s current locale.
You can try this yourself: create a new file named now.swift with the code above. To make it faster, click the copy button, open Terminal and write: pbpaste > now.swift
. This will paste the copied content and save it to a file in the current working directory. To execute this code, run swift now.swift
. You should see the current date printed in the console.
To support reading arguments in a script, use the CommandLine
and its static property arguments
:
if CommandLine.arguments.contains("--verbose") {
print("Current locale is (Locale.current.identifier)")
}
Run the script with the --verbose
flag: swift now.swift --verbose
, and you will see that it prints the current locale.
The advantage of this approach is that it’s very simple and fast to get started. Having Swift installed in the machine running it is enough.
However, with this comes also its limitations and disadvantages: it doesn’t support adding package dependencies. Your script might get too long, or you’ll find yourself adding similar functions across different scripts. To add dependencies, your script will have to become a compiled executable - or at least a target that the Swift Package Manager (SPM) can build and run.
swift sh
Between the simple script approach and using SPM directly, there is also a tool called swift sh
, by Max Howell. It uses SPM behind the scenes to link packages and execute your code, while keeping the simplicity of a script. To install it with homebrew, run brew install swift-sh
.
Now, importing a dependency doesn’t even require writing a Package.swift file:
import Foundation
import XMLCoder // CoreOffice/XMLCoder
struct Feed: Codable {
let title: String
let entry: [Entry]
struct Entry: Codable {
let title: String
}
}
let url = URL(string: "https://swifttoolkit.dev/feed.xml")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
let feed = try XMLDecoder().decode(Feed.self, from: data)
print(feed.title)
print(feed.entry.suffix(3).map(.title).joined(separator: "\n"))
} catch {
print(error)
}
This script will fetch the XML feed of SwiftToolkit.dev, parse it, and print the title of the last 3 posts.
In the second line of this script, notice the import of XMLCoder, a package for encoding and decoding XML data. The content of the comment right after it tells swift sh
where to find the package at. Check the README to see all different options.
If you copy and execute this script (for the exercise, save it as posts.swift), you can run it with the following command:
swift sh posts.swift
In the first run, it will take a few seconds for swift sh
to do its magic, to fetch the package dependencies, build and run the script. After the first run it should be faster.
Due to the lack of completion and the
swift sh edit
command not working in recent Xcode versions, it might be hard to work on scripts withswift sh
. Although it is as a fast and simple approach to run scripts with dependencies, it might be hard to work on scripts that require constant changes and maintenance.
An SPM Executable Target
The next step on the scale of Swift tooling is creating an executable target with the Swift Package Manager:
swift package init --type executable
If you want a target name different than the default one, pass the --name
argument:
swift package init --type executable --name MyTool
The init command will create a package in the current directory: a Package.swift file, where you can add your dependencies (or other targets), and also a main.swift file, which is the script itself.
Instead of a main.swift file, you can rename it and have an entry point using the @main
attribute:
@main
struct MyTool {
static func main() {
print("Your tool starts here!")
}
}
In a similar manner as explained in the script in the first section, accessing CommandLine.arguments
allows your program to read the arguments passed when running the tool. But doing it manually might be error prone, and there is a better approach!
A Tool Using the Argument Parser
The state of the art in Swift programs is the Argument Parser, created and maintained by Apple itself. It provides property wrappers and many other handy types to create tools.
Being the “official” package, it is automatically added as a dependency when creating an executable with the tool
type:
swift package init --type tool
Inside any ParsableCommand
, you can use the property wrappers for Argument
s, Flag
s, Option
s, and more goodies.
The advantages of building an executable are many:
- your program is not constrained to a single file anymore
- you can depend on packages (local, private or public)
- you can distribute your tool without the source code
In the other hand, you might need to either distribute the source code or the compiled binary, potentially for different OSs (macOS, Linux or even Windows) and architectures (x86 and ARM).
Distributing binaries is a subject for another post!
Running with SPM
To build and run it, use the following command:
swift run
When you have only one target - which is the case initially - there is no need to specify the target name. When multiple targets exist in the same package, you append its name to the run
command. Also, if you want to pass arguments, then the target name is required:
swift run MyTool --verbose
Compiling as Executable
To compile your target into an executable, use the build
command:
swift build
To use the release configuration instead of DEBUG (the default one), pass the -c
argument:
swift build -c release
Finally, you might want to know where your binary will be located at. To find out:
swift build -c release --show-bin-path
You can omit the -c release
argument to see the debug path, or open the path in Finder to better understand the output file structure.
Explore Further
If you have any questions, comments, or suggestions, ping SwiftToolkit.dev at Mastodon or X.
When building programs with the Swift Argument Parser, check out the cheatsheet. It is a quick reference you can use when declaring targets and adding package and target dependencies.
We also have a 3 part series guide about the Swift Argument Parser.
See you at the next post. Have a good one!