Skip to content

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

  1. Recursively find .md files under one or more given paths (default: .).
  2. Extract links: inline [text](target), images ![alt](target), reference definitions [ref]: target, and autolinks <https://...>. Ignore anything inside fenced code blocks and inline code.
  3. Classify each link: URL (has a scheme like https:), anchor (#section), or local file.
  4. Local files: resolve relative to the containing file; check existence. If the target is a .md file with a #fragment, check the heading exists.
  5. Anchors: check against the file's own headings, using GitHub's slug rules (## Hello, World! becomes #hello-world).
  6. URLs: report as skipped (a checker that hits the network is a stretch goal).
  7. -q / --quiet prints only broken links.
  8. Exit code: 0 all good, 1 broken links found, 2 usage 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 `![alt](target "title")`
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:

  • FenceTracker is a tiny state machine in a struct. mutating func skips(_:) both updates state and answers the question, and both extractLinks and headingSlugs reuse it, so they agree about what counts as code.
  • inlineTargets works on Array(text) so it can use integer indices and track parenthesis depth ([a](page_(1).md)). Remember from the Strings lesson: String has no integer subscripts, so converting once to an array is the pragmatic choice for scanning code.
  • split(whereSeparator: \.isNewline) rather than split(separator: "\n"): Swift treats "\r\n" as a single Character, so splitting on "\n" would silently fail on Windows line endings. A dedicated test pins this down.
  • hasScheme requires a scheme of at least two characters, so C:\notes.md is a file path rather than a URL with scheme C.

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 the FileManager — private helpers with no extra types.
  • anchorCache means each target file's headings are parsed once, even if a hundred links point at it.
  • Verdict is 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 ![logo](img/logo.png "Logo")."#,
            "`[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 slugs test 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:

swift test
swift build -c release
.build/release/linkcheck docs/

Self-check

  • [ ] swift test is 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 CRLF files are a risk for naive splitting.

Stretch goals

  1. Reference-style usage: resolve [text][ref] against [ref]: target definitions and report a missing definition.
  2. Parallel checking of files with withThrowingTaskGroup (after Advanced lesson 5).
  3. HTTP checks for external URLs using URLSession with a timeout and a concurrency limit, behind a --check-external flag.
  4. --json output using Codable (lesson 9) for CI integration.
  5. Swap in swift-argument-parser for option parsing, --help, and a --root option.
  6. Support HTML anchors (<a id="foo">) as valid fragment targets.
  7. 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.