Mr.Docs as a Library

A C++ program can also link the mrdocs-core library, drive corpus extraction itself, and pass the resulting Corpus to its own analysis. This is the alternative to use when the scripting extensions are not powerful enough, such as processing multiple codebases at once, embedding Mr.Docs’s extractor in another C++ host (IDE plugin, static analyzer), or writing a custom Generators with non-trivial C++ logic that depend on special system libraries.

Build integration

mrdocs-core is exported through a CMake package config. Once Mr.Docs is installed, a consumer locates it with a single find_package call. The breaking-changes example does this only when it’s built outside the Mr.Docs source tree, where the target isn’t already defined:

examples/library/breaking-changes/CMakeLists.txt
find_package(mrdocs REQUIRED CONFIG)

The imported target is mrdocs::mrdocs-core. Link it like any other library: it carries the include directories, compile definitions, and transitive dependencies the consumer needs, so no extra setup is required.

examples/library/breaking-changes/CMakeLists.txt
add_executable(mrdocs-breaking-changes-example
    src/main.cpp
    src/Corpus.cpp
    src/Diff.cpp
    src/BreakingChangesGenerator.cpp
)
target_link_libraries(mrdocs-breaking-changes-example PRIVATE mrdocs::mrdocs-core)
mrdocs_ensure_runtime_dlls(mrdocs-breaking-changes-example)

When Mr.Docs is a shared library, CMake gives a Linux or macOS program the run path to find it. Windows has no run path: it finds a DLL next to the program, so mrdocs_ensure_runtime_dlls(<target>) puts mrdocs-core.dll there after the build, unless the same file is already there or on PATH. The call does nothing for a static Mr.Docs or on Linux and macOS, so call it after every link line.

Building a corpus

The Library Reference for mrdocs-core covers the public API for extracting and processing a corpus. A consumer typically drives extraction with:

  1. Read an mrdocs.yml into a fully validated Config.

  2. Call Corpus::build with the resulting config and the reporter of the run, session.log() when the program has a Session.

The compilation database is resolved from the configuration with the same rules Mr.Docs uses. LLVM and Clang are never exposed to the caller.

The breaking-changes example packages those steps in one helper:

examples/library/breaking-changes/src/Corpus.cpp
Expected<Corpus>
loadCorpusFromConfig(
    std::string const& configPath,
    ReferenceDirectories const& dirs,
    char const** argv,
    report::Reporter& reporter)
{
    Config config;
    Expected<std::string> absolute = files::makeAbsolute(configPath);
    if (!absolute)
    {
        return Unexpected(absolute.error());
    }
    std::string const& absolutePath = *absolute;
    ReferenceDirectories localDirs = dirs;
    localDirs.cwd = std::string(files::getParentDir(absolutePath));
    // Command-line overrides (if any) are applied on top of the config file and
    // before normalization. Running the example against an installed MrDocs
    // needs none; the in-tree test forwards the built-in directory flags here.
    Expected<void> loaded =
        Config::loadFile(config, absolutePath, localDirs, argv, reporter);
    if (!loaded)
    {
        return Unexpected(loaded.error());
    }
    // loadFile sets the reporter to the config's log level; this example
    // only wants the breaking-change report, so keep extraction diagnostics
    // quiet.
    reporter.setMinimumLevel(report::Level::error);
    return Corpus::build(config, reporter);
}

Reading a corpus

A Corpus is iterable and looks up symbols by name. The breaking-changes example walks the baseline corpus once, asks the candidate corpus for the matching symbol with Corpus::lookup, and classifies each pair:

  • Symbols in v1 that are missing from v2 are removed;

  • the reverse are added;

  • same-name same-kind pairs go to a kind-specific comparator.

examples/library/breaking-changes/src/Diff.cpp
DiffResult
diff(Corpus const& v1, Corpus const& v2)
{
    DiffResult result;

    // For every public symbol in v1, see what happened in v2.
    for (Symbol const& a : v1)
    {
        if (!isPublicTopLevel(a))
        {
            continue;
        }
        std::string name = v1.qualifiedName(a);
        auto found = v2.lookup(name);
        if (!found)
        {
            result.removed.emplace_back(std::move(name), &a);
            continue;
        }
        Symbol const& b = *found;

        if (a.Kind != b.Kind)
        {
            // A function turned into a record, or similar.
            // Treat it as removed + added rather than changed.
            result.added.emplace_back(name, &b);
            result.removed.emplace_back(std::move(name), &a);
            continue;
        }

        if (a.isFunction())
        {
            auto reasons = compareFunctions(a.asFunction(), b.asFunction());
            if (!reasons.empty())
            {
                result.changed.push_back(
                    {std::move(name), &a, &b, std::move(reasons)});
            }
        }
    }

    // Anything public in v2 that wasn't in v1 is a new addition.
    for (Symbol const& b : v2)
    {
        if (!isPublicTopLevel(b))
        {
            continue;
        }
        std::string name = v2.qualifiedName(b);
        if (!v1.lookup(name))
        {
            result.added.emplace_back(std::move(name), &b);
        }
    }

    if (!result.removed.empty() || !result.changed.empty())
    {
        result.impact = SemverImpact::Major;
    }
    else if (!result.added.empty())
    {
        result.impact = SemverImpact::Minor;
    }
    return result;
}

Custom Generators

You can subclass Generator to create new output formats that need to do something script generators cannot express, such as comparing multiple corpora, multi-pass analyzes, emitting binary formats, depending on system libraries. A generator does its work in Generator::build, which receives the corpus, the configuration and the reporter of the run. It reads its options block with getGeneratorParams.

The body of Generator::build is free to do whatever it needs. It can resolve its own output location and write files or, as this example does, bring the result straight to the screen.

The example’s BreakingChangesGenerator holds a reference to the baseline corpus and runs the diff against whatever candidate corpus Generator::build is invoked with:

examples/library/breaking-changes/src/BreakingChangesGenerator.hpp
// A Generator subclass that emits a breaking-change report. The baseline
// corpus is captured at construction; the candidate corpus is supplied to
// `build`, which diffs the two and prints the report. Adding this
// generator to the generators of a Session under the id "breaking-changes"
// lets a caller look it up like any built-in generator.
class BreakingChangesGenerator final : public Generator
{
public:
    explicit BreakingChangesGenerator(Corpus const& baseline)
        : baseline_(&baseline)
    {}

    std::string_view id()           const noexcept override;
    std::string_view displayName()  const noexcept override;
    std::string_view fileExtension()const noexcept override;

    Expected<void>
    build(
        Corpus const& current,
        Config const& config,
        report::Reporter& reporter) const override;

private:
    Corpus const* baseline_;
};

A program keeps its generators in a Session, which owns everything one run of Mr.Docs needs and which you make with Session::create. Its log() is the report::Reporter of the run, returned by reference. The example builds both corpora with it, so the reporter prints and counts what they report in one place. A second session would count its own.

The generators() of the session is a GeneratorRegistry. It starts with the generators that ship with Mr.Docs (adoc, html, xml, json and noop) and owns every generator added later. Adding one whose id is already taken fails. Two sessions in one process never see each other’s generators. The example adds its generator and looks it up by id:

examples/library/breaking-changes/src/main.cpp
// The session owns the reporter of the run, so it comes first: both
// corpora report through session.log(), which prints errors only
Expected<Session> created = Session::create(Config());
if (!created)
{
    std::println(stderr, "Session::create: {}", created.error().reason());
    return 1;
}
Session& session = *created;
session.log().setMinimumLevel(report::Level::error);
auto v1Corpus = example::loadCorpusFromConfig(
    argv[1], dirs, overrides, session.log());
if (!v1Corpus)
{
    std::println(stderr, "v1: {}", v1Corpus.error().reason());
    return 1;
}
auto v2Corpus = example::loadCorpusFromConfig(
    argv[2], dirs, overrides, session.log());
if (!v2Corpus)
{
    std::println(stderr, "v2: {}", v2Corpus.error().reason());
    return 1;
}

auto installed = session.generators().install(
    std::make_unique<example::BreakingChangesGenerator>(*v1Corpus));
if (!installed)
{
    std::println(stderr,
        "install: {}", installed.error().reason());
    return 1;
}

Generator const* gen =
    session.generators().find("breaking-changes");
if (!gen)
{
    std::println(stderr,
        "find: breaking-changes not installed");
    return 1;
}

auto wrote = session.build(*gen, *v2Corpus);
if (!wrote)
{
    std::println(stderr,
        "Generator::build: {}", wrote.error().reason());
    return 1;
}

Order matters when a session transforms a corpus. A script may keep a value that refers to the corpus, so a program that calls Session::applyTransforms declares the corpus before the session, and the session is destroyed first. This example declares the session first, which is safe because it never transforms its corpora.

A program that goes through several configurations passes one ExtensionRegistry::LibraryRegistry to each Session::create, so the entry point of a plugin runs once.

The body of Generator::build is short, the heavy lifting is the diff and the formatter:

examples/library/breaking-changes/src/BreakingChangesGenerator.cpp
// build() does whatever the generator needs. This one is not a
// file-producing documentation format: it diffs the baseline against the
// candidate corpus and prints the report to stdout. A generator is free
// to bring its output to the screen instead of writing files.
Expected<void>
BreakingChangesGenerator::
build(
    Corpus const& current,
    Config const& config,
    report::Reporter&) const
{
    writeReport(std::cout, diff(*baseline_, current));
    return {};
}

The example ships two test fixtures, each its own directory with an mrdocs.yml and a single lib.cpp that stands in for a small library:

  • v1

  • v2

examples/library/breaking-changes/test/v1/lib.cpp
/// A point on the plane.
struct Point
{
    /// X coordinate.
    int x;

    /// Y coordinate.
    int y;

    /** Distance from the origin.

        @return The Euclidean length of the vector.
    */
    double length() const;
};

/// Compute the area of a circle.
double area_of_circle(double radius);

/// Helper that should be removed in v2.
int legacy_helper();
examples/library/breaking-changes/test/v2/lib.cpp
/// A point on the plane.
struct Point
{
    /// X coordinate.
    int x;

    /// Y coordinate.
    int y;

    /** Distance from the origin.

        @return The Euclidean length of the vector.
    */
    double length() const noexcept;
};

/// Compute the area of a circle.
double area_of_circle(double radius);

/// New helper added in v2.
double area_of_square(double side);

Running the tool against those two fixtures produces the report through the generator’s Generator::build:

examples/library/breaking-changes/test/expected.txt
== Breaking-change report ==

Added (1):
  + [function] area_of_square

Removed (1):
  - [function] legacy_helper

Changed (1):
  * [function] Point::length
      - noexcept differs

Suggested version bump: major