Skip to content

12. Capstone: Markdown Link Checker

Goal

Build a real tool with a proper project structure, a CMake build, tests, and sanitizer-clean code. 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: classes and structs, enums, std::filesystem, <regex>, templates and lambdas, STL algorithms, exceptions, std::format, CMake targets, and Catch2.

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)
  SKIP  9:https://isocpp.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! → #hello-world).
  6. URLs: report as skipped (the standard library has no HTTP client — see stretch goals).
  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/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── main.cpp                 # argument parsing + report (thin)
│   └── linkcheck/
│       ├── links.hpp / .cpp     # parsing: extract_links, classify, slugify
│       └── checker.hpp / .cpp   # filesystem: check_file, find_markdown_files
└── tests/
    ├── CMakeLists.txt
    ├── test_links.cpp           # pure functions — no files
    └── test_checker.cpp         # uses a temporary directory

The split matters: links.cpp is pure (text in, data out) and trivially testable. checker.cpp touches the filesystem. main.cpp only handles arguments and output.

Build files

CMakeLists.txt

cmake_minimum_required(VERSION 3.24)
project(linkcheck VERSION 0.1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

option(LINKCHECK_WERROR "Treat warnings as errors" OFF)

add_subdirectory(src)

include(CTest)
if(BUILD_TESTING)
  add_subdirectory(tests)
endif()

src/CMakeLists.txt

add_library(linkcheck_lib
  linkcheck/links.cpp
  linkcheck/checker.cpp
)
target_include_directories(linkcheck_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(linkcheck_lib PUBLIC
  $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall -Wextra -Wpedantic>
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
  $<$<BOOL:${LINKCHECK_WERROR}>:$<IF:$<CXX_COMPILER_ID:MSVC>,/WX,-Werror>>
)

add_executable(linkcheck main.cpp)
target_link_libraries(linkcheck PRIVATE linkcheck_lib)

tests/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  Catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG        v3.7.1
)
FetchContent_MakeAvailable(Catch2)

add_executable(tests test_links.cpp test_checker.cpp)
target_link_libraries(tests PRIVATE linkcheck_lib Catch2::Catch2WithMain)

include(Catch)
catch_discover_tests(tests)

Parsing

src/linkcheck/links.hpp

#pragma once

#include <string>
#include <string_view>
#include <vector>

namespace linkcheck {

enum class LinkKind { Url, Anchor, LocalFile };

struct Link {
    std::string target;   // exactly as written, e.g. "./setup.md#install"
    int line{};           // 1-based line number in the source file
};

// Extract every link from Markdown text: inline [text](target), reference
// definitions "[ref]: target", and autolinks <https://...>.
// Links inside fenced code blocks are ignored.
std::vector<Link> extract_links(std::string_view markdown);

LinkKind classify(std::string_view target);

// GitHub-style heading slug: "Hello, World!" -> "hello-world"
std::string slugify(std::string_view heading);

// Slugs of every ATX heading ("# ...") in the text, outside code blocks.
std::vector<std::string> heading_slugs(std::string_view markdown);

}  // namespace linkcheck

src/linkcheck/links.cpp

#include "linkcheck/links.hpp"

#include <cctype>
#include <regex>
#include <sstream>

namespace linkcheck {

namespace {

// Calls fn(line, line_number) for every line that is not inside a ``` fence.
template <typename Fn>
void for_each_prose_line(std::string_view text, Fn fn) {
    std::istringstream in{std::string{text}};
    std::string line;
    int line_no = 0;
    bool in_fence = false;
    while (std::getline(in, line)) {
        ++line_no;
        auto first = line.find_first_not_of(" \t");
        if (first != std::string::npos && line.compare(first, 3, "```") == 0) {
            in_fence = !in_fence;
            continue;
        }
        if (!in_fence) {
            fn(line, line_no);
        }
    }
}

// Remove `inline code` spans so links shown as code aren't checked.
std::string strip_inline_code(const std::string& line) {
    static const std::regex code{R"(`[^`]*`)"};
    return std::regex_replace(line, code, "");
}

}  // namespace

std::vector<Link> extract_links(std::string_view markdown) {
    static const std::regex inline_link{R"(!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\))"};
    static const std::regex reference_def{R"(^\s{0,3}\[[^\]]+\]:\s*<?(\S+?)>?(?:\s+.*)?$)"};
    static const std::regex autolink{R"(<(https?://[^>\s]+)>)"};

    std::vector<Link> links;
    for_each_prose_line(markdown, [&](const std::string& raw, int line_no) {
        const std::string line = strip_inline_code(raw);
        std::smatch def;
        if (std::regex_match(line, def, reference_def)) {
            links.push_back({def[1].str(), line_no});
            return;
        }
        for (const auto* re : {&inline_link, &autolink}) {
            for (std::sregex_iterator it{line.begin(), line.end(), *re}, end; it != end; ++it) {
                links.push_back({(*it)[1].str(), line_no});
            }
        }
    });
    return links;
}

LinkKind classify(std::string_view target) {
    if (target.starts_with('#')) {
        return LinkKind::Anchor;
    }
    auto colon = target.find(':');
    auto slash = target.find('/');
    if (colon != std::string_view::npos && (slash == std::string_view::npos || colon < slash)) {
        return LinkKind::Url;   // http:, https:, mailto:, ...
    }
    return LinkKind::LocalFile;
}

std::string slugify(std::string_view heading) {
    std::string slug;
    for (char ch : heading) {
        auto c = static_cast<unsigned char>(ch);
        if (std::isalnum(c) || ch == '-' || ch == '_') {
            slug += static_cast<char>(std::tolower(c));
        } else if (ch == ' ') {
            slug += '-';
        } else if (c >= 0x80) {
            slug += ch;   // keep non-ASCII bytes as-is
        }
    }
    return slug;
}

std::vector<std::string> heading_slugs(std::string_view markdown) {
    static const std::regex atx{R"(^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$)"};
    std::vector<std::string> slugs;
    for_each_prose_line(markdown, [&](const std::string& line, int) {
        std::smatch m;
        if (std::regex_match(line, m, atx)) {
            slugs.push_back(slugify(m[1].str()));
        }
    });
    return slugs;
}

}  // namespace linkcheck

Things to notice:

  • static const std::regex — regexes are expensive to construct; static builds each one once.
  • for_each_prose_line is a function template taking any callable, so the fence-tracking logic is written once and reused by both public functions.
  • Raw string literals R"(...)" keep the regexes readable — no double escaping.
  • std::regex is convenient but slow. It's fine here; the advanced track discusses faster alternatives.

Checking

src/linkcheck/checker.hpp

#pragma once

#include <filesystem>
#include <string>
#include <vector>

#include "linkcheck/links.hpp"

namespace linkcheck {

enum class Status { Ok, Broken, Skipped };

struct Result {
    Link link;
    Status status{Status::Ok};
    std::string reason;   // empty when Ok
};

// Check every link in one Markdown file. URLs are reported as Skipped.
std::vector<Result> check_file(const std::filesystem::path& file);

// Every .md file under the given paths (files are taken as-is), sorted.
std::vector<std::filesystem::path> find_markdown_files(
    const std::vector<std::filesystem::path>& roots);

}  // namespace linkcheck

src/linkcheck/checker.cpp

#include "linkcheck/checker.hpp"

#include <algorithm>
#include <fstream>
#include <map>
#include <sstream>
#include <stdexcept>

namespace fs = std::filesystem;

namespace linkcheck {

namespace {

std::string read_file(const fs::path& path) {
    std::ifstream in{path, std::ios::binary};
    if (!in) {
        throw std::runtime_error("cannot read " + path.string());
    }
    std::ostringstream buffer;
    buffer << in.rdbuf();
    return buffer.str();
}

// Caches the heading slugs of each Markdown file we look inside.
class AnchorIndex {
public:
    bool has_anchor(const fs::path& file, const std::string& anchor) {
        auto key = fs::weakly_canonical(file);
        auto it = cache_.find(key);
        if (it == cache_.end()) {
            it = cache_.emplace(key, heading_slugs(read_file(key))).first;
        }
        return std::ranges::find(it->second, anchor) != it->second.end();
    }

private:
    std::map<fs::path, std::vector<std::string>> cache_;
};

Result check_link(const Link& link, const fs::path& file, AnchorIndex& anchors) {
    switch (classify(link.target)) {
        case LinkKind::Url:
            return {link, Status::Skipped, "external URL"};
        case LinkKind::Anchor: {
            auto anchor = link.target.substr(1);
            if (anchors.has_anchor(file, anchor)) {
                return {link, Status::Ok, ""};
            }
            return {link, Status::Broken, "no heading #" + anchor};
        }
        case LinkKind::LocalFile: {
            auto hash = link.target.find('#');
            auto path_part = link.target.substr(0, hash);
            auto target = file.parent_path() / path_part;
            if (!fs::exists(target)) {
                return {link, Status::Broken, "file not found"};
            }
            if (hash != std::string::npos && target.extension() == ".md") {
                auto anchor = link.target.substr(hash + 1);
                if (!anchors.has_anchor(target, anchor)) {
                    return {link, Status::Broken, "no heading #" + anchor};
                }
            }
            return {link, Status::Ok, ""};
        }
    }
    throw std::logic_error("unhandled LinkKind");
}

}  // namespace

std::vector<Result> check_file(const fs::path& file) {
    AnchorIndex anchors;
    std::vector<Result> results;
    for (const auto& link : extract_links(read_file(file))) {
        results.push_back(check_link(link, file, anchors));
    }
    return results;
}

std::vector<fs::path> find_markdown_files(const std::vector<fs::path>& roots) {
    std::vector<fs::path> files;
    for (const auto& root : roots) {
        if (fs::is_regular_file(root)) {
            files.push_back(root);
            continue;
        }
        if (!fs::is_directory(root)) {
            throw std::runtime_error("no such file or directory: " + root.string());
        }
        for (const auto& entry : fs::recursive_directory_iterator{root}) {
            if (entry.is_regular_file() && entry.path().extension() == ".md") {
                files.push_back(entry.path());
            }
        }
    }
    std::ranges::sort(files);
    return files;
}

}  // namespace linkcheck

Things to notice:

  • AnchorIndex caches each file's headings in a std::map, so a file linked 50 times is parsed once.
  • switch over an enum class with no default: — if you add a new LinkKind, the compiler warns about the unhandled case.
  • Result is an aggregate — {link, Status::Broken, "file not found"} builds it directly.

The executable

src/main.cpp

// linkcheck: find broken links in Markdown files.
#include <exception>
#include <filesystem>
#include <format>
#include <iostream>
#include <string_view>
#include <vector>

#include "linkcheck/checker.hpp"

namespace fs = std::filesystem;

namespace {

struct Options {
    std::vector<fs::path> paths;
    bool quiet = false;   // only print broken links
};

Options parse_args(int argc, char* argv[]) {
    Options opts;
    for (int i = 1; i < argc; ++i) {
        std::string_view arg = argv[i];
        if (arg == "-q" || arg == "--quiet") {
            opts.quiet = true;
        } else if (arg.starts_with('-')) {
            throw std::invalid_argument(std::format("unknown option: {}", arg));
        } else {
            opts.paths.emplace_back(arg);
        }
    }
    if (opts.paths.empty()) {
        opts.paths.emplace_back(".");
    }
    return opts;
}

std::string_view label(linkcheck::Status s) {
    switch (s) {
        case linkcheck::Status::Ok: return "OK  ";
        case linkcheck::Status::Broken: return "FAIL";
        case linkcheck::Status::Skipped: return "SKIP";
    }
    return "?";
}

int run(const Options& opts) {
    auto files = linkcheck::find_markdown_files(opts.paths);
    int ok = 0, broken = 0, skipped = 0;

    for (const auto& file : files) {
        auto results = linkcheck::check_file(file);
        bool header_printed = false;
        for (const auto& r : results) {
            if (r.status == linkcheck::Status::Ok) ++ok;
            if (r.status == linkcheck::Status::Broken) ++broken;
            if (r.status == linkcheck::Status::Skipped) ++skipped;
            if (opts.quiet && r.status != linkcheck::Status::Broken) {
                continue;
            }
            if (!header_printed) {
                std::cout << '\n' << file.generic_string() << '\n';
                header_printed = true;
            }
            std::cout << std::format("  {}  {}:{}", label(r.status), r.link.line, r.link.target);
            if (!r.reason.empty()) {
                std::cout << std::format("  ({})", r.reason);
            }
            std::cout << '\n';
        }
    }

    std::cout << std::format("\nChecked {} links in {} files: {} OK, {} broken, {} skipped\n",
                             ok + broken + skipped, files.size(), ok, broken, skipped);
    return broken == 0 ? 0 : 1;
}

}  // namespace

int main(int argc, char* argv[]) {
    try {
        return run(parse_args(argc, argv));
    } catch (const std::invalid_argument& e) {
        std::cerr << "linkcheck: " << e.what() << "\nusage: linkcheck [-q] [PATH...]\n";
        return 2;
    } catch (const std::exception& e) {
        std::cerr << "linkcheck: " << e.what() << '\n';
        return 2;
    }
}

Tests

tests/test_links.cpp

#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>

#include "linkcheck/links.hpp"

using namespace linkcheck;

TEST_CASE("extract_links finds inline links with line numbers") {
    auto links = extract_links("intro\nsee [setup](./setup.md) and [site](https://x.dev)\n");
    REQUIRE(links.size() == 2);
    CHECK(links[0].target == "./setup.md");
    CHECK(links[0].line == 2);
    CHECK(links[1].target == "https://x.dev");
}

TEST_CASE("extract_links handles images, titles, references and autolinks") {
    auto links = extract_links(
        "![logo](img/logo.png \"Logo\")\n"
        "[ref]: ../other.md#top\n"
        "visit <https://example.com>\n");
    REQUIRE(links.size() == 3);
    CHECK(links[0].target == "img/logo.png");
    CHECK(links[1].target == "../other.md#top");
    CHECK(links[2].target == "https://example.com");
}

TEST_CASE("extract_links ignores code") {
    auto links = extract_links(
        "```\n[not](a-link.md)\n```\n"
        "inline `[also](not.md)` here\n");
    CHECK(links.empty());
}

TEST_CASE("classify") {
    auto [target, kind] = GENERATE(table<std::string, LinkKind>({
        {"https://example.com", LinkKind::Url},
        {"mailto:ada@example.com", LinkKind::Url},
        {"#install", LinkKind::Anchor},
        {"setup.md", LinkKind::LocalFile},
        {"../a/b.md#x", LinkKind::LocalFile},
    }));
    CAPTURE(target);
    CHECK(classify(target) == kind);
}

TEST_CASE("slugify follows GitHub's rules") {
    CHECK(slugify("Hello, World!") == "hello-world");
    CHECK(slugify("1. Setup & your first program") == "1-setup--your-first-program");
    CHECK(slugify("Using `std::move`") == "using-stdmove");
}

TEST_CASE("heading_slugs skips headings in code blocks") {
    auto slugs = heading_slugs("# Title\n```\n# comment\n```\n## Next step ##\n");
    REQUIRE(slugs == std::vector<std::string>{"title", "next-step"});
}

tests/test_checker.cpp

#include <catch2/catch_test_macros.hpp>

#include <filesystem>
#include <fstream>
#include <random>

#include "linkcheck/checker.hpp"

namespace fs = std::filesystem;
using namespace linkcheck;

namespace {

struct TempDir {
    fs::path path;
    TempDir() {
        std::random_device rd;
        path = fs::temp_directory_path() / ("linkcheck-test-" + std::to_string(rd()));
        fs::create_directories(path);
    }
    ~TempDir() { fs::remove_all(path); }
    TempDir(const TempDir&) = delete;
    TempDir& operator=(const TempDir&) = delete;

    fs::path write(const fs::path& name, const std::string& content) const {
        auto p = path / name;
        fs::create_directories(p.parent_path());
        std::ofstream{p} << content;
        return p;
    }
};

}  // namespace

TEST_CASE("check_file reports ok, broken and skipped links") {
    TempDir dir;
    dir.write("guide/setup.md", "# Install\n");
    auto index = dir.write("index.md",
                           "# Home\n"
                           "[ok](guide/setup.md)\n"
                           "[ok anchor](guide/setup.md#install)\n"
                           "[bad anchor](guide/setup.md#missing)\n"
                           "[missing](nope.md)\n"
                           "[self](#home)\n"
                           "[web](https://example.com)\n");

    auto results = check_file(index);
    REQUIRE(results.size() == 6);
    CHECK(results[0].status == Status::Ok);
    CHECK(results[1].status == Status::Ok);
    CHECK(results[2].status == Status::Broken);
    CHECK(results[2].reason == "no heading #missing");
    CHECK(results[3].status == Status::Broken);
    CHECK(results[3].reason == "file not found");
    CHECK(results[4].status == Status::Ok);
    CHECK(results[5].status == Status::Skipped);
}

TEST_CASE("find_markdown_files walks directories and sorts") {
    TempDir dir;
    dir.write("b.md", "");
    dir.write("a/c.md", "");
    dir.write("notes.txt", "");

    auto files = find_markdown_files({dir.path});
    REQUIRE(files.size() == 2);
    CHECK(files[0].filename() == "c.md");
    CHECK(files[1].filename() == "b.md");
}

TEST_CASE("find_markdown_files rejects missing paths") {
    CHECK_THROWS(find_markdown_files({"/definitely/not/here"}));
}

Build, test, and run

cmake -S . -B build -DLINKCHECK_WERROR=ON
cmake --build build -j
ctest --test-dir build --output-on-failure
./build/src/linkcheck path/to/docs

And the sanitizer build — this must also pass:

cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
cmake --build build-asan -j
ctest --test-dir build-asan --output-on-failure

Try it on this course: clone the repo and run linkcheck docs/.

Build it in stages

  1. CMake skeleton: library + executable + one trivial Catch2 test. Get ctest green.
  2. extract_links for inline links only, test-first. Then images, reference definitions, autolinks, and code-block skipping — a failing test before each.
  3. classify and slugify, driven by GENERATE tables.
  4. check_file with a TempDir fixture; start with file existence, then anchors.
  5. main.cpp: argument parsing, report formatting, exit codes.
  6. Hardening: run under ASan/UBSan; point it at a directory that doesn't exist, a file with no read permission, an empty file, a binary file renamed to .md.

Stretch goals

  • Check URLs using libcurl or cpr (via FetchContent) behind a --external flag, with a timeout and a result cache per URL.
  • Check files in parallel with std::async — each file is independent. Measure the speedup on a large docs tree. (Preview of the advanced track.)
  • --format json output for CI tools.
  • Handle duplicate headings the way GitHub does (#setup, #setup-1, ...).
  • A CMakePresets.json with debug, asan, and release presets.
  • A GitHub Actions workflow that builds and tests on Linux, macOS, and Windows.

Self-check

  • [ ] Builds warning-free with -DLINKCHECK_WERROR=ON on GCC and Clang.
  • [ ] ctest passes, in a normal build and under AddressSanitizer + UBSan.
  • [ ] Tests never touch files outside a temporary directory.
  • [ ] Exit codes are correct for CI use.
  • [ ] You can explain why links.cpp has no filesystem code, and why main.cpp has no parsing code.

When done, move to Advanced → Move semantics & perfect forwarding.