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/
Step 8 — Publish (optional but recommended)
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 testpasses all four tests. -
wordstats --file <some-file> --top 5prints a sorted table. -
--jsonproduces valid JSON (verify with| jq .). -
--ignore stop.txtactually drops the stopwords. - The repo is on GitHub.
Stretch goals
- Add a
--watchflag that re-runs whenever the file changes (useDispatchSource.makeFileSystemObjectSource). - Support reading from stdin when no
--fileis given (cat foo.txt | wordstats). - Add a
--bigramsflag 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