12. Capstone: Markdown link checker¶
Goal¶
Build a real tool with a proper package structure, a library/executable split, and tests. It scans Markdown files, extracts every link, checks that local files and #anchors exist, and prints a report with a CI-friendly exit code.
This exercises: structs and enums, FileManager, string processing, protocol-free but testable pure functions, Result-style verdicts, SwiftPM targets, and Swift Testing.
What it does¶
$ linkcheck docs/
docs/index.md
OK 3:./setup.md
FAIL 4:./missng.md (file not found)
FAIL 7:./setup.md#instal (no heading #instal in ./setup.md)
SKIP 9:https://swift.org (external URL)
docs/setup.md
OK 12:#install
Checked 5 links in 2 files: 2 OK, 2 broken, 1 skipped
$ echo $?
1
Requirements¶
- Recursively find
.mdfiles under one or more given paths (default:.). - Extract links: inline
[text](target), images, reference definitions[ref]: target, and autolinks<https://...>. Ignore anything inside fenced code blocks and inline code. - Classify each link: URL (has a scheme like
https:), anchor (#section), or local file. - Local files: resolve relative to the containing file; check existence. If the target is a
.mdfile with a#fragment, check the heading exists. - Anchors: check against the file's own headings, using GitHub's slug rules (
## Hello, World!becomes#hello-world). - URLs: report as skipped (a checker that hits the network is a stretch goal).
-q/--quietprints only broken links.- Exit code:
0all good,1broken links found,2usage or I/O error.
Project layout¶
linkcheck/
├── Package.swift
├── Sources/
│ ├── LinkCheckKit/
│ │ ├── Links.swift # pure: extractLinks, classify, slugify, headingSlugs
│ │ └── Checker.swift # filesystem: findMarkdownFiles, check
│ └── linkcheck/
│ └── main.swift # argument parsing + report (thin)
└── Tests/
└── LinkCheckKitTests/
├── LinksTests.swift # pure functions — no files
└── CheckerTests.swift # uses a temporary directory
The split matters: Links.swift is pure (text in, data out) and trivially testable. Checker.swift touches the filesystem. main.swift only handles arguments and output. Put the logic in a library, keep the executable thin — the shape from the SwiftPM lesson.
The manifest¶
Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "linkcheck",
products: [
.executable(name: "linkcheck", targets: ["linkcheck"])
],
targets: [
.target(name: "LinkCheckKit"),
.executableTarget(name: "linkcheck", dependencies: ["LinkCheckKit"]),
.testTarget(name: "LinkCheckKitTests", dependencies: ["LinkCheckKit"]),
]
)
Step 1: parsing (pure code)¶
Start with Sources/LinkCheckKit/Links.swift. Write the tests (below) first if you like — each function takes a string and returns data, so there's nothing to mock.
import Foundation
/// A link found in a Markdown document.
public struct Link: Equatable, Sendable {
public var line: Int // 1-based
public var target: String // exactly as written, minus any title
public init(line: Int, target: String) {
self.line = line
self.target = target
}
}
public enum LinkKind: Equatable, Sendable {
case url // https://..., mailto:..., etc.
case anchor(String) // #heading in the same file
case file(path: String, fragment: String?) // ./other.md#heading
}
// MARK: - Classification
public func classify(_ target: String) -> LinkKind {
if target.hasPrefix("#") { return .anchor(String(target.dropFirst())) }
if hasScheme(target) { return .url }
var path = target
var fragment: String?
if let hash = path.firstIndex(of: "#") {
fragment = String(path[path.index(after: hash)...])
path = String(path[..<hash])
}
if let question = path.firstIndex(of: "?") {
path = String(path[..<question])
}
return .file(path: path, fragment: fragment)
}
/// "https:", "mailto:" — but not a Windows drive letter such as "C:\".
func hasScheme(_ text: String) -> Bool {
guard let colon = text.firstIndex(of: ":") else { return false }
let scheme = text[..<colon]
guard scheme.count > 1, let first = scheme.first, first.isLetter else { return false }
return scheme.allSatisfy { $0.isLetter || $0.isNumber || "+-.".contains($0) }
}
// MARK: - Headings and slugs
/// GitHub-style anchor: "Hello, World!" -> "hello-world".
public func slugify(_ heading: String) -> String {
var slug = ""
for ch in heading.lowercased() {
if ch.isLetter || ch.isNumber || ch == "_" || ch == "-" {
slug.append(ch)
} else if ch == " " {
slug.append("-")
}
}
return slug
}
/// Every anchor a document defines. Repeated headings get "-1", "-2", ... suffixes.
public func headingSlugs(in markdown: String) -> Set<String> {
var slugs: Set<String> = []
var seen: [String: Int] = [:]
var fences = FenceTracker()
for line in markdown.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
if fences.skips(line) { continue }
let hashes = line.prefix(while: { $0 == "#" }).count
guard (1...6).contains(hashes), line.dropFirst(hashes).first == " " else { continue }
let text = line.dropFirst(hashes)
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
.trimmingCharacters(in: .whitespaces)
let base = slugify(text)
let count = seen[base, default: 0]
seen[base] = count + 1
slugs.insert(count == 0 ? base : "\(base)-\(count)")
}
return slugs
}
// MARK: - Extracting links
public func extractLinks(from markdown: String) -> [Link] {
var links: [Link] = []
var fences = FenceTracker()
let lines = markdown.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
for (offset, line) in lines.enumerated() {
if fences.skips(line) { continue }
let text = withoutInlineCode(line)
var targets = inlineTargets(in: text)
if let reference = referenceTarget(in: text) { targets.append(reference) }
for auto in autolinks(in: text) where !targets.contains(auto) { targets.append(auto) }
for target in targets where !target.isEmpty {
links.append(Link(line: offset + 1, target: target))
}
}
return links
}
/// Tracks whether we are inside a fenced code block (``` or ~~~).
struct FenceTracker {
private var open: Character?
/// True for fence lines and for lines inside a fence — i.e. lines to ignore.
mutating func skips(_ line: Substring) -> Bool {
let trimmed = line.drop(while: { $0 == " " })
let marker: Character? = trimmed.hasPrefix("```") ? "`" : (trimmed.hasPrefix("~~~") ? "~" : nil)
if let current = open {
if marker == current { open = nil }
return true
}
if let marker {
open = marker
return true
}
return false
}
}
func withoutInlineCode(_ line: Substring) -> String {
var result = ""
var inCode = false
for ch in line {
if ch == "`" {
inCode.toggle()
} else if !inCode {
result.append(ch)
}
}
return result
}
/// `[text](target)` and ``
func inlineTargets(in text: String) -> [String] {
let chars = Array(text)
var targets: [String] = []
var i = 0
while i + 1 < chars.count {
if chars[i] == "]" && chars[i + 1] == "(" {
var j = i + 2
var depth = 1
var inner = ""
while j < chars.count {
if chars[j] == "(" {
depth += 1
} else if chars[j] == ")" {
depth -= 1
if depth == 0 { break }
}
inner.append(chars[j])
j += 1
}
if j < chars.count { targets.append(cleanTarget(inner)) }
i = j
}
i += 1
}
return targets
}
/// `[ref]: target "title"`
func referenceTarget(in text: String) -> String? {
let trimmed = String(text.drop(while: { $0 == " " }))
guard trimmed.hasPrefix("["), let close = trimmed.range(of: "]:") else { return nil }
let rest = trimmed[close.upperBound...].trimmingCharacters(in: .whitespaces)
let target = cleanTarget(rest)
return target.isEmpty ? nil : target
}
/// `<https://example.com>`
func autolinks(in text: String) -> [String] {
var found: [String] = []
var current: String?
for ch in text {
if ch == "<" {
current = ""
} else if ch == ">" {
if let candidate = current, hasScheme(candidate), !candidate.contains(" ") {
found.append(candidate)
}
current = nil
} else if current != nil {
current?.append(ch)
}
}
return found
}
/// Removes surrounding `<>` and any trailing "title".
func cleanTarget(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("<"), let end = trimmed.firstIndex(of: ">") {
return String(trimmed[trimmed.index(after: trimmed.startIndex)..<end])
}
if let space = trimmed.firstIndex(where: { $0 == " " || $0 == "\t" }) {
return String(trimmed[..<space])
}
return trimmed
}
Things worth studying:
FenceTrackeris a tiny state machine in a struct.mutating func skips(_:)both updates state and answers the question, and bothextractLinksandheadingSlugsreuse it, so they agree about what counts as code.inlineTargetsworks onArray(text)so it can use integer indices and track parenthesis depth ([a](page_(1).md)). Remember from the Strings lesson:Stringhas no integer subscripts, so converting once to an array is the pragmatic choice for scanning code.split(whereSeparator: \.isNewline)rather thansplit(separator: "\n"): Swift treats"\r\n"as a singleCharacter, so splitting on"\n"would silently fail on Windows line endings. A dedicated test pins this down.hasSchemerequires a scheme of at least two characters, soC:\notes.mdis a file path rather than a URL with schemeC.
Step 2: checking (filesystem)¶
Sources/LinkCheckKit/Checker.swift:
import Foundation
public enum Verdict: Equatable, Sendable {
case ok
case broken(String)
case skipped(String)
}
public struct LinkReport: Sendable {
public let file: URL
public let link: Link
public let verdict: Verdict
}
public enum CheckerError: Error, CustomStringConvertible {
case notFound(String)
public var description: String {
switch self {
case .notFound(let path): return "no such file or directory: \(path)"
}
}
}
/// Expands directories into the sorted list of `.md` files beneath them.
public func findMarkdownFiles(in paths: [String]) throws -> [URL] {
let fm = FileManager.default
var found: [URL] = []
for path in paths {
var isDirectory: ObjCBool = false
guard fm.fileExists(atPath: path, isDirectory: &isDirectory) else {
throw CheckerError.notFound(path)
}
let root = URL(fileURLWithPath: path)
if isDirectory.boolValue {
guard let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) else { continue }
for case let url as URL in walker where url.pathExtension.lowercased() == "md" {
found.append(url)
}
} else {
found.append(root)
}
}
return found.sorted { $0.path < $1.path }
}
/// Checks every link in `files` and returns one report per link, in file/line order.
public func check(files: [URL]) throws -> [LinkReport] {
let fm = FileManager.default
var anchorCache: [URL: Set<String>] = [:]
func anchors(in url: URL) throws -> Set<String> {
if let cached = anchorCache[url] { return cached }
let text = try String(contentsOf: url, encoding: .utf8)
let slugs = headingSlugs(in: text)
anchorCache[url] = slugs
return slugs
}
func verdict(for link: Link, in file: URL) throws -> Verdict {
switch classify(link.target) {
case .url:
return .skipped("external URL")
case .anchor(let fragment):
let known = try anchors(in: file)
return known.contains(fragment.lowercased()) ? .ok : .broken("no heading #\(fragment)")
case .file(let path, let fragment):
if path.hasPrefix("/") { return .skipped("root-relative path") }
let decoded = path.removingPercentEncoding ?? path
let resolved = URL(fileURLWithPath: decoded, relativeTo: file.deletingLastPathComponent())
.standardizedFileURL
var isDirectory: ObjCBool = false
guard fm.fileExists(atPath: resolved.path, isDirectory: &isDirectory) else {
return .broken("file not found")
}
guard let fragment, !isDirectory.boolValue, resolved.pathExtension.lowercased() == "md" else {
return .ok
}
let known = try anchors(in: resolved)
return known.contains(fragment.lowercased()) ? .ok : .broken("no heading #\(fragment) in \(path)")
}
}
var reports: [LinkReport] = []
for file in files {
let text = try String(contentsOf: file, encoding: .utf8)
for link in extractLinks(from: text) {
reports.append(LinkReport(file: file, link: link, verdict: try verdict(for: link, in: file)))
}
}
return reports
}
- Nested functions (
anchors(in:),verdict(for:in:)) capture the cache and theFileManager— private helpers with no extra types. anchorCachemeans each target file's headings are parsed once, even if a hundred links point at it.Verdictis an enum with associated values: the reason travels with the failure. This is the sum-type idea from the Enums lesson.
Step 3: the command-line front end¶
Sources/linkcheck/main.swift:
import Foundation
import LinkCheckKit
func usage() -> String {
"""
usage: linkcheck [-q|--quiet] [path ...]
Checks local links and #anchors in Markdown files.
Paths may be files or directories (default: current directory).
exit status: 0 all links fine, 1 broken links found, 2 usage or I/O error
"""
}
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(2)
}
var quiet = false
var paths: [String] = []
for argument in CommandLine.arguments.dropFirst() {
switch argument {
case "-q", "--quiet":
quiet = true
case "-h", "--help":
print(usage())
exit(0)
case _ where argument.hasPrefix("-"):
fail("unknown option '\(argument)'\n\(usage())")
default:
paths.append(argument)
}
}
if paths.isEmpty { paths = ["."] }
let files: [URL]
let reports: [LinkReport]
do {
files = try findMarkdownFiles(in: paths)
reports = try check(files: files)
} catch {
fail("linkcheck: \(error)")
}
var okCount = 0
var brokenCount = 0
var skippedCount = 0
var currentFile: URL?
for report in reports {
let place = "\(report.link.line):\(report.link.target)"
let line: String
switch report.verdict {
case .ok:
okCount += 1
if quiet { continue }
line = " OK \(place)"
case .skipped(let reason):
skippedCount += 1
if quiet { continue }
line = " SKIP \(place) (\(reason))"
case .broken(let reason):
brokenCount += 1
line = " FAIL \(place) (\(reason))"
}
if report.file != currentFile {
if currentFile != nil { print() }
print(report.file.path)
currentFile = report.file
}
print(line)
}
if !quiet && !reports.isEmpty { print() }
print("Checked \(reports.count) links in \(files.count) files: \(okCount) OK, \(brokenCount) broken, \(skippedCount) skipped")
exit(brokenCount > 0 ? 1 : 0)
Notice what's absent: no parsing, no filesystem rules. If you wanted a GUI, a GitHub Action, or an editor plugin, you would write another thin front end over LinkCheckKit.
Step 4: tests¶
Tests/LinkCheckKitTests/LinksTests.swift:
import Testing
@testable import LinkCheckKit
@Suite("Pure parsing") struct LinksTests {
@Test(arguments: [
("Hello, World!", "hello-world"),
("C++ & Swift", "c--swift"),
("snake_case stays", "snake_case-stays"),
("Ünïcode 123", "ünïcode-123"),
])
func slugs(heading: String, expected: String) {
#expect(slugify(heading) == expected)
}
@Test func classification() {
#expect(classify("https://swift.org") == .url)
#expect(classify("mailto:a@b.com") == .url)
#expect(classify("#intro") == .anchor("intro"))
#expect(classify("./a.md") == .file(path: "./a.md", fragment: nil))
#expect(classify("../b.md#part") == .file(path: "../b.md", fragment: "part"))
#expect(classify("img/logo.png?raw=1") == .file(path: "img/logo.png", fragment: nil))
#expect(classify(#"C:\notes.md"#) == .file(path: #"C:\notes.md"#, fragment: nil)) // drive letter, not a scheme
}
@Test func extractsEveryLinkStyleAndIgnoresCode() {
let fence = String(repeating: "`", count: 3)
let markdown = [
"# Title",
#"See [setup](./setup.md) and ."#,
"`[not](a-link.md)` is inline code.",
"",
"\(fence)swift",
#"let x = "[nope](nope.md)""#,
fence,
"",
"[ref]: https://example.com/ref",
"Auto <https://swift.org> link.",
].joined(separator: "\n")
let links = extractLinks(from: markdown)
#expect(links == [
Link(line: 2, target: "./setup.md"),
Link(line: 2, target: "img/logo.png"),
Link(line: 9, target: "https://example.com/ref"),
Link(line: 10, target: "https://swift.org"),
])
}
@Test func handlesParenthesesAndAngleBrackets() {
let links = extractLinks(from: "[a](page_(1).md) [b](<my file.md>)")
#expect(links.map(\.target) == ["page_(1).md", "my file.md"])
}
@Test func handlesWindowsLineEndings() {
let links = extractLinks(from: "# Hi\r\n[a](a.md)\r\n[b](b.md)\r\n")
#expect(links == [Link(line: 2, target: "a.md"), Link(line: 3, target: "b.md")])
}
@Test func headingSlugsDeduplicateAndSkipCode() {
let fence = String(repeating: "`", count: 3)
let markdown = [
"# Intro",
"## Usage",
"## Usage",
"### `code` Heading ###",
fence,
"# Not A Heading",
fence,
].joined(separator: "\n")
#expect(headingSlugs(in: markdown) == ["intro", "usage", "usage-1", "code-heading"])
}
}
Tests/LinkCheckKitTests/CheckerTests.swift:
import Foundation
import Testing
@testable import LinkCheckKit
@Suite("Filesystem checks") struct CheckerTests {
/// A fresh directory per test: tests run in parallel, so never share one.
func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("linkcheck-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test func reportsEachKindOfLink() throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let a = dir.appendingPathComponent("a.md")
let b = dir.appendingPathComponent("b.md")
try """
# Home
[ok](b.md)
[frag](b.md#intro)
[badfrag](b.md#nope)
[missing](gone.md)
[self](#home)
[badself](#nowhere)
[web](https://swift.org)
""".write(to: a, atomically: true, encoding: .utf8)
try "# Intro\n".write(to: b, atomically: true, encoding: .utf8)
let reports = try check(files: [a])
#expect(reports.map(\.verdict) == [
.ok,
.ok,
.broken("no heading #nope in b.md"),
.broken("file not found"),
.ok,
.broken("no heading #nowhere"),
.skipped("external URL"),
])
}
@Test func findsMarkdownRecursivelyInSortedOrder() throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let sub = dir.appendingPathComponent("sub", isDirectory: true)
try FileManager.default.createDirectory(at: sub, withIntermediateDirectories: true)
for name in ["z.md", "a.md", "notes.txt", "sub/m.md"] {
try "x".write(to: dir.appendingPathComponent(name), atomically: true, encoding: .utf8)
}
let names = try findMarkdownFiles(in: [dir.path]).map(\.lastPathComponent)
#expect(names == ["a.md", "m.md", "z.md"])
}
@Test func missingPathThrows() {
#expect(throws: CheckerError.self) {
try findMarkdownFiles(in: ["/definitely/not/here"])
}
}
}
- The parameterized
slugstest turns four cases into four separately reported results. - Every filesystem test creates its own temporary directory with a UUID name. Swift Testing runs tests in parallel, so shared fixture paths would cause flaky failures.
defer { try? removeItem }cleans up even if an expectation throws.
Run everything:
Self-check¶
- [ ]
swift testis green on your platform. - [ ] You can explain why a link inside a fenced code block is ignored (and where that logic lives).
- [ ] Running the tool on its own project's Markdown (or on this course's
docs/folder!) gives sensible output. - [ ] A directory link (
[x](../)) reports OK, a missing file reports FAIL. - [ ] You understand why
CRLFfiles are a risk for naive splitting.
Stretch goals¶
- Reference-style usage: resolve
[text][ref]against[ref]: targetdefinitions and report a missing definition. - Parallel checking of files with
withThrowingTaskGroup(after Advanced lesson 5). - HTTP checks for external URLs using
URLSessionwith a timeout and a concurrency limit, behind a--check-externalflag. --jsonoutput usingCodable(lesson 9) for CI integration.- Swap in
swift-argument-parserfor option parsing,--help, and a--rootoption. - Support HTML anchors (
<a id="foo">) as valid fragment targets. - Fix suggestions: for a broken anchor, suggest the nearest existing slug using edit distance.
When you're done¶
You have a tested, modular command-line tool and a feel for how Swift code is organised into packages. Next up: Advanced — protocol-oriented design, advanced generics, concurrency, ownership, performance, and C interop.