Back Original

Documenting C++ with Doxygen and Sphinx - doxyrest

This post is part of the C++ Documentation Practices series.

Unifying C++ and Python documentation.

Background

For almost half a decade my C++ projects have been either scoped to where plain Doxygen works well enough 1, or largely undocumented at the C++ API level 2. While integrating with Sphinx via exhale or breathe shown earlier 3 works well, it often fails to provide a seamless source code view or struggles with modern C++ constructs.

For smaller, well formed libraries, the drawbacks of not being able to read the source are manifold, tending towards over-verbose documentation strings. Back in 2020 during the season of docs for Symengine, I stuck to pure doxygen with a custom theme, doxyYoda, a setup which served me well for Fortran projects like GaussJacobiQuad 4, and continues to be attractive.

Given the advent of pixi, however, it is worth revisiting the doxyrest setup 5. For the most part, the focus will be on implementation within a meson based project, rgpot, which defines an RPC interface for accessing forces and energies, typically for an atomistic calculation, before demonstrating the benefit for a much larger project, metatomic.

Baseline – rgpot

In general, the pipeline relies on Doxygen emitting XML, which doxyrest parses via Lua templates to generate reStructuredText, finally consumed by Sphinx. This allows the C++ API to sit seamlessly alongside Python documentation in a single Sphinx project, utilizing modern themes like furo or pydata-sphinx-theme.

Figure 1: Overview of the doxyrest build process

Figure 1: Overview of the doxyrest build process

The pixi task workflow

For rgpot, I opted to encapsulate the build complexity within pixi tasks. This avoids polluting the repository with git submodules for tooling that is essentially a build-time dependency, and has better support for managing dependencies.

The workflow is broken down into dependency retrieval, patching, and generation:

1[feature.docs.tasks.setup-doxyrest]
2description = "Clones the doxyrest dependency into the subprojects directory"
3cmd = """
4test -d subprojects/doxyrest || \
5(mkdir -p subprojects && \
6git clone --depth 1 --single-branch \
7https://github.com/vovkos/doxyrest subprojects/doxyrest)
8"""

The setup-doxyrest task handles the “shallow clone” approach to keep the checkout light.

The Build Cycle

With the infrastructure in place, the actual build is a two-step process defined in doxybuild:

1[feature.docs.tasks.doxybuild]
2description = "Generates XML via Doxygen and converts it to RST via Doxyrest"
3cwd = "docs/source"
4depends-on = ["setup-doxyrest"]
5cmd = "doxygen Doxyfile_potlib.cfg && doxyrest -c doxyrest-config.lua"

We first generate the XML blobs with Doxygen. Immediately after, doxyrest is invoked with a Lua configuration file, which defines the files used to map the generated XML into frames. Internally, doxyrest relies on Lua Frames which are templates that mix standard reStructuredText with injected Lua logic (delimited by %{ ... }) and variable substitution (via $var).

In principle, one could write custom frames to radically alter the output, for C++ projects we simply point FRAME_DIR_LIST to the standard “C-family” frames provided by the repo we cloned earlier.

 1INPUT_FILE = "xml/index.xml"
 2OUTPUT_FILE = "api/index.rst"
 3INDEX_TITLE = "API Reference"
 4
 5FRAME_DIR_LIST = {
 6    "../../subprojects/doxyrest/frame/common",
 7    "../../subprojects/doxyrest/frame/cfamily"
 8}
 9
10LANGUAGE = "cpp"
11ESCAPE_ASTERISKS = true
12ESCAPE_TRAILING_UNDERSCORES = true

With this, doxyrest digests the Doxygen XML into produce RST files that Sphinx can ingest.

Sphinx Configuration

For Sphinx to process the generated RST correctly, it needs to load the doxyrest extension. Since we are downloading/cloning this into a local subdirectory, we must explicitly update the python path in docs/src/conf.py so Sphinx can find it.

 1# For doxyrest, the location of this can change
 2sys.path.insert(0, os.path.abspath("subprojects/doxyrest/sphinx"))
 3
 4extensions = [
 5    # ... other extensions
 6    # c++ helpers
 7    "doxyrest",
 8    "cpplexer",
 9    # ...
10]

Finally, the standard Sphinx build can pick up the artifacts:

1[feature.docs.tasks.sphinxbld]
2depends-on = [ { task = "mkrst", environment = "docs" }, ]
3cmd = """ sphinx-build docs/source/ docs/build """

CI deployment

The primary advantage of encapsulating the build logic within pixi tasks lies in the simplification of Continuous Integration (CI) pipelines. Since the environment and build steps reside within pixi.toml, the CI configuration need only setup pixi and invoke the target task.

1- uses: prefix-dev/setup-pixi@v0.8.10
2  with:
3    activate-environment: true
4    environments: docs
5- name: Generate Docs
6  run: pixi r docbld

This reduces the friction of maintenance significantly, as local reproduction of CI failures becomes a matter of running pixi r docbld, and integrates easily with other actions, like the doc-previewer to post artifact links directly on Pull Requests, facilitating easier review of the generated output without digging through Action logs.

For larger projects like metatomic which do not use pixi, the process requires a bit more manual orchestration. Here, the challenge is replacing the declarative task runner with a robust script that ensures dependencies (like the doxyrest lua frames) are present before invoking the build.

While a Makefile or bash script is the traditional route, using nushell provides a nice balance of readability and cross-platform handling without the foot-guns of strict POSIX shell scripting, which are best avoided, despite my return to bash.

The ideal orchestrator script

Instead of relying solely on tox and file driven configurations to do the heavy lifting of environment setup, we use a nu script to bootstrap the documentation build.

First, we define our environment and ensure the local directory structure exists. We check if doxyrest is available in the path (useful for local development) or if we need to fetch it.

1let root = ($env.PWD)
2let deps_dir = ($root | path join "docs/subprojects")
3let doxyrest_src = ($deps_dir | path join "doxyrest")
4let install_dir = ($deps_dir | path join "install")
5let has_doxyrest = (which doxyrest | is-not-empty)
6
7if not ($deps_dir | path exists) { mkdir $deps_dir }

Next, if the binary is missing, we download the pre-compiled release. This avoids the complexity of setting up a C++ build chain in the CI environment just for a doc tool.

 1if not $has_doxyrest and not ($install_dir | path exists) {
 2    print "Downloading Doxyrest binary release..."
 3    let version = "2.1.3"
 4    let target = "linux-amd64"
 5    let archive = $"doxyrest-($version)-($target).tar.xz"
 6    let url = $"https://github.com/vovkos/doxyrest/releases/download/doxyrest-($version)/($archive)"
 7
 8    cd $deps_dir
 9    http get $url | save $archive
10    tar -xJf $archive
11
12    if ($install_dir | path exists) { rm -rf $install_dir }
13    mv $"doxyrest-($version)-($target)" $install_dir
14    rm $archive
15    cd $root
16}

Even with the binary, we still need the Lua frames (themes/templates) from the source repository. We clone this shallowly.

1if not ($doxyrest_src | path exists) {
2    print $"Cloning doxyrest frames into ($doxyrest_src)..."
3    git clone --depth 1 --single-branch https://github.com/vovkos/doxyrest $doxyrest_src
4}

Finally, we run the pipeline. Note that we prepend the local install directory to the PATH only for the doxyrest execution block.

 1print "Generating XML with Doxygen..."
 2cd docs
 3if not ("build/doxygen/xml" | path exists) { mkdir build/doxygen/xml }
 4doxygen Doxyfile.metatomic
 5
 6print "Generating RST with local Doxyrest..."
 7with-env { PATH: ($env.PATH | prepend ($install_dir | path join "bin")) } {
 8    doxyrest -c doxyrest-config.lua
 9}
10
11print "Building HTML with Sphinx (tox)..."
12tox -e docs

Sphinx and CI configurations are similar to the previous project, with the complete including a bash orchestrator in the PR here.

Click to see the full scripts/mkdoc.nu script
 1#!/usr/bin/env nu
 2
 3def main [] {
 4    let root = ($env.PWD)
 5    let deps_dir = ($root | path join "docs/subprojects")
 6    let doxyrest_src = ($deps_dir | path join "doxyrest")
 7    let install_dir = ($deps_dir | path join "install")
 8    let has_doxyrest = (which doxyrest | is-not-empty)
 9
10    if not ($deps_dir | path exists) { mkdir $deps_dir }
11
12    # 1. Grab Doxyrest Binary if missing
13    if not $has_doxyrest and not ($install_dir | path exists) {
14        print "Downloading Doxyrest binary release..."
15        let version = "2.1.3"
16        let target = "linux-amd64"
17        let archive = $"doxyrest-($version)-($target).tar.xz"
18        let url = $"https://github.com/vovkos/doxyrest/releases/download/doxyrest-($version)/($archive)"
19
20        cd $deps_dir
21        http get $url | save $archive
22        tar -xJf $archive
23
24        if ($install_dir | path exists) { rm -rf $install_dir }
25        mv $"doxyrest-($version)-($target)" $install_dir
26        rm $archive
27        cd $root
28    }
29
30    # 2. Clone the frames (still needed for the Lua templates)
31    if not ($doxyrest_src | path exists) {
32        print $"Cloning doxyrest frames into ($doxyrest_src)..."
33        git clone --depth 1 --single-branch https://github.com/vovkos/doxyrest $doxyrest_src
34    }
35
36    # 3. Run the Pipeline
37    print "Generating XML with Doxygen..."
38    cd docs
39    if not ("build/doxygen/xml" | path exists) { mkdir build/doxygen/xml }
40    doxygen Doxyfile.metatomic
41
42    print "Generating RST with local Doxyrest..."
43    with-env { PATH: ($env.PATH | prepend ($install_dir | path join "bin")) } {
44        doxyrest -c doxyrest-config.lua
45    }
46
47    print "Building HTML with Sphinx (tox)..."
48    tox -e docs
49}

Conclusions

Personally, I like the look of Doxygen still, and for some themes like shibuya, manual patches are required (e.g. monkeypatch @ rgpot). Nevertheless there are multi-language projects 6 which benefit from having a consistent style across languages, so this works out quite well. Probably the next step would consistency across even more languages and esoteric builds, like those in Metatensor.


Series info

C++ Documentation Practices series

  1. Documenting C++ with Doxygen and Sphinx - Exhale
  2. Publishing Doxygen and Sphinx with Nix and Rake
  3. Documenting C++ with Doxygen and Sphinx - doxyrest <-- You are here!