Lab 1.B — Build a real CLI with Swift Package Manager

Goal: Ship a working command-line tool, built with SwiftPM, that takes flags, reads a file, does real work, returns proper exit codes, and is publishable to GitHub for anyone with Swift installed to git clone && swift run.

Time budget: 90 minutes.

Prerequisites: Ch 1.2, Ch 1.4, Ch 1.8. Comfortable in a terminal.

What you’ll build

A wordstats CLI:

$ wordstats --file README.md --min-length 4 --top 10
Top 10 words (min length 4) in README.md:
  swift     42
  package   31
  ...

Total words: 1,247  |  Unique: 412

Optional flags: --json to emit JSON instead of a table; --ignore words.txt to load a stopword list.

Step 1 — Scaffold the package

mkdir wordstats && cd wordstats
swift package init --type executable --name wordstats

You should now have:

Package.swift
Sources/wordstats/wordstats.swift
Tests/wordstatsTests/wordstatsTests.swift

Verify it builds: swift build, then swift run wordstats.

Step 2 — Add swift-argument-parser

In Package.swift, add the dependency and link it:

let package = Package(
    name: "wordstats",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser",
                 from: "1.5.0"),
    ],
    targets: [
        .executableTarget(
            name: "wordstats",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]),
        .testTarget(
            name: "wordstatsTests",
            dependencies: ["wordstats"]),
    ]
)

Then swift package update. Apple’s ArgumentParser is the de-facto standard for Swift CLIs (used by Apple’s own tooling, swift-format, swift-syntax, etc.).

Step 3 — The command definition

Replace Sources/wordstats/wordstats.swift with:

import ArgumentParser
import Foundation

@main
struct WordStats: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "wordstats",
        abstract: "Compute word-frequency statistics for a text file."
    )

    @Option(name: [.short, .long], help: "Path to the input text file.")
    var file: String

    @Option(name: .long, help: "Minimum word length to include.")
    var minLength: Int = 1

    @Option(name: .long, help: "How many of the top words to display.")
    var top: Int = 20

    @Option(name: .long, help: "Path to a stopword list (one word per line).")
    var ignore: String?

    @Flag(name: .long, help: "Output as JSON instead of a table.")
    var json: Bool = false

    func run() throws {
        let url = URL(fileURLWithPath: file)
        let text = try String(contentsOf: url, encoding: .utf8)

        let stop: Set<String> = try {
            guard let ignore else { return [] }
            let raw = try String(contentsOfFile: ignore, encoding: .utf8)
            return Set(raw.lowercased().split(whereSeparator: \.isNewline).map(String.init))
        }()

        let stats = WordCounter.count(text: text, minLength: minLength, stopwords: stop)

        if json {
            try Output.json(stats: stats, top: top)
        } else {
            Output.table(stats: stats, top: top, file: file, minLength: minLength)
        }
    }
}

Step 4 — The counting engine (in its own file, for testing)

Create Sources/wordstats/WordCounter.swift:

import Foundation

struct WordStats {
    let counts: [String: Int]
    var totalWords: Int  { counts.values.reduce(0, +) }
    var uniqueWords: Int { counts.count }
}

enum WordCounter {
    static func count(text: String, minLength: Int, stopwords: Set<String>) -> WordStats {
        let words = text
            .lowercased()
            .components(separatedBy: CharacterSet.alphanumerics.inverted)
            .filter { $0.count >= minLength && !stopwords.contains($0) }
        var counts: [String: Int] = [:]
        for w in words { counts[w, default: 0] += 1 }
        return WordStats(counts: counts)
    }
}

Notice: pure function, no I/O. That’s what makes it testable.

Step 5 — Output helpers

Create Sources/wordstats/Output.swift:

import Foundation

enum Output {
    static func table(stats: WordStats, top: Int, file: String, minLength: Int) {
        let sorted = stats.counts.sorted { $0.value > $1.value }.prefix(top)
        print("Top \(top) words (min length \(minLength)) in \(file):")
        for (word, n) in sorted {
            print("  \(word.padding(toLength: 16, withPad: " ", startingAt: 0))\(n)")
        }
        print()
        print("Total words: \(stats.totalWords)  |  Unique: \(stats.uniqueWords)")
    }

    static func json(stats: WordStats, top: Int) throws {
        struct Payload: Encodable {
            let top: [Entry]
            let totalWords: Int
            let uniqueWords: Int
        }
        struct Entry: Encodable { let word: String; let count: Int }

        let entries = stats.counts.sorted { $0.value > $1.value }
            .prefix(top)
            .map { Entry(word: $0.key, count: $0.value) }
        let payload = Payload(top: entries,
                              totalWords: stats.totalWords,
                              uniqueWords: stats.uniqueWords)

        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let data = try encoder.encode(payload)
        print(String(decoding: data, as: UTF8.self))
    }
}

Step 6 — Tests

Replace Tests/wordstatsTests/wordstatsTests.swift:

import XCTest
@testable import wordstats

final class WordCounterTests: XCTestCase {

    func test_counts_are_case_insensitive() {
        let s = WordCounter.count(text: "Apple apple APPLE", minLength: 1, stopwords: [])
        XCTAssertEqual(s.counts["apple"], 3)
    }

    func test_respects_min_length() {
        let s = WordCounter.count(text: "a bb ccc dddd", minLength: 3, stopwords: [])
        XCTAssertEqual(s.counts.keys.sorted(), ["ccc", "dddd"])
    }

    func test_ignores_stopwords() {
        let s = WordCounter.count(text: "the cat sat on the mat",
                                  minLength: 1, stopwords: ["the", "on"])
        XCTAssertEqual(s.counts["the"], nil)
        XCTAssertEqual(s.counts["on"],  nil)
        XCTAssertEqual(s.counts["cat"], 1)
    }

    func test_strips_punctuation() {
        let s = WordCounter.count(text: "Hello, world! Hello.",
                                  minLength: 1, stopwords: [])
        XCTAssertEqual(s.counts["hello"], 2)
        XCTAssertEqual(s.counts["world"], 1)
    }
}

Run them: swift test.

Step 7 — Use it

swift build -c release
.build/release/wordstats --file README.md --min-length 4 --top 10
.build/release/wordstats --file README.md --json --top 5 | jq .top

For convenience, you can copy the binary to a folder on your PATH:

cp .build/release/wordstats /usr/local/bin/
git init
git add . && git commit -m "Initial commit"
# create repo on GitHub
git remote add origin git@github.com:<you>/wordstats.git
git push -u origin main

Anyone can now do git clone … && swift run wordstats --file foo.txt.

Done when

  • swift test passes all four tests.
  • wordstats --file <some-file> --top 5 prints a sorted table.
  • --json produces valid JSON (verify with | jq .).
  • --ignore stop.txt actually drops the stopwords.
  • The repo is on GitHub.

Stretch goals

  • Add a --watch flag that re-runs whenever the file changes (use DispatchSource.makeFileSystemObjectSource).
  • Support reading from stdin when no --file is given (cat foo.txt | wordstats).
  • Add a --bigrams flag that counts two-word phrases instead.
  • Package the binary as a Homebrew formula for your tap.

Real-world context

Apple’s own developer tools (swift-format, swift-syntax-test, xcrun simctl shims) are all SwiftPM CLIs using ArgumentParser. The skeleton you just built scales to those tools.


Next lab: 1.C — Protocol-oriented calculator