Build a Swift CLI Tool — ArgumentParser, File I/O, and Terminal Apps
In this tutorial, you will learn about Build a Swift CLI Tool. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a Swift command-line tool using Apple's ArgumentParser library that processes files, handles terminal I/O, and is packaged as an executable SPM project for distribution via Homebrew or direct download.
What You'll Build
- A CLI tool for file analysis (word count, line count, frequency)
- ArgumentParser for command-line arguments
- File system operations with FileManager
- Colorized terminal output
- SPM executable packaging
- Error handling for invalid input
Why It Matters
CLI tools automate repetitive tasks. Swift's ArgumentParser makes building professional-quality command-line tools trivial. Many popular developer tools — including SwiftLint, Sourcery, and CocoaPods — are written in Swift.
Real-World Use
A team maintains a "CodeAnalyzer" CLI tool that scans Swift files for TODOs, FIXMEs, and code smells. It runs in CI and fails the build if too many issues are found. The tool is distributed via Homebrew.
Learning Path
flowchart LR A[Project: Game
Lesson 36] --> B[Project: CLI Tool
You are here] B --> C[Server-Side Swift
Lesson 38] B --> D[App Architecture
Lesson 39] style B fill:#f90,color:#fff
Project Setup
mkdir FileAnalyzer
cd FileAnalyzer
swift package init --name FileAnalyzer --type executable
Add the ArgumentParser dependency to Package.swift:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "FileAnalyzer",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git",
from: "1.3.0"),
],
targets: [
.executableTarget(
name: "FileAnalyzer",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
),
]
)
Core Command
import ArgumentParser
import Foundation
@main
struct FileAnalyzer: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "fileanalyzer",
abstract: "Analyze text files and report statistics.",
discussion: "Count lines, words, characters, and more in text files.",
version: "1.0.0",
subcommands: [Stats.self, Find.self, Replace.self, Watch.self]
)
}
Stats Subcommand
import ArgumentParser
import Foundation
extension FileAnalyzer {
struct Stats: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Display file statistics."
)
@Argument(help: "File path to analyze")
var path: String
@Flag(name: .shortAndLong, help: "Show detailed statistics")
var detailed = false
@Flag(name: .shortAndLong, help: "Include hidden files")
var all = false
@Option(name: .shortAndLong, help: "Encoding to use (utf8, utf16, ascii)")
var encoding: String = "utf8"
mutating func run() throws {
let url = URL(fileURLWithPath: path)
let data = try Data(contentsOf: url)
guard let content = String(data: data, encoding: .utf8) else {
throw CLIError.invalidEncoding
}
let lines = content.components(separatedBy: .newlines)
let words = content.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
let chars = content.count
let bytes = data.count
print(Terminal.bold("File: \(path)"))
print(String(repeating: "─", count: 40))
print("Lines: \(Terminal.green("\(lines.count)"))")
print("Words: \(Terminal.cyan("\(words.count)"))")
print("Chars: \(Terminal.yellow("\(chars)"))")
print("Bytes: \(Terminal.magenta("\(bytes)"))")
if detailed {
print(String(repeating: "─", count: 40))
print(Terminal.bold("Detailed Statistics:"))
print("Avg line length: \(chars / max(lines.count, 1))")
print("Max line length: \(lines.map(\.count).max() ?? 0)")
let freq = wordFrequency(content)
print(Terminal.bold("\nTop 10 Words:"))
for (word, count) in freq.prefix(10) {
let bar = String(repeating: "█", count: count)
print(" \(Terminal.cyan(word.padding(toLength: 15, withPad: " ", startingAt: 0))) \(bar) \(count)")
}
}
}
func wordFrequency(_ text: String) -> [(String, Int)] {
let words = text.lowercased()
.components(separatedBy: .alphanumerics.inverted)
.filter { !$0.isEmpty && $0.count > 2 }
var freq: [String: Int] = [:]
words.forEach { freq[$0, default: 0] += 1 }
return freq.sorted { $0.value > $1.value }
}
}
}
enum CLIError: LocalizedError {
case invalidEncoding
case fileNotFound(String)
case permissionDenied(String)
case invalidPath
var errorDescription: String? {
switch self {
case .invalidEncoding: return "Could not decode file with specified encoding"
case .fileNotFound(let path): return "File not found: \(path)"
case .permissionDenied(let path): return "Permission denied: \(path)"
case .invalidPath: return "Invalid file path"
}
}
}
Terminal Helpers
import Foundation
enum Terminal {
static func bold(_ text: String) -> String { "\u{001B}[1m\(text)\u{001B}[22m" }
static func red(_ text: String) -> String { "\u{001B}[31m\(text)\u{001B}[0m" }
static func green(_ text: String) -> String { "\u{001B}[32m\(text)\u{001B}[0m" }
static func yellow(_ text: String) -> String { "\u{001B}[33m\(text)\u{001B}[0m" }
static func blue(_ text: String) -> String { "\u{001B}[34m\(text)\u{001B}[0m" }
static func magenta(_ text: String) -> String { "\u{001B}[35m\(text)\u{001B}[0m" }
static func cyan(_ text: String) -> String { "\u{001B}[36m\(text)\u{001B}[0m" }
static func error(_ text: String) -> String { red("Error: \(text)") }
static func warning(_ text: String) -> String { yellow("Warning: \(text)") }
static func success(_ text: String) -> String { green("✓ \(text)") }
}
Find Subcommand
import ArgumentParser
import Foundation
extension FileAnalyzer {
struct Find: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Search for text in files."
)
@Argument(help: "Pattern to search for")
var pattern: String
@Argument(help: "File or directory to search")
var path: String
@Option(name: .shortAndLong, help: "File extension filter (e.g., swift, md)")
var ext: String?
@Flag(name: .shortAndLong, help: "Case insensitive search")
var ignoreCase = false
@Flag(name: .long, help: "Show line numbers")
var lineNumbers = false
mutating func run() throws {
let url = URL(fileURLWithPath: path)
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else {
throw CLIError.fileNotFound(path)
}
let searchPattern = ignoreCase ? pattern.lowercased() : pattern
var totalMatches = 0
if isDir.boolValue {
let enumerator = FileManager.default.enumerator(at: url,
includingPropertiesForKeys: nil)
while let fileURL = enumerator?.nextObject() as? URL {
if let ext = ext, fileURL.pathExtension != ext { continue }
totalMatches += try searchFile(fileURL, pattern: searchPattern)
}
} else {
totalMatches = try searchFile(url, pattern: searchPattern)
}
print(Terminal.success("Found \(totalMatches) matches"))
}
func searchFile(_ url: URL, pattern: String) throws -> Int {
let content = try String(contentsOf: url, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
var matches = 0
for (index, line) in lines.enumerated() {
let searchLine = ignoreCase ? line.lowercased() : line
if searchLine.contains(pattern) {
matches += 1
let location = "\(url.lastPathComponent):\(index + 1)"
let prefix = lineNumbers ? Terminal.cyan("[\(index + 1)] ") : ""
print(" \(Terminal.blue(location)): \(prefix)\(line.trimmingCharacters(in: .whitespaces).prefix(100))")
}
}
return matches
}
}
}
Replace Subcommand
import ArgumentParser
import Foundation
extension FileAnalyzer {
struct Replace: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Replace text in files."
)
@Argument(help: "Text to find")
var find: String
@Argument(help: "Replacement text")
var replace: String
@Argument(help: "File path")
var path: String
@Flag(name: .shortAndLong, help: "Dry run (show changes without saving)")
var dryRun = false
mutating func run() throws {
let url = URL(fileURLWithPath: path)
var content = try String(contentsOf: url, encoding: .utf8)
let original = content
content = content.replacingOccurrences(of: find, with: replace)
let changes = zip(original.components(separatedBy: .newlines),
content.components(separatedBy: .newlines))
.enumerated()
.filter { $0.element.0 != $0.element.1 }
if changes.isEmpty {
print(Terminal.warning("No occurrences of '\(find)' found"))
return
}
print("Found \(changes.count) occurrence(s):")
for (_, (old, new)) in changes.prefix(10) {
print(" \(Terminal.red(old.prefix(60))) → \(Terminal.green(new.prefix(60)))")
}
if changes.count > 10 {
print(" ... and \(changes.count - 10) more")
}
if !dryRun {
try content.write(to: url, atomically: true, encoding: .utf8)
print(Terminal.success("File updated"))
} else {
print(Terminal.warning("Dry run — no changes saved"))
}
}
}
}
Watch Subcommand
import ArgumentParser
import Foundation
extension FileAnalyzer {
struct Watch: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Watch a file for changes."
)
@Argument(help: "File to watch")
var path: String
@Option(name: .shortAndLong, help: "Polling interval in seconds")
var interval: Double = 1.0
mutating func run() throws {
let url = URL(fileURLWithPath: path)
var lastModification = try fileModificationDate(url)
print(Terminal.blue("Watching \(path) every \(interval)s..."))
print("Press Ctrl+C to stop")
repeat {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
let currentMod = try? fileModificationDate(url)
if currentMod != lastModification {
lastModification = currentMod
print(Terminal.green("\n[\(Date().formatted(date: .omitted, time: .standard))] File changed!"))
let stats = try await analyzeFile(url)
print(stats)
}
} while true
}
func fileModificationDate(_ url: URL) throws -> Date? {
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
return attrs[.modificationDate] as? Date
}
func analyzeFile(_ url: URL) async throws -> String {
let content = try String(contentsOf: url, encoding: .utf8)
let lines = content.components(separatedBy: .newlines).count
let words = content.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }.count
return "Lines: \(lines), Words: \(words)"
}
}
}
Usage Examples
# Build
swift build -c release
# Run stats
./.build/release/FileAnalyzer stats README.md --detailed
# Search for TODO
./.build/release/FileAnalyzer find "TODO" Sources/ --ext swift --line-numbers
# Replace text (dry run)
./.build/release/FileAnalyzer replace "old" "new" config.json --dry-run
# Watch file changes
./.build/release/FileAnalyzer watch log.txt --interval 2
# Get help
./.build/release/FileAnalyzer --help
./.build/release/FileAnalyzer stats --help
Key Takeaways
- ArgumentParser handles all CLI boilerplate
- ANSI escape codes provide colored terminal output
- FileManager enables file system operations
- Subcommands create a professional tool hierarchy
- SPM packaging makes distribution easy
Common Mistakes
Not handling file encoding properly: Always specify encoding. Default to UTF-8 but allow configuration.
Blocking the main thread with file I/O: For large files, use async file reading or read in chunks.
Not validating paths: Check file existence and permissions before reading.
Ignoring stderr for errors: Print errors to
FileHandle.standardError, not standard output.Not cleaning up ANSI codes in logs: Strip ANSI codes when output is piped to a file.
Practice Questions
- How does ArgumentParser handle subcommands?
- Why should errors go to stderr instead of stdout?
- How would you add JSON output mode (--json)?
- What is the purpose of --dry-run in the Replace command?
- Challenge: Add a
--recursiveflag to the Stats command that walks a directory tree and produces a summary of all files found.
FAQ
What's Next
Explore Server-Side Swift with Vapor, or learn App Architecture patterns for large-scale iOS apps.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro