Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.