12 minutes
Swift on the Command Line
Some notes on learning Swift by using it to develop command-line utilities
Swift for non-GUI things is kind of a new area, and while the tools seem well-developed, the developers themselves admit that things aren’t documented in as discoverable a way as they might like, due to their own familiarity with things. Right up front, I can point you at this thread on the Swift forums, which is where I started uncovering some things for myself.
Where’s the executable?
The Swift docs basically tell you that you want to do things via the Swift Package Manager, and I agree with them! Doing things the way the community does them is really important, for several reasons! But while Swift PM does know how to create scaffolding for executables (or tools, which I’ll get to in a minute), it appears to be incredibly biased toward being part of an Xcode development flow.
And in that flow, executables aren’t important in the least. Why would you ever want to run one directly? And why in god’s name would you want to move one around the filesystem? That’s crazy talk (from the perspective of an iOS developer). But we’ll get to that in a moment.
Someone who is used to a more CLI-centric development environment is
going to expect the build process for an executable to result in… a
faily obvious executable. But when you run swift build
in the top
level of a Swift package, as set up by Swift PM, nothing appears to
change – even on success. There will also be no executable, either in
that top directory or in the Sources
directory, where your actual
code is.
But there is an executable, and it’s where ever swift build --show-bin-path
says it is. For me that was
./.build/arm64-apple-macosx/debug
but obviously your mileage will
vary.
How do I get an optimized (non-debug) build?
swift build -c release
per the Github docs for Package
Manager
.
I feel like I’m starting to see a pattern of the “real” (or perhaps it’s more fair to say “in depth”) docs living on Github, rather than on Apple’s site or swift.org. The problem is discovering when a tool or project has additional / less shallow / supplementary documentation on Github.
Also, this changes the tail of the binpath from debug
to release
.
How do I produce a movable binary?
On Mac, I don’t know yet.
It seems to go back to the predominant use case for Swift and its toolchain: app packages, either iOS or Mac OS. The presumption is very much not that you want a binary with no dependencies which can be moved around the filesytem (or between machines).
In the forum thread linked at the top of this article, one person
notes that swift build --static-swift-stdlib
is a thing – but the
next poster points out “Does not work on all platforms”. I don’t think
it actually works at all, because when I tried it I got:
warning: Swift compiler no longer supports statically linking the Swift libraries. They’re included in the OS by default starting with macOS Mojave 10.14.4 beta 3.
So this has actually been broken since 2018. It seems a good candidate for removal from the toolchain?
For now, I’m just using a symlink to the binary produced by swift build
.
…meanwhile, over on Linux…
There is another Swift forums
post
from 2020 which says that a different invocation now works on Linux,
but it requires knowing exactly which libs need to be included and
specifying them all in Package.swift
(the standard Swift PM config
file, which is itself expressed in Swift). That’s archaic, even for
Linux – strong “I’m building Emacs from source, and I have
nonstandard library locations, and it’s 1997” vibes.
However! It turns out that the work described in that post kept moving forward for the intervening 4 years, and has become an official part (though not a default part) of the Swift toolchain, but only on Linux.
https://www.swift.org/documentation/articles/static-linux-getting-started.html
Swift can be a script
Say we have a file named test
:
#!/usr/bin/env swift
print("hello world")
Then we can do:
$ chmod +x test
$ ./test
hello world
$
So that’s neat. And if you can tolerate the runtime compilation costs, it’s a solution to the static binary problem.
What’s the difference between executable and tool?
This came up because the standard examples of initializing a Swift
package all say either --type library
or --type executable
, and me
being me, I wondered what the other options were. The answer is “quite
a lot, but most of them seem pretty obscure/specialized”. But one of
them was tool
, and the output of swift help package init
has this
to say about it:
--type <type> Package type: (default: library)
library - A package with a library.
executable - A package with an executable.
tool - A package with an executable that uses
Swift Argument Parser. Use this template if you
plan to have a rich set of command-line arguments.
To try to be more helpful than the help
, the latter two are
identical except that tool
gives you an initial source file with
some minimal scaffolding for the Argument Parser, which is the Swift
equivalent of Python’s argparse
or Golang’s flags
.
That said, you’ll definitely need the docs because Argument Parser is designed in what I think is a highly idiomatic way. I’m sure this is great if you’re already conversant in Swift, but if this is your first time dipping a toe into the language, it’s very opaque indeed. Luckily the doc example makes the basics pretty clear.
Be sure to read the linked examples. The actual docs have a lot of
good info, but the examples are where you’ll find some neat stuff like
how to get --version
for free, and how to implement the util <CMD> <SUBCMD>
pattern.
Swift errors seem irritating
More specifically, the error presentation is irritating.
In my very first experiments with adding an argument to my machine-generated boilerplate code and compiling it, I made a syntax error. Specifically, I flubbed a type declaration. I should have typed:
var mode: String
but instead I typed:
var mode string
which would have been great if I were writing Go. Ah well.
But the point is that instead of a succinct error message (like Go) or a stack trace with the most immediate / closest-to-the-problem element last (like Python), I got 93 lines of stack, with the most relevent bit at the top. This left me looking at
Swift.Decodable:2:5: note: protocol requires initializer 'init(from:)' with type 'Decodable'
1 | public protocol Decodable {
2 | init(from decoder: any Decoder) throws
| `- note: protocol requires initializer 'init(from:)' with type 'Decodable'
3 | }
rather than the vastly more helpful
roget22_tool.swift:13:14: error: found an unexpected second identifier in variable declaration; is there an accidental break?
11 |
12 | @Argument(help: "")
13 | var mode string
| |- error: found an unexpected second identifier in variable declaration; is there an accidental break?
| |- note: join the identifiers together
| `- note: join the identifiers together with camel-case
14 |
15 | mutating func run() throws {
I assume that this is because the build system assumes that it’s being
run inside Xcode. I further assume that Xcode would take care of the
fact that even in my fullscreen terminal, 93 lines is more than fits
onscreen, so I had to re-run the build and pipe it through less
to
find out what the issue was.
Update: It’s not always this bad. I seem to have hit a pathological case my first time out. I still think the chosen ordering is a counterintuitive choice.
Where’s the standard library?
The Swift standard library is extremely small, and pretty much only defines language builtins.
To find the equivalent of Golang or Python’s standard library, what you want is the Foundation framework .
A tiny example
The Foundation reference linked above is just that: a reference, and it’s light on examples. So here is a snippet to illustrate usage.
This code imports Foundation and uses a function of the FileManager
class to get a listing of files in a directory. The only thing that
might be surprising to someone coming from a language like Python is
that try
attaches to the expression which might throw an error
rather than to the broader statement if there is one (as there is in
this case; the assignment to files
).
import Foundation
let fm = FileManager.init()
do {
let files = try fm.contentsOfDirectory(atPath: "./some/path")
print("got list of \(files.count) files")
print(files)
} catch {
print(error)
}
Exiting (with code)
As you might expect, it’s exit(<RC>)
(e.g. exit(0)
for normal
termination).
This requires importing Foundation
(or Darwin
, which Foundation
transitively imports, if you’re trying to avoid having Foundation in
the mix for whatever reason). Depending on where you are in code you
may be required to specify Foundation.exit()
(my apologies for not
having a good handle on this yet).
If you’re in a top-level function (such as run()
, the fake “main”
provided by Argument Parser) you can also call return
to exit the
function, and thus the program as a whole.
Additionally, Swift provides the syntactic sugar of
guard
to handle some “exit if” cases.
Trace/BPT trap: 5
This is (one of the) way(s) that Swift spells “array bounds violation”. You’ve asked for an element of a composite data type which doesn’t exist.
(Sub)String Indices (and other weirdness)
Strings in Swift are not slice-able in an integer-addressable way, as you may be expecting. It is possible to find threads on the Swift forums of people defending this decision because of “Unicode correctness” and because strings in Swift are “collections of Characters”. But Go and Python both handle Unicode correctly and do have strings that are addressable by character count (and strings in Go are arrays of runes, which seems to map very cleanly onto the concept of “collections of Characters”).
This is the first thing that I’ve encountered in Swift that I have actively disagreed with, instead of just figuring it out and thinking “Okay, that’s different”. Either this is an over-engineering situation, or there’s some underlying platform justification for this that I didn’t find in my quick searching. Anyway, carrying on…
To, say, get a substring of a string, you supply a starting and ending
imdex
, and indices are objects. So this code:
if let i = name.firstIndex(of: "[") {
let j = name.firstIndex(of: "]")
desc = String(name[i ..< j!])
}
Says, “if name
contains a open bracket, store its location in i
and then: find the location of the matching close bracket (stowing it
in j
); then extract the substring using the half-open range
operator
and put that in desc
”.
But i
and j
are index
objects, not integers. And while the
half-open range will exclude position j
, the open bracket at
position i
will be included in the substring, which isn’t what we
want. It seems very natural to say String(name[i+1 ..< j!])
, but
that is a no-no:
error: binary operator '+' cannot be applied to operands of type 'String.Index' and 'Int'
The solution in Swift is to generate the index of the string in question which is after the index that we have, pointing to the open bracket, changing the last time to:
desc = String(name[name.index(after: i) ..< j!])
String tangents and notes
If you’ve been wondering what j!
means, in this context it’s
effectively “I, the programmer, am aware that this value could be
nil
, and I give you permission to explode if it is. Don’t let the
compiler whine about it.”
(But !
actually is the force-unwrap operator. Check the Swift docs
on optionals
(short
,
long
)
for more information.)
You may also have been wondering why we’re casting a slice of a string
to String. Surely the substring of a string is also a string? No; it
is a distinct type, the
SubString
. You
can treat it like a String is at least some contexts (e.g. you can
print()
a SubString with no conversion or casting), but not
others. And if you hang on to one, you can create leaks because they
contain references to their parent string.
To remove leading/trailing whitespace: string.trimmingCharacters(in: .whitespaces)
JSON and runtime OS version checks
As in Go, user-defined composite data structures are structs
. Unlike
Go (or Python) you can’t just throw a structure at the JSON
marshaller/serializer library. You have to declare your type to be
Codable
(or Encodable, or Decodable).
Other than that things are fairly predictable, excepting how file
output is handled. The Data object that you get back from calling
JSONEncode
does not have a file-writing method which accepts a
string (which represents a filepath). Instead it has a method which
writes to a URL – which, and this should not be a surprise by now, is
an object and not a string.
Now, you can create a URL from a string which represents a filepath! But only on Mac OS. And only if the version is greater than v13 (aka Ventura, circa 2022, because you can’t guarantee that a user has ever updated the runtimes that came with their install OS). And this is where I discovered how serious Swift really is about preventing runtime errors.
Since you must ensure that your code is executing on Mac OS 13+, in
order to create a URL from a filepath string, to pass to the write
method of the Data that you get back from JSONEncode
, the block you
need to dump a user-defined struct to a file as JSON is gonna look
something like this:
do {
let data = try JSONEncoder().encode(myStruct)
if #available(macOS 13.0, *) {
try data.write(to: URL(filePath: "./path/to/myfile.json"))
} else {
print("bro your Mac OS is ancient")
}
} catch {
print(error)
}
Leaving out the check block (if #available...
) is a compile error.
Reading JSON
var livedata = MyStruct()
var jsondata = Data()
do {
if #available(macOS 13.0, *) {
jsondata = try Data(contentsOf: URL(filePath: "./path/to/myfile.json"))
} else {
print("bro your Mac OS is ancient")
}
livedata = try JSONDecoder().decode(MyStruct.self, from: jsondata)
} catch {
print(error)
}
Updates
The original version of this post was from 20 minutes of fiddling around, then two hours of searching, reading, and documenting. I’m updating it as I discover more things that are worth writing down.
- 2024-11-24: “Where’s stdlib?”, Foundation example
- 2024-11-25: Copy editing,
exit
- 2024-11-26:
guard
, Trace/BPT, strings, JSON