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¶
- 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!→#hello-world). - URLs: report as skipped (the standard library has no HTTP client — see stretch goals).
-q/--quietprints only broken links.- Exit code:
0all good,1broken links found,2usage 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;staticbuilds each one once.for_each_prose_lineis 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::regexis 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:
AnchorIndexcaches each file's headings in astd::map, so a file linked 50 times is parsed once.switchover anenum classwith nodefault:— if you add a newLinkKind, the compiler warns about the unhandled case.Resultis 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(
"\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¶
- CMake skeleton: library + executable + one trivial Catch2 test. Get
ctestgreen. extract_linksfor inline links only, test-first. Then images, reference definitions, autolinks, and code-block skipping — a failing test before each.classifyandslugify, driven byGENERATEtables.check_filewith aTempDirfixture; start with file existence, then anchors.main.cpp: argument parsing, report formatting, exit codes.- 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--externalflag, 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 jsonoutput for CI tools.- Handle duplicate headings the way GitHub does (
#setup,#setup-1, ...). - A
CMakePresets.jsonwithdebug,asan, andreleasepresets. - A GitHub Actions workflow that builds and tests on Linux, macOS, and Windows.
Self-check¶
- [ ] Builds warning-free with
-DLINKCHECK_WERROR=ONon GCC and Clang. - [ ]
ctestpasses, 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.cpphas no filesystem code, and whymain.cpphas no parsing code.
When done, move to Advanced → Move semantics & perfect forwarding.