Corpus Transforms

A corpus transform is a pipeline extension that rewrites metadata across many symbols at once to incorporate project conventions into the corpus. You can use it to do things like:

  • Backfill documentation fields from naming conventions,

  • Create and identify relations between fields,

  • Tag symbols by group, and

  • Mark generated code as "implementation defined" according to project conventions.

Transforms run once, after the corpus is built and finalized and before any generator runs, so every output format sees the change. After a transform runs, the corpus forgets the lookups by name it remembered, so the generators see a symbol that a transform renames under its new name.

As specified in Defining Extensions, you can write an extension in Lua, JavaScript, or C++. A transform must be independent and not rely on the order in which extensions are executed.

Every transform works on the same corpus. The transforms run one after another, and each sees what the earlier ones wrote. Each receives its own transform-options.<id> block. The transforms of scripts and of plugins run in one pipeline. There is nothing to select: once a plugin is installed, its transform runs in every build.

Registering a transform

A transform defined is a function registered under an id. A script registers it with mrdocs.register_transform(id, fn) and a plugin registers it with registerTransform, as Defining Extensions describes:

  • JavaScript

  • Lua

  • C++

addons/extensions/hello_world_transform.js
mrdocs.register_transform("hello-world-transform", function(ctx) {
    ctx.report.warn("Hello from a transform");
});
addons/extensions/hello_world_transform.lua
mrdocs.register_transform("hello-world-transform", function(ctx)
    ctx.report.warn("Hello from a transform")
end)
hello-world-transform/plugin.cpp
#include <mrdocs/Extensions/TransformExtensionContext.hpp>
#include <mrdocs/Plugin.hpp>
#include <mrdocs/Support/Reporter.hpp>
#include <mrdocs/Transform.hpp>

MRDOCS_PLUGIN_MAIN(plugin)
{
    return plugin.registerTransform("hello-world-transform",
        [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
        {
            ctx.report->warn("Hello from a transform");
            return {};
        });
}

A transform that keeps state across calls is a subclass of Transform instead, with Transform::id and Transform::apply. A plugin passes an object of the class to the other overload of registerTransform, the way Programmatic Generators describes the Generator subclass.

The id keys the transform-options.<id> block of the configuration file. The block arrives as ctx.params in a script. In C++ a callable reads it as the params field of its TransformExtensionContext, and a subclass of Transform calls getTransformParams from Transform::apply.

The function body is where the work goes, and the sections below fill it in. The C++ tabs of the later sections show the callable a plugin passes to registerTransform and the helpers it uses.

Options

The block is an empty object when the configuration has none. With this configuration, the transform with the id brief-filler gets { "text": "TODO" }, and the one registered as brief-checker gets { "suffix": "checked" }:

transform-options:
  brief-filler:
    text: TODO
  brief-checker:
    suffix: checked

The context

Every transform registered as a function receives a TransformExtensionContext, which holds the corpus, the configuration, the transform’s own options block and the reporter. A plugin receives the object itself. A script receives a proxy of it as ctx, whose keys are the fields of the object (ctx.corpus, ctx.config, ctx.params, ctx.report), as Defining Extensions says.

The rest of this page uses the corpus: in a script a flat view that the sections below walk and change, and in C++ the Corpus itself, a range of Symbol objects.

Errors

An error from a transform stops the run before any generator sees the corpus. The diagnostic names the transform by its id. Here a script transform registered as brief-filler reads transform-options.brief-filler.text and raises because the block has no text:

the transform "brief-filler" failed: [script]:2: transform-options.brief-filler.text is missing

Accessing the corpus

Iterating symbols

The ctx.corpus object provides functions that expose the symbol graph. The ctx.corpus.symbols field is a flat array containing every extracted symbol. Scripts that need queries like "all members of `X`" simply walk the array and filter.

For instance, the following scripts count the symbols of each kind and report the totals at the end of the run:

  • JavaScript

  • Lua

  • C++

addons/extensions/count_by_kind.js
mrdocs.register_transform("count-by-kind", function(ctx) {
    var counts = {};
    for (var i = 0; i < ctx.corpus.symbols.length; ++i) {
        var k = ctx.corpus.symbols[i].kind;
        counts[k] = (counts[k] || 0) + 1;
    }
    for (var k in counts) {
        console.log(k + ": " + counts[k]);
    }
});
addons/extensions/count_by_kind.lua
mrdocs.register_transform("count-by-kind", function(ctx)
    local counts = {}
    for _, sym in ipairs(ctx.corpus.symbols) do
        counts[sym.kind] = (counts[sym.kind] or 0) + 1
    end
    for k, v in pairs(counts) do
        print(k .. ": " .. v)
    end
end)
count-by-kind/plugin/plugin.cpp
return plugin.registerTransform("count-by-kind",
    [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
    {
        std::map<std::string_view, int> counts;
        for (mrdocs::Symbol const& symbol : *ctx.corpus)
        {
            ++counts[mrdocs::toString(symbol.Kind)];
        }
        for (auto const& [kind, count] : counts)
        {
            ctx.report->info("{}: {}", kind, count);
        }
        return {};
    });

Run on the shapes of the next section, the C++ version reports:

namespace: 1
record: 6

Each entry in ctx.corpus.symbols is a proxy for a live Mr.Docs symbol, and the fields of each object are at the DOM reference. In C++, iterating the corpus yields the Symbol objects themselves, and symbol.Kind and symbol.Name are plain members. The array itself (internally represented by a set) is fixed in size: read it, iterate it, filter it, but copy the entries you keep into your own array or table before trimming or reordering them.

Looking up a symbol

When a script knows a symbol’s id and needs to act on that one symbol:

  • ctx.corpus.get(id) returns the proxy for it or null if the id is unknown

  • ctx.corpus.lookup(name) does a global-namespace name lookup and returns the proxy (or null)

subclass-tree.cpp
/// The root of the shape hierarchy.
struct Shape {};

/// A shape with straight sides.
struct Polygon : Shape {};

/// A three-sided polygon.
struct Triangle : Polygon {};

/// A four-sided polygon.
struct Quadrilateral : Polygon {};

/// A quadrilateral with equal sides.
struct Square : Quadrilateral {};

/// A round shape.
struct Circle : Shape {};
  • JavaScript

  • Lua

  • C++

addons/extensions/subclass_tree.js
mrdocs.register_transform("subclass-tree", function(ctx) {
    const base = ctx.corpus.lookup("Shape");
    if (base) {
        console.log(base.name);
        listSubclasses(ctx.corpus, base, "  ");
    }
});

/**
 * Print the inheritance subtree below a record, one class per line, indented by
 * depth. Each record's `derived` list holds the ids of its direct subclasses;
 * `corpus.get` turns each id back into a symbol, and the recursion follows them
 * down the graph.
 * @param {object} corpus - `ctx.corpus`.
 * @param {object} sym - The record whose subclasses to print.
 * @param {string} indent - The leading whitespace for this level.
 */
function listSubclasses(corpus, sym, indent) {
    for (let i = 0; i < sym.derived.length; ++i) {
        const child = corpus.get(sym.derived[i]);
        if (child) {
            console.log(indent + child.name);
            listSubclasses(corpus, child, indent + "  ");
        }
    }
}
addons/extensions/subclass_tree.lua
local listSubclasses

mrdocs.register_transform("subclass-tree", function(ctx)
    local base = ctx.corpus.lookup("Shape")
    if base then
        print(base.name)
        listSubclasses(ctx.corpus, base, "  ")
    end
end)

--- Print the inheritance subtree below a record, one class per line, indented
--- by depth. Each record's `derived` list holds the ids of its direct
--- subclasses; `corpus.get` turns each id back into a symbol, and the recursion
--- follows them down the graph.
--- @param corpus table `ctx.corpus`.
--- @param sym table The record whose subclasses to print.
--- @param indent string The leading whitespace for this level.
function listSubclasses(corpus, sym, indent)
    for _, id in ipairs(sym.derived) do
        local child = corpus.get(id)
        if child then
            print(indent .. child.name)
            listSubclasses(corpus, child, indent .. "  ")
        end
    end
end
subclass-tree/plugin/plugin.cpp
return plugin.registerTransform("subclass-tree",
    [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
    {
        mrdocs::Corpus const& corpus = *ctx.corpus;
        auto base = corpus.lookup("Shape");
        if (base)
        {
            ctx.report->info("{}", base->Name);
            if (auto const* record = base->asRecordPtr())
            {
                listSubclasses(*ctx.report, corpus, *record, "  ");
            }
        }
        return {};
    });
subclass-tree/plugin/plugin.cpp
void
listSubclasses(mrdocs::report::Reporter& report, mrdocs::Corpus const& corpus,
    mrdocs::RecordSymbol const& record, std::string const& indent)
{
    for (mrdocs::SymbolID const& id : record.Derived)
    {
        auto const* child = corpus.find(id);
        if (!child)
        {
            continue;
        }
        report.info("{}{}", indent, child->Name);
        if (auto const* derived = child->asRecordPtr())
        {
            listSubclasses(report, corpus, *derived, indent + "  ");
        }
    }
}

Running any of them against the fixture above prints:

Shape
  Circle
  Polygon
    Quadrilateral
      Square
    Triangle

Emitting warnings

A read-only transform can do more than compute totals: it can report the problems it finds through ctx.report, the reporter of the run.

A script calls ctx.report.warn(…​), ctx.report.error(…​) or ctx.report.info(…​) with the same free-form arguments as console.log. A plugin calls ctx.report→warn(…​) and the others with a format string and its arguments, as std::format takes them. Either way, Mr.Docs counts each message in the totals of the run like one of its own, and under warn-as-error a warning counts as an error.

A natural use is checking documentation against your project’s conventions. The example below checks for a "low-quality brief" that reads like a stray comment or is too short. A complete, runnable example lives at examples/extensions/project-conventions/.

project-conventions.cpp
/// Reset the parser to its initial state.
void reset();

/// x
void flush();

/// >>>>>>>>>>>>
void sync();
  • JavaScript

  • Lua

  • C++

addons/extensions/project_conventions.js
// Check each symbol's documentation against the project's conventions. Every
// convention is a small predicate; this transform warns about each symbol that
// breaks one, routing through ctx.report so the messages count as real
// diagnostics. The one convention checked here flags a "low-quality brief": a
// brief that reads like a stray comment rather than a description.
mrdocs.register_transform("project-conventions", function(ctx) {
    for (let i = 0; i < ctx.corpus.symbols.length; ++i) {
        const sym = ctx.corpus.symbols[i];
        const brief = symbolBrief(sym);
        if (isLowQualityBrief(brief)) {
            ctx.report.warn(sym.name + ': low-quality brief: "' + brief + '"');
        }
    }
});

/**
 * The text of a documentation node, gathered by walking its children.
 * @param {object} node - A documentation node with `literal` and `children`.
 * @returns {string} The concatenated text of the node and its descendants.
 */
function nodeText(node) {
    let text = node.literal || "";
    if (node.children) {
        for (let i = 0; i < node.children.length; ++i) {
            text += nodeText(node.children[i]);
        }
    }
    return text;
}

/**
 * A symbol's brief as plain text, trimmed, or "" when it has none.
 * @param {object} sym - The symbol to read.
 * @returns {string} The brief text, or an empty string.
 */
function symbolBrief(sym) {
    if (!sym.doc || !sym.doc.brief) {
        return "";
    }
    return nodeText(sym.doc.brief).replace(/^\s+|\s+$/g, "");
}

/**
 * Whether a brief is low-quality: it reads like a stray comment rather than a
 * description because it is too short or more punctuation than words.
 * @param {string} brief - The brief text, already trimmed.
 * @returns {boolean} True when the brief breaks the convention.
 */
function isLowQualityBrief(brief) {
    if (brief.length === 0) {
        return false;
    }
    let special = 0, total = 0;
    for (let i = 0; i < brief.length; ++i) {
        if (brief[i] === " ") {
            continue;
        }
        total += 1;
        if (!/[A-Za-z0-9]/.test(brief[i])) {
            special += 1;
        }
    }
    return brief.length < 4 || special > total / 2;
}
addons/extensions/project_conventions.lua
local node_text, symbol_brief, is_low_quality_brief

-- Check each symbol's documentation against the project's conventions. Every
-- convention is a small predicate; this transform warns about each symbol that
-- breaks one, routing through ctx.report so the messages count as real
-- diagnostics. The one convention checked here flags a "low-quality brief": a
-- brief that reads like a stray comment rather than a description.
mrdocs.register_transform("project-conventions", function(ctx)
    for _, sym in ipairs(ctx.corpus.symbols) do
        local brief = symbol_brief(sym)
        if is_low_quality_brief(brief) then
            ctx.report.warn(sym.name .. ': low-quality brief: "' .. brief .. '"')
        end
    end
end)

--- The text of a documentation node, gathered by walking its children.
--- @param node table A documentation node with `literal` and `children`.
--- @return string The concatenated text of the node and its descendants.
function node_text(node)
    local text = node.literal or ""
    if node.children then
        for _, child in ipairs(node.children) do
            text = text .. node_text(child)
        end
    end
    return text
end

--- A symbol's brief as plain text, trimmed, or "" when it has none.
--- @param sym table The symbol to read.
--- @return string The brief text, or an empty string.
function symbol_brief(sym)
    if not (sym.doc and sym.doc.brief) then
        return ""
    end
    return node_text(sym.doc.brief):match("^%s*(.-)%s*$")
end

--- Whether a brief is low-quality: it reads like a stray comment rather than a
--- description because it is too short or more punctuation than words.
--- @param brief string The brief text, already trimmed.
--- @return boolean True when the brief breaks the convention.
function is_low_quality_brief(brief)
    if #brief == 0 then
        return false
    end
    local special, total = 0, 0
    for c in brief:gmatch(".") do
        if c ~= " " then
            total = total + 1
            if not c:match("%w") then special = special + 1 end
        end
    end
    return #brief < 4 or special > total / 2
end
project-conventions/plugin/plugin.cpp
return plugin.registerTransform("project-conventions",
    [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
    {
        for (mrdocs::Symbol const& symbol : *ctx.corpus)
        {
            warnIfLowQuality(*ctx.report, symbol);
        }
        return {};
    });
project-conventions/plugin/plugin.cpp
void
warnIfLowQuality(mrdocs::report::Reporter& report, mrdocs::Symbol const& symbol)
{
    if (!symbol.doc || !symbol.doc->brief)
    {
        return;
    }
    std::string brief = mrdocs::doc::getAsPlainText(*symbol.doc->brief);
    brief.erase(0, brief.find_first_not_of(" \t\n\r\f\v"));
    brief.erase(brief.find_last_not_of(" \t\n\r\f\v") + 1);
    if (isLowQuality(brief))
    {
        report.warn(
            "{}: low-quality brief: \"{}\"", symbol.Name, brief);
    }
}
project-conventions/plugin/plugin.cpp
bool
isLowQuality(std::string const& brief)
{
    std::size_t special = 0;
    std::size_t total = 0;
    for (char c : brief)
    {
        if (c == ' ')
        {
            continue;
        }
        ++total;
        if (!std::isalnum(static_cast<unsigned char>(c)))
        {
            ++special;
        }
    }
    return !brief.empty() && (brief.size() < 4 || special > total / 2);
}

Running any of them against the fixture above warns:

flush: low-quality brief: "x"
sync: low-quality brief: ">>>>>>>>>>>>"

Modifying the corpus

Assigning to a field of a symbol writes to the corpus, which a transform gets mutable and a generator gets as const. A C++ transform writes through the mutable reference it asks the corpus for by id.

Most extensions read some attribute of a symbol and write back a string, an enumerator, or a small structured value. For instance, a codebase where a function named is_<name> is a predicate returning true if its <name> holds can repeat that in the brief:

brief-from-name.cpp
bool is_prime(int n);
bool is_palindrome(char const* s);
bool is_empty(char const* s);
  • JavaScript

  • Lua

  • C++

addons/extensions/brief_from_name.js
mrdocs.register_transform("brief-from-name", function(ctx) {
    for (let i = 0; i < ctx.corpus.symbols.length; ++i) {
        fillPredicateDoc(ctx.corpus.symbols[i]);
    }
});

/**
 * Fill in the brief and parameter doc of an `is_*` predicate from its name.
 * The subject is the name after `is_` with underscores turned to spaces, so
 * `is_prime` becomes "Returns true if prime." Does nothing for a symbol that is
 * not an `is_*` function, and never overwrites a field an author already wrote.
 * @param {object} sym - The symbol to document, mutated in place.
 */
function fillPredicateDoc(sym) {
    if (sym.kind !== "function" || sym.name.indexOf("is_") !== 0) {
        return;
    }
    if (!sym.doc) { sym.doc = {}; }

    const subject = sym.name.slice(3).replace(/_/g, " ");

    if (!sym.doc.brief) {
        sym.doc.brief = "Returns true if " + subject + ".";
    }

    if (sym.params.length === 1
        && (!sym.doc.params || sym.doc.params.length === 0)) {
        sym.doc.params = [{
            name: sym.params[0].name,
            children: "The input examined for the " + subject + " property."
        }];
    }
}
addons/extensions/brief_from_name.lua
local fillPredicateDoc

mrdocs.register_transform("brief-from-name", function(ctx)
    for _, sym in ipairs(ctx.corpus.symbols) do
        fillPredicateDoc(sym)
    end
end)

--- Fill in the brief and parameter doc of an `is_*` predicate from its name.
--- The subject is the name after `is_` with underscores turned to spaces, so
--- `is_prime` becomes "Returns true if prime." Does nothing for a symbol that
--- is not an `is_*` function, and never overwrites a field an author already
--- wrote.
--- @param sym table The symbol to document, mutated in place.
function fillPredicateDoc(sym)
    if sym.kind ~= "function" or sym.name:sub(1, 3) ~= "is_" then
        return
    end
    if not sym.doc then sym.doc = {} end

    local subject = sym.name:sub(4):gsub("_", " ")

    if not sym.doc.brief then
        sym.doc.brief = "Returns true if " .. subject .. "."
    end

    if #sym.params == 1
       and (not sym.doc.params or #sym.doc.params == 0) then
        sym.doc.params = {
            {
                name = sym.params[1].name,
                children = "The input examined for the "
                    .. subject .. " property."
            }
        }
    end
end
brief-from-name/plugin/plugin.cpp
return plugin.registerTransform("brief-from-name",
    [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
    {
        for (mrdocs::Symbol const& symbol : *ctx.corpus)
        {
            describePredicate(*ctx.corpus, symbol);
        }
        return {};
    });
brief-from-name/plugin/plugin.cpp
void
describePredicate(mrdocs::Corpus& corpus, mrdocs::Symbol const& symbol)
{
    auto const* function = symbol.asFunctionPtr();
    if (!function || !symbol.Name.starts_with("is_"))
    {
        return;
    }
    std::string subject = symbol.Name.substr(3);
    std::ranges::replace(subject, '_', ' ');
    mrdocs::Symbol* const target = corpus.find(symbol.id);
    fillBrief(*target, subject);
    if (function->Params.size() == 1 && target->doc->params.empty())
    {
        fillParam(*target, *function, subject);
    }
}
brief-from-name/plugin/plugin.cpp
void
fillBrief(mrdocs::Symbol& symbol, std::string const& subject)
{
    if (!symbol.doc)
    {
        symbol.doc.emplace();
    }
    if (!symbol.doc->brief)
    {
        symbol.doc->brief = mrdocs::doc::BriefBlock(
            "Returns true if " + subject + ".");
    }
}
brief-from-name/plugin/plugin.cpp
void
fillParam(mrdocs::Symbol& symbol, mrdocs::FunctionSymbol const& function,
    std::string const& subject)
{
    auto const& name = function.Params[0].Name;
    symbol.doc->params.emplace_back(name ? *name : std::string(),
        "The input examined for the " + subject + " property.");
}

Every is_foo_bar function then ships with "Returns true if foo bar." Authors only have to write a brief when the synthesized one is not the right one.

Preview
is_empty

Returns true if empty.

Synopsis

Declared in <brief‐from‐name.cpp>

bool
is_empty(char const* s);
Parameters

Name

Description

s

The input examined for the empty property.

is_palindrome

Returns true if palindrome.

Synopsis

Declared in <brief‐from‐name.cpp>

bool
is_palindrome(char const* s);
Parameters

Name

Description

s

The input examined for the palindrome property.

is_prime

Returns true if prime.

Synopsis

Declared in <brief‐from‐name.cpp>

bool
is_prime(int n);
Parameters

Name

Description

n

The input examined for the prime property.

The runtime validates each assignment and raises an exception on an invalid value. An uncaught error in an extension aborts the build and includes the script’s path and the error message.

Corpus shape invariants

What a transform changes has to leave the corpus in the shape finalization left it in:

  • A symbol keeps its id and its kind.

  • Every symbol id written into the corpus names a symbol the corpus has, so a transform cannot create or destroy a symbol.

  • A transform does not redo what finalization derived: sorted member lists, overload sets, copied briefs and resolved references.

Within those limits a transform changes what it likes: doc comments, attributes, and which members a list holds and in what order.

Cross-linking Symbols

When the value being written needs to reference another symbol, the second symbol’s id is what makes the link clickable in the rendered output rather than a plain string.

For instance, consider a project where the parse_X and format_X free functions are symmetric. A reader landing on one almost always wants to see the other. The extension walks the functions once and looks up the partner of each by name:

parse-format-relates.cpp
/// An HTTP request as a structured value.
struct request;

/// Parse `text` into a request. Returns a valid request on success.
request parse_request(char const* text);

/// Format `r` as the wire-format text of an HTTP request.
char const* format_request(request const& r);

/// A user record.
struct user;

/// Parse `text` into a user.
user parse_user(char const* text);

/// Format `u` as the canonical wire-format text of a user.
char const* format_user(user const& u);
  • JavaScript

  • Lua

  • C++

addons/extensions/parse_format_relates.js
mrdocs.register_transform("parse-format-relates", function(ctx) {
    for (let i = 0; i < ctx.corpus.symbols.length; ++i) {
        const s = ctx.corpus.symbols[i];
        if (s.kind === "function") {
            const pname = partnerName(s.name);
            const partner = pname ? ctx.corpus.lookup(pname) : null;
            if (partner) {
                s.doc = {
                    sees: [{
                        children: [{
                            kind: "reference",
                            literal: pname,
                            id: partner.id
                        }]
                    }]
                };
            }
        }
    }
});

/**
 * The name of a function's symmetric IO partner: `format_X` for a `parse_X`,
 * and `parse_X` for a `format_X`. Returns null for any name that is neither.
 * @param {string} name - The function's name.
 * @returns {string|null} The partner's name, or null if `name` is not a
 *   `parse_`/`format_` helper.
 */
function partnerName(name) {
    let partner = null;
    if (name.indexOf("parse_") === 0) {
        partner = "format_" + name.slice(6);
    } else if (name.indexOf("format_") === 0) {
        partner = "parse_" + name.slice(7);
    }
    return partner;
}
addons/extensions/parse_format_relates.lua
local partnerName

mrdocs.register_transform("parse-format-relates", function(ctx)
    for _, s in ipairs(ctx.corpus.symbols) do
        if s.kind == "function" then
            local pname = partnerName(s.name)
            if pname then
                local partner = ctx.corpus.lookup(pname)
                if partner then
                    s.doc = {
                        sees = {
                            {
                                children = {
                                    { kind = "reference",
                                      literal = pname,
                                      id = partner.id }
                                }
                            }
                        }
                    }
                end
            end
        end
    end
end)

--- The name of a function's symmetric IO partner: `format_X` for a `parse_X`,
--- and `parse_X` for a `format_X`. Returns nil for any name that is neither.
--- @param name string The function's name.
--- @return string|nil partner The partner's name, or nil if `name` is not a
---   `parse_`/`format_` helper.
function partnerName(name)
    local partner = nil
    if name:sub(1, 6) == "parse_" then
        partner = "format_" .. name:sub(7)
    elseif name:sub(1, 7) == "format_" then
        partner = "parse_" .. name:sub(8)
    end
    return partner
end
parse-format-relates/plugin/plugin.cpp
return plugin.registerTransform("parse-format-relates",
    [](mrdocs::TransformExtensionContext const& ctx) -> mrdocs::Expected<void>
    {
        for (mrdocs::Symbol const& symbol : *ctx.corpus)
        {
            relatePartner(*ctx.corpus, symbol);
        }
        return {};
    });
parse-format-relates/plugin/plugin.cpp
void
relatePartner(mrdocs::Corpus& corpus, mrdocs::Symbol const& symbol)
{
    if (!symbol.isFunction())
    {
        return;
    }
    std::string const name = partnerName(symbol.Name);
    if (name.empty())
    {
        return;
    }
    auto const partner = corpus.lookup(name);
    if (partner)
    {
        addSee(*corpus.find(symbol.id), name, partner->id);
    }
}
parse-format-relates/plugin/plugin.cpp
std::string
partnerName(std::string_view name)
{
    if (name.starts_with("parse_"))
    {
        return "format_" + std::string(name.substr(6));
    }
    if (name.starts_with("format_"))
    {
        return "parse_" + std::string(name.substr(7));
    }
    return {};
}
parse-format-relates/plugin/plugin.cpp
void
addSee(mrdocs::Symbol& target, std::string const& name,
    mrdocs::SymbolID const& id)
{
    mrdocs::doc::ReferenceInline reference(name);
    reference.id = id;
    mrdocs::doc::SeeBlock see;
    see.children.emplace_back(std::move(reference));
    if (!target.doc)
    {
        target.doc.emplace();
    }
    target.doc->sees.clear();
    target.doc->sees.push_back(std::move(see));
}
Preview
request

An HTTP request as a structured value.

Synopsis

Declared in <parse‐format‐relates.cpp>

struct request;
Non-Member Functions

Name

Description

format_request

Format r as the wire‐format text of an HTTP request.

parse_request

Parse text into a request. Returns a valid request on success.

user

A user record.

Synopsis

Declared in <parse‐format‐relates.cpp>

struct user;
Non-Member Functions

Name

Description

format_user

Format u as the canonical wire‐format text of a user.

parse_user

Parse text into a user.

format_request

Format r as the wire‐format text of an HTTP request.

Synopsis

Declared in <parse‐format‐relates.cpp>

char const*
format_request(request const& r);
Parameters

Name

Description

r

An HTTP request as a structured value.

See Also
format_user

Format u as the canonical wire‐format text of a user.

Synopsis

Declared in <parse‐format‐relates.cpp>

char const*
format_user(user const& u);
Parameters

Name

Description

u

A user record.

See Also
parse_request

Parse text into a request. Returns a valid request on success.

Synopsis

Declared in <parse‐format‐relates.cpp>

request
parse_request(char const* text);
Return Value

An HTTP request as a structured value.

See Also
parse_user

Parse text into a user.

Synopsis

Declared in <parse‐format‐relates.cpp>

user
parse_user(char const* text);
Return Value

A user record.

See Also

Looking the other symbol up by name is the idiom whenever a write needs to refer to a symbol other than the one the walk is on.

Notice in this example that s.doc.sees receives a list of polymorphic types that represent a paragraph in s.doc.sees.children. These polymorphic objects accept an object with a kind: selector that names the concrete derived class to construct.

Reading files

After being presented with the arguments for generated reference documentation, a common objection is that the prose should be mostly written by technical writers, not developers. The rationale is not to clutter the headers, and that technical writers can focus on use cases the users are interested in and dedicate themselves to long tutorials.

A transform can bridge the two by also reading documentation from external sources. While developers write documentation in code that is verifiably correct and guaranteed never to drift, a writer keeps the extra documentation alongside the project. The ghostwrite transform fills in each symbol’s description from the corresponding file. A complete, runnable example lives at examples/extensions/ghostwriter/.

  • Transform

  • Input

  • External docs

  • Config

addons/extensions/ghostwriter.lua
local read_file, doc_path, paragraphs, blocks, inlines

mrdocs.register_transform("ghostwriter", function(ctx)
  local root = (ctx.params and ctx.params.source) or "external-docs"
  for _, sym in ipairs(ctx.corpus.symbols) do
    -- Fill the description from the file when the symbol has none of its own.
    -- The brief written in the header is a separate field, so it is kept; the
    -- writer's file supplies the detailed description the developer left out.
    if sym.name ~= nil and (not sym.doc or #(sym.doc.document or {}) == 0) then
      local text = read_file(doc_path(ctx, sym, root))
      if text then
        local new_blocks = blocks(paragraphs(text))
        if #new_blocks > 0 then
          if not sym.doc then
            sym.doc = {}
          end
          sym.doc.document = new_blocks
        end
      end
    end
  end
end)

--- Read a whole file into a string.
--- @param path string Path to the file, relative to the run's working directory.
--- @return string|nil The file's contents, or nil when the file does not exist.
function read_file(path)
  local f = io.open(path, "r")
  if not f then
    return nil
  end
  local content = f:read("*a")
  f:close()
  return content
end

--- The external file that documents a symbol, mirroring its scope.
--- For example app::Vec2 maps to `<root>/app/Vec2.md`.
--- @param ctx table The transform context, used to walk `parent` ids.
--- @param sym table The symbol to locate a file for.
--- @param root string The source directory holding the writer's files.
--- @return string The path to the symbol's Markdown file.
function doc_path(ctx, sym, root)
  local parts = {}
  local cur = sym
  while cur and cur.name ~= nil do
    table.insert(parts, 1, cur.name)
    cur = cur.parent and ctx.corpus.get(cur.parent) or nil
  end
  return root .. "/" .. table.concat(parts, "/") .. ".md"
end

--- Split Markdown text into paragraphs.
--- Paragraphs are separated by blank lines; a soft line break inside a paragraph
--- is collapsed to a single space.
--- @param text string The raw Markdown.
--- @return string[] One entry per non-empty paragraph.
function paragraphs(text)
  local out = {}
  text = text:gsub("\r\n", "\n")
  for para in (text .. "\n\n"):gmatch("(.-)\n\n") do
    para = para:gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", "")
    if #para > 0 then
      out[#out + 1] = para
    end
  end
  return out
end

--- Wrap each paragraph string in a documentation paragraph block.
--- @param paras string[] Paragraphs, as returned by `paragraphs`.
--- @return table[] Paragraph block nodes, suitable for `sym.doc.document`.
function blocks(paras)
  local out = {}
  for _, p in ipairs(paras) do
    out[#out + 1] = { kind = "paragraph", children = inlines(p) }
  end
  return out
end

--- Parse one paragraph into documentation inline nodes.
--- Text wrapped in single backticks becomes an inline-code node; everything else
--- becomes plain text. Only backtick code spans are handled here; a richer
--- transform could parse more of Markdown.
--- @param text string A single paragraph, with no line breaks.
--- @return table[] Inline nodes, suitable as a block's `children`.
function inlines(text)
  local nodes = {}
  local is_code = false
  for segment in (text .. "`"):gmatch("(.-)`") do
    if #segment > 0 then
      if is_code then
        nodes[#nodes + 1] = { kind = "code", children = { { kind = "text", literal = segment } } }
      else
        nodes[#nodes + 1] = { kind = "text", literal = segment }
      end
    end
    is_code = not is_code
  end
  return nodes
end
simple.cpp
/// Application value types.
namespace app {

/// A point or displacement in the plane.
struct Vec2
{
    /// The x component.
    double x;
    /// The y component.
    double y;

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

/// A circle in the plane.
struct Circle
{
    /// The radius.
    double radius;

    /// The enclosed area.
    ///
    /// @return The area enclosed by the circle.
    double area() const;
};

/// The distance between two points.
///
/// @param a The first point.
/// @param b The second point.
/// @return The straight-line distance between `a` and `b`.
double distance(Vec2 const& a, Vec2 const& b);

}
external-docs/app/Vec2.md
A `Vec2` stores a pair of Cartesian coordinates and doubles as both a point
and a displacement. Arithmetic on it follows the usual rules for a 2-D vector
space, so adding two vectors adds their components and scaling multiplies them.

Prefer `Vec2` over a bare `std::pair<double, double>` when the values are
geometric: the named `x` and `y` members and the geometry helpers make intent
obvious at the call site, and keep unrelated pairs from being mixed in by
mistake.
external-docs/app/Circle.md
A `Circle` is defined entirely by its radius; its center is supplied by the
surrounding coordinate system rather than stored here. This keeps the type
small enough to pass by value.

The radius is assumed non-negative. Passing a negative radius is a precondition
violation, not a runtime error, so the geometry helpers do not check for it.
external-docs/app/distance.md
`distance` returns the Euclidean distance between two points: the length of the
straight segment joining them, or equivalently the square root of the summed
squared differences of their `x` and `y` components.

The value is symmetric in its arguments and is zero exactly when the two points
coincide, so it works well as the basis for an equality-with-tolerance check.
mrdocs.yml
addons-supplemental:
  - addons
# tag::generator[]
generator: adoc
# end::generator[]
multipage: false
show-namespaces: false
warn-if-undocumented: false
source-root: .
input:
  - .
transform-options:
  ghostwriter:
    source: external-docs

The example is a Lua transform that reads the corresponding Markdown files by the technical writer. The code includes a brief for each symbol and its metadata, while the writer provides a longer description. The mrdocs.yml configuration file loads the transform and points it at the source directory through transform-options.ghostwriter.source.

The transform reads the file whose path matches each symbol’s scope and sets its description from the file’s paragraphs. It only touches the description, and every other field the developer wrote stays. The distance function shows the split: its brief, its @param entries, and its @return all come from the header, while the writer’s file supplies just the longer description. The rendered page carries both sources at once:

Preview
app::Circle

A circle in the plane.

Synopsis

Declared in <simple.cpp>

struct Circle;
Description

A Circle is defined entirely by its radius; its center is supplied by the surrounding coordinate system rather than stored here. This keeps the type small enough to pass by value.

The radius is assumed non‐negative. Passing a negative radius is a precondition violation, not a runtime error, so the geometry helpers do not check for it.

Member Functions

Name

Description

area

The enclosed area.

Data Members

Name

Description

radius

The radius.

app::Circle::area

The enclosed area.

Synopsis

Declared in <simple.cpp>

double
area() const;
Return Value

The area enclosed by the circle.

app::Circle::radius

The radius.

Synopsis

Declared in <simple.cpp>

double radius;
app::Vec2

A point or displacement in the plane.

Synopsis

Declared in <simple.cpp>

struct Vec2;
Description

A Vec2 stores a pair of Cartesian coordinates and doubles as both a point and a displacement. Arithmetic on it follows the usual rules for a 2‐D vector space, so adding two vectors adds their components and scaling multiplies them.

Prefer Vec2 over a bare std::pair<double, double> when the values are geometric: the named x and y members and the geometry helpers make intent obvious at the call site, and keep unrelated pairs from being mixed in by mistake.

Member Functions

Name

Description

length

The Euclidean length.

Data Members

Name

Description

x

The x component.

y

The y component.

Non-Member Functions

Name

Description

distance

The distance between two points.

app::Vec2::length

The Euclidean length.

Synopsis

Declared in <simple.cpp>

double
length() const;
Return Value

The straight‐line length of the vector.

app::Vec2::x

The x component.

Synopsis

Declared in <simple.cpp>

double x;
app::Vec2::y

The y component.

Synopsis

Declared in <simple.cpp>

double y;
app::distance

The distance between two points.

Synopsis

Declared in <simple.cpp>

double
distance(
    Vec2 const& a,
    Vec2 const& b);
Description

distance returns the Euclidean distance between two points: the length of the straight segment joining them, or equivalently the square root of the summed squared differences of their x and y components.

The value is symmetric in its arguments and is zero exactly when the two points coincide, so it works well as the basis for an equality‐with‐tolerance check.

Return Value

The straight‐line distance between a and b.

Parameters

Name

Description

a

The first point.

b

The second point.