11. Building a Program
Everything so far compiled as a single file. Real programs are packages: several
modules, a manifest, dependencies, tests, and a command-line interface. This
chapter builds one, a word-frequency counter called wf, and the finished thing
is in examples/wf beside this text.
11.1 The Package
swift package init --type executable --name wf
That writes a Package.swift and a source directory. Replace the manifest with
this, which splits the program into a library and the command that drives it:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "wf",
targets: [
.target(name: "WordCount"),
.executableTarget(name: "wf", dependencies: ["WordCount"]),
.testTarget(name: "WordCountTests", dependencies: ["WordCount"]),
],
swiftLanguageModes: [.v6]
)
Package.swift is Swift, not a configuration file. It is compiled and run to
produce the description, so the manifest can compute things. The
swift-tools-version comment on the first line is not a comment: it fixes which
manifest API is in use and which language mode the package defaults to.
A target is a module. Its sources live in Sources/<target name> and the
directory layout is the configuration, so there is no list of files to keep up
to date. A product is what the package offers to others, a library or an
executable; a package with no products section, like this one, still builds
its executable targets and is simply not meant to be depended on.
The split is the point. WordCount holds the logic and knows nothing about the
command line. wf holds the command line and is thin. WordCountTests tests
the library, which it can do without running a program or capturing output.
Splitting a small program this way costs one line of manifest and is the
difference between testable and not.
Dependencies go in two places, the package and the target:
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
],
targets: [
.executableTarget(name: "wf", dependencies: [
"WordCount",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
]
swift build resolves and fetches them, writing Package.resolved with the
exact versions used. Commit that file for an executable, so a build a year from
now produces the same program.
11.2 Modules and Access Control
A module is the unit of both namespacing and access control. internal, the
default, means visible throughout the module and invisible outside it, which is
why every type and method in WordCount that wf needs is marked public.
/// Counts how often each word appears across the lines it is given.
public struct Counter {
private var counts: [String: Int] = [:]
public init() {}
public var distinct: Int { counts.count }
public var total: Int { counts.values.reduce(0, +) }
public mutating func add(_ line: String) {
for word in Counter.words(in: line) {
counts[word, default: 0] += 1
}
}
/// Words, most frequent first, ties broken alphabetically.
public func ranked() -> [(word: String, count: Int)] {
counts
.sorted { a, b in a.value == b.value ? a.key < b.key : a.value > b.value }
.map { (word: $0.key, count: $0.value) }
}
/// Lowercased runs of letters, with apostrophes kept inside a word.
static func words(in line: String) -> [String] {
line.lowercased()
.split(whereSeparator: { !$0.isLetter && $0 != "'" })
.map(trimmingApostrophes)
.filter { !$0.isEmpty }
}
private static func trimmingApostrophes(_ word: Substring) -> String {
var word = word
while word.first == "'" { word = word.dropFirst() }
while word.last == "'" { word = word.dropLast() }
return String(word)
}
}
Three levels are in use. counts is private, so nothing outside the struct can
reach the dictionary and no caller can put it in a state add would not produce.
words(in:) is internal by default, so it is invisible to wf and visible to
the tests, which is what @testable import is for. Everything else is public,
and that list is the module’s surface: four members, each of which somebody
outside needs.
A public struct gets no public initializer unless you write one, which is why
public init() {} is there. The rule surprises everyone once. A memberwise
initializer is internal, because making it public would freeze the property list
as part of the module’s contract.
Comments beginning /// are documentation. They are what an editor shows on
hover, and what DocC renders into a documentation site once you add the
swift-docc-plugin dependency. On a public API they are the difference between
a module somebody can use and one they have to read.
11.3 The Command Line
/// The command line, parsed.
public struct Options {
public var show = 10
public var path: String?
public enum Failure: Error, CustomStringConvertible {
case missingValue(flag: String)
case notANumber(flag: String, value: String)
case unknownFlag(String)
case tooManyFiles
public var description: String {
switch self {
case .missingValue(let flag):
return "\(flag) needs a value"
case .notANumber(let flag, let value):
return "\(flag) wants a number, not \"\(value)\""
case .unknownFlag(let flag):
return "unknown option \(flag)"
case .tooManyFiles:
return "one file at a time"
}
}
}
public static let usage = "usage: wf [-n count] [file]"
/// Parses everything after the program name.
public init(arguments: [String]) throws {
var rest = arguments[...]
while let argument = rest.first, argument.hasPrefix("-"), argument != "-" {
rest = rest.dropFirst()
switch argument {
case "-n":
guard let value = rest.first else {
throw Failure.missingValue(flag: argument)
}
rest = rest.dropFirst()
guard let n = Int(value) else {
throw Failure.notANumber(flag: argument, value: value)
}
show = n
default:
throw Failure.unknownFlag(argument)
}
}
guard rest.count <= 1 else { throw Failure.tooManyFiles }
path = rest.first
}
}
The parser lives in the library and takes its arguments as a parameter rather
than reading CommandLine.arguments itself, which is what makes it testable
without launching a process. That is the same move as putting the counting in
Counter: a function that reaches out to the world can only be tested by
arranging the world.
arguments[...] takes the whole array as a slice, so consuming an argument is
dropFirst() rather than an index that has to be kept right. Chapter 6’s warning
about slices keeping their original indices is the reason nothing here indexes
with a number.
For anything with more than a few flags, use Apple’s swift-argument-parser,
which derives the parsing, the usage text and the completions from a struct. The
hand-written version is here because you should see what it is doing once.
import Foundation
import WordCount
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data("wf: \(message)\n".utf8))
exit(2)
}
let options: Options
do {
options = try Options(arguments: Array(CommandLine.arguments.dropFirst()))
} catch {
fail("\(error)\n\(Options.usage)")
}
var counter = Counter()
if let path = options.path {
guard let text = try? String(contentsOfFile: path, encoding: .utf8) else {
fail("cannot read \(path)")
}
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
counter.add(String(line))
}
} else {
while let line = readLine() {
counter.add(line)
}
}
for (word, count) in counter.ranked().prefix(options.show) {
print(String(format: "%7d %@", count, word))
}
$ swift run -q wf -n 5 gettysburg.txt
3 and
3 nation
2 a
2 are
2 conceived
$ swift run -q wf -n 3 < gettysburg.txt
3 and
3 nation
2 a
$ swift run -q wf -x; echo "exit=$?"
wf: unknown option -x
usage: wf [-n count] [file]
exit=2
A file named main.swift may contain top-level statements, which is what makes
this read like a script. The alternative is a type marked @main with a
static func main(), which is what you want when the program has real
structure or needs async, and Chapter 12 uses it.
CommandLine.arguments includes the program’s own path at position zero, so it
gets dropped. Errors go to standard error and the program exits non-zero, so
wf composes with other programs in a pipeline: output on stdout, complaints on
stderr, and a status a shell can test. A program that prints its errors to
stdout has corrupted whatever is downstream of it.
-> Never on fail tells the compiler the function does not come back, which
is why the guard else calling it is a legitimate exit and why options is
definitely assigned after the do-catch.
Exercise 11-1. Add a -u flag that prints the number of distinct words and
the total, instead of the ranking.
Exercise 11-2. Make wf accept several files, printing a heading for each.
Decide what tooManyFiles becomes.
Exercise 11-3. Replace the hand-written parser with swift-argument-parser
and compare the amount of code, the usage message, and what breaks when you add
a flag.
11.4 Tests
The swift-testing library ships with the toolchain. A test is a function
marked @Test, and #expect records a failure and keeps going while
#require stops the test.
import Testing
@testable import WordCount
@Test func splitsOnPunctuationAndKeepsApostrophes() {
#expect(Counter.words(in: "Don't stop, don't!") == ["don't", "stop", "don't"])
#expect(Counter.words(in: "one--two three") == ["one", "two", "three"])
#expect(Counter.words(in: "''' ") == [])
}
@Test func countsAndRanks() {
var c = Counter()
c.add("the cat sat on the mat")
c.add("the end")
#expect(c.total == 8)
#expect(c.distinct == 6)
let top = c.ranked()
#expect(top[0] == (word: "the", count: 3))
#expect(top[1].word == "cat")
}
@Test func parsesOptions() throws {
let plain = try Options(arguments: [])
#expect(plain.show == 10)
#expect(plain.path == nil)
let full = try Options(arguments: ["-n", "3", "notes.txt"])
#expect(full.show == 3)
#expect(try #require(full.path) == "notes.txt")
}
@Test func rejectsBadOptions() {
#expect(throws: Options.Failure.self) {
_ = try Options(arguments: ["-n"])
}
#expect(throws: Options.Failure.self) {
_ = try Options(arguments: ["-n", "many"])
}
#expect(throws: Options.Failure.self) {
_ = try Options(arguments: ["-x"])
}
}
$ swift test
✔ Test parsesOptions() passed after 0.001 seconds.
✔ Test splitsOnPunctuationAndKeepsApostrophes() passed after 0.001 seconds.
✔ Test countsAndRanks() passed after 0.001 seconds.
✔ Test rejectsBadOptions() passed after 0.001 seconds.
✔ Test run with 4 tests in 0 suites passed after 0.001 seconds.
@testable import raises the module’s internal members to visible for the
test target, which is how Counter.words(in:) is tested without being made
public. private stays private, so the design is still enforced.
A test function may throw, and a thrown error is a failure, which is why
parsesOptions can write try with no do. #expect(throws:) asserts the
other direction.
Tests can be grouped in a struct marked @Suite, given names with
@Test("counts the words"), run against a list of inputs with
@Test(arguments: [...]), and disabled with .disabled("reason"). XCTest still
works and is what older code uses; new code has no reason to prefer it.
11.5 Shipping
swift build -c release optimizes, and the binary lands in
.build/release/wf. Copy that one file anywhere with a matching Swift runtime,
or build with --static-swift-stdlib on Linux to get an executable that carries
its own.
swift run builds and runs in one step, -q suppressing the build chatter that
would otherwise mix into your program’s output. swift package clean when you
suspect the build directory. swift-format formats.
Exercise 11-4. Write a test that fails, run it, and read the output. The
failure message shows the values on both sides of the ==, which is the reason
#expect is a macro and not a function.
Exercise 11-5. Move Options into its own module and make the tests still
pass. Then decide whether the split was worth it.
Exercise 11-6. Add a test that runs the built executable against
gettysburg.txt and checks its output, and notice how much more it has to
arrange than the tests above.