Skip to content

Gate docstring @code blocks in CI: extract, compile, fail on error #380

Description

@mvandeberg

Summary

The 104 @code blocks in our public headers are never compiled by anything. They
are the only code we publish with no build gate. This issue proposes extracting
every block from the MrDocs corpus, generating one translation unit per block, and
compiling and linking them in CI with the same warnings-as-errors posture as
test/doc/snippets.

Measured baseline (method below): 73 of the 104 blocks do not compile.

There is a prerequisite: MrDocs currently aborts on our corpus. It is fully
diagnosed in Prerequisite: fix the MrDocs abort
and must be resolved first.

Why we implement this ourselves

MrDocs does not have a code-block extraction feature. It is an open,
unimplemented request: cppalliance/mrdocs#620 "Compile code snippets in
javadocs"
, filed 2024-06-05,
label feat, no milestone, no PR. Its only substance is a Dec 2025 comment noting
that rustdoc "generate[s] a single long file containing all examples".

Verified against both cached 0.8.0 builds and current develop: no CLI option, no
config key, and no generator does this.

We do not need it. The data is already exposed. mrdocs::doc::CodeBlock carries
{ literal, info }, and the XML generator emits each block verbatim:

<code><kind>code</kind><literal>poc::widget w;
w.spin();</literal></code>

A ~40-line extractor over reference.xml suffices. Proven with a throwaway PoC: a
header with 5 blocks (one valid, one with a typo'd member, one @code{.bash}, one
declaration fragment) extracted cleanly, compiled 4, skipped the shell block, and
failed the broken one with exit 1.

What we already do, and the gap

test/doc/ is the precedent, and it is stronger than the docstring situation:

corpus count built linked run warnings
test/doc/snippets/*.cpp 40 yes yes yes — 35 declare TEST_SUITE(...), run under b2 (run [ glob snippets/*.cpp ]) and ctest (boost_capy_test_suite_discover_tests) -Wall -Wextra -Werror / /W4 /WX
test/doc/programs/*.cpp 25 yes yes yes — one executable each, passes when it exits cleanly; 3b_synchronization_race is compile-only same
docstring @code blocks 104 no no no none

Doc snippets build, link, and run. Docstring blocks get nothing.

Docstring blocks should build and link, but not run, matching what
test/doc/snippets does minus execution. Running needs a main, an io_context
and real I/O, and the blocks have no expected output to assert against. Linking, by
contrast, is nearly free and catches a class of error compilation cannot: calling
API that has a declaration but no definition.

Measured on the blocks that compile today: 30 of 31 link against
libboost_capy.a with a trivial main. The single failure is
ex/io_awaitable_promise_base.hpp:49, which illustrates a promise type whose
members (initial_suspend, final_suspend, get_return_object) are declared but
never defined — an incomplete example that the rewrite should complete anyway.

One binary per block. Blocks cannot share a link target: the 31 objects produce
5 duplicate-symbol collisions, all on example(), because independently authored
blocks pick the same names. This differs from test/doc/snippets, whose 40 files
are hand-authored into one binary with names managed by their authors.

Compile to an object with -c (never -fsyntax-only) so template instantiation and
constexpr evaluation are exercised, then link each object with a stub main.

Design

Requirement: every @code block must be valid at namespace scope. This adds
overhead to examples but makes every block trivially compilable, with no wrapping
heuristics guessing at intent.

Generated TU per block:

#include <boost/capy.hpp>
// the same -Wunused-* suppression preamble test/doc/snippets/*.cpp already carry
namespace capy = boost::capy;          // 5 blocks write `capy::` unqualified
using namespace boost::capy;           // or boost::capy::test for test/ headers
<block literal verbatim>

The using namespace is not optional. Docstring blocks are written from inside the
library's namespace — they say task<>, fuse, mutable_buffer. Without a
using-directive 0 of 104 blocks compile, and every failure is
no template named 'task'-style lookup noise that says nothing about block
quality. MrDocs knows each symbol's enclosing namespace, so the harness can emit
the correct directive per block.

Suggested layout, mirroring test/doc: generated files under a build directory,
compiled by a test/doc/docstrings target with boost_capy_doc_warnings_as_errors
applied, added to the tests target but registering no ctest entries.

Measured baseline

All 104 blocks extracted and compiled with the harness above
(clang++ -std=c++20 -c -Wall -Wextra -Werror):

count
total @code blocks (52 headers) 104
compile today 31
require rewriting 73
failures that are warning-only 0

Allowing four harness shapes (umbrella vs. declaring header, using-directive vs.
wrapping in namespace boost::capy { }) raises the pass count only to 36, so
harness generosity is not the lever — the blocks genuinely need rewriting.

Corpus characterisation:

property count
contain a function or class definition 89
bare co_await / co_return not inside a definition 20
contain ... elision, i.e. pseudo-code that cannot compile 15
tagged fences (@code{...}) 0

The 15 elided blocks are in scope: they get upgraded to real, compiling code
rather than opted out.

The dominant failure is expected unqualified-id — the block is a bare statement
sequence. test/fuse.hpp opens with:

fuse()([](fuse& f) {
    auto ec = f.maybe_fail();
    ...
});

fuse resolves correctly; the statement is simply not valid at namespace scope.
Under the new rule these become named functions.

Blocks requiring rewrite

header blocks lines
include/boost/capy/buffers.hpp 1 383
include/boost/capy/buffers/buffer_slice.hpp 1 63
include/boost/capy/buffers/consuming_buffers.hpp 1 38
include/boost/capy/concept/execution_context.hpp 1 51
include/boost/capy/concept/executor.hpp 1 90
include/boost/capy/concept/io_awaitable.hpp 2 81, 96
include/boost/capy/concept/io_runnable.hpp 1 84
include/boost/capy/cond.hpp 1 29
include/boost/capy/detail/thread_local_ptr.hpp 1 42
include/boost/capy/ex/any_executor.hpp 1 75
include/boost/capy/ex/async_event.hpp 1 84
include/boost/capy/ex/async_waker.hpp 1 103
include/boost/capy/ex/execution_context.hpp 1 54
include/boost/capy/ex/executor_ref.hpp 1 98
include/boost/capy/ex/immediate.hpp 2 139, 214
include/boost/capy/ex/io_awaitable_promise_base.hpp 1 105
include/boost/capy/ex/recycling_memory_resource.hpp 1 42
include/boost/capy/ex/run.hpp 2 614, 715
include/boost/capy/ex/run_async.hpp 5 376, 541, 579, 627, 677
include/boost/capy/ex/strand.hpp 1 72
include/boost/capy/ex/thread_pool.hpp 1 37
include/boost/capy/ex/work_guard.hpp 1 48
include/boost/capy/io/any_read_stream.hpp 1 65
include/boost/capy/io/any_stream.hpp 1 55
include/boost/capy/io/any_write_stream.hpp 1 66
include/boost/capy/io/write_now.hpp 2 66, 312
include/boost/capy/io_result.hpp 2 33, 40
include/boost/capy/io_task.hpp 1 27
include/boost/capy/task.hpp 1 109
include/boost/capy/test/buffer_to_string.hpp 1 30
include/boost/capy/test/bufgrind.hpp 3 48, 67, 80
include/boost/capy/test/fuse.hpp 20 75, 89, 99, 115, 145, 158, 176, 252, 293, 325, 361, 378, 423, 467, 607, 725, 784, 833, 889, 954
include/boost/capy/test/read_stream.hpp 1 45
include/boost/capy/test/run_blocking.hpp 4 297, 404, 437, 472
include/boost/capy/test/stream.hpp 1 63
include/boost/capy/test/write_stream.hpp 1 47
include/boost/capy/when_all.hpp 2 621, 744
include/boost/capy/when_any.hpp 2 698, 829

Corpus fidelity: three traps in the XML

Measured against the real capy corpus (reference.xml, 1745 declarations):

1. Filter inline code spans. The corpus contains 1891 <code> elements, but
only 111 are code blocks. The other 1780 are inline code spans, which carry
no <literal> child. Selecting on tag name alone inflates the work ~17x and feeds
garbage to the compiler. Select only <code> elements having a non-empty
<literal>.

2. Deduplicate by literal. Those 111 block instances reduce to 102 distinct
literals
. Overload sets and copied documentation repeat the same block on several
symbols — 8 literals appear on more than one symbol, up to 3x. Without dedup the
same failure is reported repeatedly, attributed to different headers.

3. The corpus does not contain every block in the headers — this gate can
silently under-report.
One of the 104 source blocks never reaches the corpus:

  • include/boost/capy/io/write_now.hpp:312 documents an operator() guarded by
    #if BOOST_CAPY_WRITE_NOW_WORKAROUND (declared at line 330).
  • That macro is 1 only for defined(__GNUC__) && !defined(__clang__).
  • MrDocs uses clang, so the declaration is preprocessed away, the docstring
    attaches to no symbol, and the block vanishes.

So coverage is compiler-dependent. The gate must cross-check the number of blocks
extracted from the corpus against a direct scan of the headers and fail on any
delta
, otherwise blocks behind inactive preprocessor branches are skipped while
the gate reports green. Do not accept a green run as proof of coverage; verify the
gate by making it fail.

MrDocs limitations to work around

1. @code{.cpp} is broken — worse than mrdocs#244 records. CodeBlock::info is declared but
never populated anywhere in current develop; visitVerbatimBlock sets only
literal. The tag leaks into the literal and the remaining lines lose dedenting:

fence emitted literal
@code poc::widget w;\nw.spin(); — dedented correctly
@code{.cpp} {.cpp}\n poc::widget w;\n w.spinn(); — tag leaks, indent kept

We use zero tagged fences, so nothing regresses today. Since all blocks must
compile, no language tag is needed. Worth reporting upstream separately, because
develop's own commands/blocks.adoc
instructs users to write @code{.cpp}.

2. No per-block source location. Confirmed on the real corpus: no <code>
element carries a <location>; only the enclosing symbol does. A failing block can
only be reported at its symbol's declaration line. In the PoC a block at header
line ~19 was reported as line 35. Emit a #line directive pointing at the symbol
and document the caveat, or re-find the block by scanning the header.

Prerequisite: fix the MrDocs abort

MrDocs currently aborts on capy's corpus, so no XML-driven gate can run. Fully
diagnosed below.

Symptom

mrdocs: clang/lib/AST/ExprClassification.cpp:72:
  Cl clang::Expr::ClassifyImpl(ASTContext &, SourceLocation *) const:
  Assertion `isPRValue()' failed.

Reproduces with both cached 0.8.0 builds (develop f942a24, master e9f847d),
with both the adoc and xml generators, and under three independent compilation
databases. It is therefore not generator-specific and not a stale-build artifact.

Root cause

capy's headers trip an LLVM assertion when compiled as C++26, and MrDocs
compiles at C++26 whenever the compile command carries no explicit -std=.

Minimal repro — a one-line TU:

#include <boost/capy.hpp>
-std= in the compile-db entry result
-std=c++17 no crash
-std=c++20 no crash
-std=c++23 no crash
-std=gnu++23 no crash
-std=c++26 CRASH
-std=c++2c CRASH
(flag absent) CRASH — identical to C++26

Two independent paths reach a -std-less command:

  1. The cmake: path, which is what the real doc build uses. MrDocs synthesizes
    its own single-TU compilation database. The generated
    .temp/build/compile_commands.json holds exactly one entry, for a generated
    mrdocs.cpp containing:

    #include <boost/capy.hpp>
    #include <boost/capy/test.hpp>

    and that entry has no -std= flag, despite doc/mrdocs.yml passing
    -DCMAKE_CXX_STANDARD=20. The cmake: variable does not reach the synthesized
    TU.

  2. A real project compile database. Bisecting build_cmake/compile_commands.json
    (170 entries) isolates extra/test_suite/test_suite.cpp, whose command is just
    clang++ -I... -g -c test_suite.cpp. The cause is
    extra/test_suite/CMakeLists.txt:21:

    target_compile_features(boost_capy_test_suite PUBLIC cxx_std_17)

    CMake satisfies a cxx_std_17 requirement with a compiler already defaulting to
    C++17 by emitting no -std= flag at all.

What is ruled out

  • Not a capy source defect. System clang compiles test_suite.cpp with 0
    errors at both its C++17 default and -std=c++26, and the umbrella header is
    clean at C++20 and C++23.
  • Not a stdlib incompatibility. Trivial headers including <memory>,
    <coroutine>, <tuple>, <optional>, <variant> and <functional> extract
    fine.
  • Not a stale build directory. Reproduces via the cmake: path, which
    configures its own fresh build tree.
  • Not new headers. The mrdocs binaries are unchanged since June while
    doc/build/site last built 2026-08-14, so the trigger is environmental.

Verified fix

Forcing -std=gnu++20 onto all 170 compile-database entries makes the whole run
succeed: rc=0, 1745 declarations extracted, a 3.0 MB reference.xml produced.
This was verified without editing any capy file, by rewriting the compilation
database.

The implementer needs to make MrDocs compile capy at C++20 through a supported
mechanism. Candidates, in rough order of preference:

  • Give the synthesized mrdocs.cpp TU an explicit -std=c++20. Note
    doc/mrdocs.yml ends its cmake: line with
    -DCMAKE_EXPORT_COMPILE_COMMANDS=OFF, overriding the =ON MrDocs passes
    itself — that looks unintentional and is likely why MrDocs falls back to
    synthesizing its own TU instead of using the project database.
  • Raise extra/test_suite/CMakeLists.txt from cxx_std_17 to cxx_std_20 so
    that entry carries a standard flag — both targets declare it, at lines 21
    (boost_capy_test_suite) and 29 (boost_capy_test_suite_main). Required if
    the project database is used; insufficient on its own, because the cmake:
    path never reads it.
  • Report the assertion upstream with the one-line repro above. capy's headers
    are valid C++26, so MrDocs should not abort — and defaulting to C++26 for a
    C++20 library is itself questionable.

Note that fixing only the CMake standard leaves the cmake: path broken, and
fixing only the cmake: path leaves the project-database route broken. Confirm
which path the doc build actually takes before declaring this resolved.

Acceptance criteria

  • The MrDocs abort is resolved and mrdocs --generator=xml completes on capy.
  • Every @code block in include/ is valid at namespace scope, including the
    15 blocks currently using ... elision.
  • An extractor produces one TU per distinct block literal, selecting only
    <code> elements with a non-empty <literal>.
  • The block count from the corpus is cross-checked against a direct header scan,
    and any delta fails the gate.
  • Generated TUs compile with -Wall -Wextra -Werror (/W4 /WX) and each links
    into its own binary with a stub main. The binaries are never executed.
  • The gate runs in CI and fails the build on any non-compiling block.
  • A deliberately broken block is proven to fail the gate — a bite-test, not a
    green run.
  • Diagnostics identify the offending header and symbol.

Out of scope

  • Running the linked binaries. They have no expected output, and running them
    would need an io_context and real I/O. Linking is in scope; execution is not.
  • Implementing mrdocs#620 upstream.
  • The @code{.cpp} language-tag fix (mrdocs#244) — track upstream.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions