Skip to content

Repository files navigation

cmake2doc

CI

Documentation generator for CMake. It parses CMake sources with tree-sitter, extracts doxygen-like comments from function() definitions and command calls, and renders them through your own Jinja templates.

Nothing about the output format is baked in: cmake2doc hands your template a parsed model of the file and gets out of the way.

Table of contents

Major features

  • Doxygen-like @-tags for arguments, options, params and more, extracted from comments above function(), macro() and command calls.
  • Custom Jinja templates, with three built-in ones to start from — Markdown or reStructuredText for Sphinx, out of the box.
  • A checking pass that compares the doc comment against what the CMake code actually accepts — and against @example blocks, which are parsed as CMake — catching drift a plain doc generator can't.
  • --inject to keep generated docs inside an existing file, such as a README, between two markers.
  • --check for CI, to fail a pipeline when generated docs are stale.
  • A cmake2doc.toml config file, so a whole project's generation settings live in one place instead of scattered across a Makefile and workflow files.
  • A cmake/cmake2doc.cmake module to run cmake2doc as part of the build.
  • --json output for tools that aren't templates.
  • Cross-platform: Linux, macOS and Windows.

Getting started

System requirements

The only system requirement is Python 3.10 or newer.

The supported OSes are the following:

  • Linux
  • MacOS
  • Windows

Ubuntu 22.04 or newer official repository already has the python of the required version.

Installation

pipx install cmake2doc
# Or:
pip install cmake2doc

Quick start

Document a function (or a macro) with @-tags in the comment block directly above it:

# Adds a library target together with its tests and install rules.
#
# @arg NAME the name of the resulting target
# @option EXCLUDE_FROM_ALL do not build this target by default
# @param OUTPUT_NAME @required file name of the produced artifact
# @multiparam SOURCES the source files to compile
function(example_add_library)
endfunction()

Render it with the built-in template:

cmake2doc --template function.md.jinja --output docs/reference.md CMakeLists.txt

That function becomes docs/reference.md:

## example_add_library

Adds a library target together with its tests and install rules.

```
example_add_library(
    <NAME>
    [EXCLUDE_FROM_ALL]
    OUTPUT_NAME <value>
    [SOURCES <value>...]
)
```

* <**NAME**> the name of the resulting target
* **EXCLUDE_FROM_ALL** do not build this target by default
* **OUTPUT_NAME <value>** file name of the produced artifact
* **SOURCES <value>...** the source files to compile

A complete, runnable example lives in examples/ — one per output flavour: Markdown, reStructuredText, and reStructuredText using Sphinx's CMake domain.

Comment syntax

A doc comment is the run of # comment lines immediately above a function(), a macro() or a command call. A blank line ends the run. The block is dedented as a whole, so the space in the conventional # disappears while indentation inside the comment — nested lists, code blocks — is preserved.

Both cmake comment forms carry documentation. A bracket comment works the same way, including the #[==[.rst: style CMake's own modules use, where the .rst: marker and the # of the closing #]==] are punctuation rather than text:

#[==[.rst:
@brief Adds a library target.
#]==]
function(example_add_library)
endfunction()
Tag Applies to Meaning
@arg NAME function, macro Positional argument. Always required.
@option NAME function, macro Valueless flag.
@param NAME function, macro Keyword taking a single value.
@multiparam NAME function, macro Keyword taking one or more values.
@set_parent_scope NAME function, macro Variable the symbol sets in its caller's scope, which is how CMake hands a result back.
@required function, macro Marks the preceding parameter as required.
@type NAME function, macro What the preceding parameter's value should be.
@default VALUE function, macro What the preceding parameter is worth when left out.
@ingroup NAME function, macro, command Assigns the symbol to a group.
@defgroup NAME <title> a comment block of its own Defines a group: its title runs to the next blank line, like @brief does, and its description is the paragraphs below.
@file a comment block of its own Marks the block as documenting the file it is in.
@deprecated function, macro, command Marks the whole symbol as deprecated. Text after it stays in the description, where it reads as the reason.
@internal function, macro, command Marks the symbol as not part of the public interface. The public filter drops it.
@brief anything A one-paragraph summary, distinct from the description.
@example anything A sample, held as a block so blank lines inside it survive. Checked to parse as CMake.
@note, @warning anything A paragraph set apart from the description.
@since, @todo, @see anything A paragraph each: a version, a task, a cross-reference.

A tag that carries prose — @brief, @note, @warning, @since, @todo, @see — ends at a blank line, as Doxygen's do, and what follows the blank line belongs to the description again. @example and the parameter tags run to the next tag instead, so a sample or a parameter description may span paragraphs.

doc.brief is a plain string; the rest arrive as doc.sections, in the order they were written, and doc.of_kind('note') selects one kind of them.

Text that is not part of a tag becomes the description: text before the first parameter tag describes the symbol, text after a parameter tag describes that parameter.

An @ only starts a tag at the beginning of a line or after whitespace, so maintainer@example.com stays literal. Write @@ for a literal @ at the start of a word — for instance when prose mentions a tag, as in not tagged with @@ingroup.

Two things are left in the text and reported rather than acted on: a tag cmake2doc does not recognise, and a known tag that is not followed by something that looks like a name (@ingroup, so … is prose, not a group named ,). Both fail the run; pass --no-strict to have them reported as warnings and carry on. A tag that takes a name but has nothing at all after it — not even prose, just the end of the comment or another tag — is a harder error still: there is no literal text to fall back to keeping, so it fails the run even under --no-strict.

Checking the comment against the code

A CMake function states its interface twice — once in the doc comment, once in its own body — and the two drift apart. cmake2doc reads the second one and fails the run on the disagreement:

# @option QUIET be quiet
# @multiparam SRCS the source files
function(example_add_library)
    cmake_parse_arguments(ARG "QUIET" "" "SOURCES" ${ARGN})
endfunction()
cmake2doc: error: CMakeLists.txt:2: function example_add_library: SRCS is
documented as @multiparam but example_add_library does not accept it

Under --no-strict the run carries on and every disagreement is reported:

CMakeLists.txt:2: function example_add_library: warning: SRCS is documented as
@multiparam but example_add_library does not accept it
CMakeLists.txt:3: function example_add_library: warning: example_add_library
takes SOURCES but it is not documented; add @multiparam SOURCES

Four things are read out of the code:

  • both call forms of cmake_parse_arguments()
  • the named parameters of function(f NAME TYPE)
  • set(VAR ... PARENT_SCOPE)
  • return(PROPAGATE VAR)

The last two being what @set_parent_scope documents.

What the code does not state plainly is never guessed at, and so never warned about. A keyword list built from a variable, a body with two cmake_parse_arguments() calls in it, a macro that reaches for ${ARGV0}, or an output variable whose name the caller supplies (set(${ARG_OUTPUT_VARIABLE} ... PARENT_SCOPE)) all leave the matching tags unchecked. A symbol with no doc comment at all is not reported either — that is what --require-docs is for — but one documented at all, even with no parameter mentioned yet, is checked against its own code from the start.

An @example is checked the same way: it is CMake, so cmake2doc parses it and reports a sample that does not parse. Prose or another language belongs in a fenced code block, which is left alone unless it is fenced as cmake.

--no-strict demotes these to warnings as well.

Integration into a CMake project

Command line

cmake2doc [-t TEMPLATE -o OUTPUT]... [-I DIR]... [-c FILE] [--inject]
         [--json OUTPUT] [--exclude PATTERN]... [--require-docs]
         [--strict] [--check] CMAKE_FILE...
Flag Effect
-t, --template Template to render: a path, or the name of a built-in. Repeatable.
-o, --output Where to write the matching --template, or - for stdout. Repeatable, paired in order.
-I, --template-dir Extra directory to search for templates. Repeatable.
-c, --config Read the arguments from this TOML file instead of the nearest cmake2doc.toml.
--inject / --no-inject Write between the markers of an existing --output file instead of replacing it.
--json Also write the parsed model as JSON, for tools that are not templates.
--exclude Skip sources matching a glob, against the whole path or the file name. Repeatable.
--require-docs / --no-require-docs Exit non-zero if a public function() or macro() has no doc comment.
--strict / --no-strict Whether to treat a documentation problem — a doubtful @tag, or a comment that disagrees with the code — as an error rather than a warning. On by default; --no-strict reports them as warnings and carries on.
--check / --no-check Write nothing; exit non-zero if any output is missing or stale.
--list-templates List the built-in template names and exit.
--version Print the version and exit.

Each of --inject, --require-docs, --strict and --check has a --no- form, so any of them recorded as true in cmake2doc.toml can still be turned off for one run.

Each CMAKE_FILE is a file, a directory to search for CMakeLists.txt and *.cmake (dot-directories are skipped), or a glob pattern. cmake2doc expands directories and patterns itself, so it behaves the same in shells that do not, such as those on Windows.

cmake2doc does not follow include() or add_subdirectory() to find more sources — it only reads what CMAKE_FILE itself resolves to. This is usually invisible: pointing it at a project's root directory already reaches every subdirectory a normal add_subdirectory() tree does, since the search above is a plain walk of the filesystem, not of the build. It matters only when a directory is reached solely through an include() or add_subdirectory() that leads outside the given root — a sibling cmake/ module directory, or a dependency pulled in with FetchContent, say. List that directory explicitly as another CMAKE_FILE (or add it to path in cmake2doc.toml); cmake2doc will not find it on its own.

--check is meant for CI, to verify that generated documentation was regenerated after a change to the CMake sources; it prints a diff of what differs, since nobody in CI can re-run the generator to find out.

--require-docs is the other CI gate, the equivalent of rustdoc's missing_docs: a public symbol with no doc comment fails the run. A name starting with _ is private by CMake convention, and @internal says so outright; neither is required to be documented.

A .cmake2docignore file at the project root lists further --exclude patterns, one per line, # starting a comment.

The config file

A CI step that renders three templates needs six paired arguments to say so, and they then have to be kept in step across a Makefile, a workflow file and a pre-commit hook. Say it once instead, in cmake2doc.toml beside your CMake code:

template = ["reference.md.jinja"]
output = ["docs/reference.md"]
path = ["."]
require-docs = true

and the CI step is cmake2doc with nothing after it. Every long option has a setting of the same name, with - or _ between words. A setting of the wrong type is refused rather than coerced, down to the items of a list: strict = "no" is a string, and every non-empty string is true, so taking it would mean doing the opposite of what it says; template = "reference.md.jinja" is a string where a list belongs, and template = [1] a number where a name does. Anything given on the command line wins over the file, so cmake2doc --output - . still prints to the terminal — and a flag turned off explicitly counts as given, so --no-strict wins over a strict = true in the file.

The file is looked for in the working directory and then in each directory above it, stopping at a repository, so cmake2doc does the same thing from a build directory as from the project root. A relative path in it is relative to the file, not to wherever cmake2doc was run from — otherwise output = "docs/reference.md" would name a different file from every directory. A template that is not a file is left as written, so a built-in name still names a built-in. --config names a different file, which must then exist, and its directory is the project root instead.

The one setting with no option behind it is the [tags] table of tags of your own: a vocabulary is a property of the project, not of the run.

Injecting into a README

--inject keeps the documentation inside a file the author writes, rather than in one of its own. Mark the place once:

# My project

<!-- BEGIN_CMAKE2MD -->
<!-- END_CMAKE2MD -->

and everything between the markers is replaced on each run, leaving the prose around them alone. It composes with --check.

The marker syntax depends on --output's own extension, so it reads as a comment in the file it is written into: an .rst output gets a reStructuredText comment, since docutils does not hide an HTML comment the way a Markdown renderer does; anything else, including .md, gets the HTML comment above.

My project
==========

.. BEGIN_CMAKE2MD
.. END_CMAKE2MD

From CMake

cmake/cmake2doc.cmake adds targets that run cmake2doc as part of the build, so a project documents its own CMake code without a separate script to remember. Copy it into your module path, or fetch it:

include(cmake2doc)

# All options are read from cmake2doc.toml
cmake2doc_generate(
    # `cmake --build build --target docs` regenerates the documentation.
    # A second target, `docs-check`, verifies instead that it is up to date
    # and fails with a diff when it is not, which is what a CI job wants.
    TARGET docs
    # Build `docs` as part of the default build.
    ALL
)

That is the whole of it: what to render, where to write it and which files to read are in cmake2doc.toml, said once. The targets run cmake2doc from the current source directory, which is where it looks for that file.

The module is documented with cmake2doc's own tags, so it also serves as a worked example.

In CI

A pre-commit hook:

repos:
  - repo: https://github.com/segoon/cmake2doc
    rev: v0.1.0
    hooks:
      - id: cmake2doc-check
        args: [--template, reference.md.jinja, --output, docs/reference.md, .]

cmake2doc-check fails when the documentation is out of date and shows what differs; cmake2doc regenerates it instead, so the commit picks it up.

A GitHub Action:

- uses: segoon/cmake2doc@v0.1.0
  with:
    args: --check --template reference.md.jinja --output docs/reference.md .

Advanced features

Writing templates

Templates receive six lists:

  • symbols — every function() and macro(), documented or not
  • variables — every cache entry a user can set: option() and set(... CACHE ...), parsed
  • targets — every add_library(), add_executable(), add_test() and add_custom_target() call, with its own name and kind, so a template wanting a table of them does not have to parse args itself
  • commands — every command call (option(), set(), …), including calls nested in a function() body or an if() block
  • groups — every @defgroup, in the order they were defined, each with a name, a title and a description
  • files — the @file comment blocks, each with the doc of the block

They are unfiltered on purpose: the documented filter drops the entries that carry no comment, public drops the ones marked @internal, and only_command selects the commands you actually document.

Each entry is a dict with:

Key Description
name Function, macro or command name.
doc Parsed comment: .description, .brief, .group, .deprecated, .internal, .args, .options, .params, .multi_params, .returns, .sections, .warnings, and the .of_kind(kind) method.
group Shorthand for doc.group, i.e. the @ingroup value or None.
pretty Symbol rendered via function.md.jinja; for commands, the plain description.
comments The raw comment lines, dedented.
comments_line Line the comment block starts on, or 0 when there is none.
type_ Symbols: 'function' or 'macro'. Variables: the cache type, BOOL, PATH, FILEPATH, STRING or INTERNAL.
signature Symbols only: what the code itself accepts, as signature.accepts.arg, .option, .param, .multiparam and .return. Each is a list of names, or None where the code does not say.
args Commands and targets: the raw argument list, e.g. ['FOO', '"desc"', 'ON'].
command Variables: 'option' or 'set'. Targets: 'add_library', 'add_executable', 'add_test' or 'add_custom_target'.
kind Targets only: 'library', 'executable', 'test' or 'custom target', derived from command.
default Variables only: the value the entry holds unless the user overrides it.
docstring Variables only: the help string the command itself gives, which is what cmake-gui shows.
choices Variables only: the values set_property(CACHE … PROPERTY STRINGS …) restricts the entry to, or None.
advanced Variables only: whether mark_as_advanced() hides it from the ordinary user.
filepath, line, location Where the symbol was found.

Each parameter in doc.args / doc.options / doc.params / doc.multi_params / doc.returns has .name, .description, .required, .kind, .line, and .type_ and .default from @type and @default. Each entry of doc.sections has .kind — the tag that opened it, without the @.text, .name, .line and .label, which is what the tag says a template should call it.

Groups

@ingroup puts a symbol in a group; @defgroup, in a comment block that documents nothing else, gives that group a title and a description:

# @defgroup build Build targets
#
# What gets built, and what is left out.

They arrive as groups, in the order they were defined, so a template writes the whole document without naming a single group:

{% for group in groups %}
## {{ group.title }}

{{ group.description }}

{{ render(symbols | documented | only_group(group.name)) }}
{% endfor %}

Once any group is defined, an @ingroup naming one that is not is reported — until then @ingroup is a bare label, which is how it worked before, and nothing is checked.

Build options

option(NAME "help" ON) and set(NAME value CACHE TYPE "help") declare the same thing in a different order, so variables gives both of them one shape:

| Option | Description | Default |
|--------|-------------|---------|
{%- for v in variables | only_group('build') %}
| `{{ v.name }}` | {{ v.docstring | md_escape }} | `{{ v.default }}` |
{%- endfor %}

A set() that writes no cache entry is a local variable rather than something to configure, so it is not in the list; it is still in commands. A set_property(CACHE … PROPERTY STRINGS …) in the same file fills choices.

The same is true of targets: an add_library() call is also in commands, promoted rather than removed, exactly as option()/set(... CACHE ...) are promoted into variables. add_custom_command() is not promoted — it names no target of its own, only an OUTPUT file or an existing TARGET — so it stays a plain command.

Filters

Filter Purpose
unquote Strip surrounding double quotes from a CMake argument.
escape Quote a value containing $ so it does not read as a variable reference.
md_escape Escape | and \ so a string is safe inside a Markdown table cell.
oneline Join CMake line continuations.
only_command(name) Keep only commands with the given name.
only_group(name) Keep only entries in the given @ingroup (use None for ungrouped).
documented Keep only entries that carry a doc comment.
public Drop the entries marked @internal.
anchor The anchor a Markdown heading holding the given text gets.
symbol_link(symbols) Link a name to its own section when symbols defines it, else leave it as written.
render Concatenate the pretty field of a collection.

The built-in templates

Three templates ship with cmake2doc, and --list-templates names them.

reference.md.jinja is a whole document: a table of contents, every documented function and macro laid out by @defgroup, and a table of the build options. A project that wants documentation without writing a template needs only:

cmake2doc --template reference.md.jinja --output docs/reference.md .

reference.rst.jinja is the same document in reStructuredText, for a project whose documentation is built with Sphinx. It uses only directives docutils itself understands — code, note, warning, admonition, list-table — so the output parses with or without Sphinx, and .. contents:: leaves the table of contents to the renderer. For Sphinx's CMake domain (.. cmake:command::, and the cross-references that come with it) see examples/sphinx/, which is a template rather than a built-in because the domain is an extension a site either installs or does not.

function.md.jinja renders a single symbol, and is what fills symbol.pretty; put a file of that name in a --template-dir (or the working directory) to change how every symbol is rendered. It also works as a whole document, listing every documented symbol and nothing else.

symbol.pretty is Markdown, in every run: function.md.jinja is what fills it, and the name of that template is not configurable. A template that emits anything else — the built-in reStructuredText one included — has to lay symbols out from doc.args, doc.params and the rest itself. Read reference.rst.jinja for how.

Tags of your own

The vocabulary above is what cmake2doc means by a tag; what a project wants to record — an owner, a rationale, a ticket — is its own business. Declare it in the config file:

[tags]
author = { label = "Author:" }
rationale = { text = "block", label = "Why:" }
ticket = { takes_name = true, label = "Ticket:" }

and @author is a tag like any other: recognised rather than reported, rendered by the built-in template as > **Author:** …, and reachable from a template of your own as doc.of_kind('author').

Setting Meaning
text paragraph, the default, ends at a blank line as @note does; block runs to the next tag, so the text may span paragraphs.
takes_name Whether a name follows the tag, as after @ingroup. It arrives as the section's .name.
label What a template calls it. Defaults to the tag's own name.

A declared tag opens a section, which is where anything a template has to render lives. A flag such as @internal or a field such as @ingroup cannot be declared this way: those write to a field of the parsed comment, and one of your own devising would have nowhere to be written.

JSON

--json writes the same model a template is given:

{
  "schema_version": 1,
  "symbols": [{"name": "example_add_library", "doc": {"brief": ""}}],
  "variables": [], "targets": [], "commands": [], "groups": [], "files": []
}

schema_version is bumped when a field disappears or changes meaning, never when one is added, so a consumer must ignore the fields it does not know.

Development

See docs/DEVELOPMENT.md: the workflow, how the modules fit together, and how to add a tag.

Prior art

Tool How it compares
Doxygen Where the @tag vocabulary comes from, down to a paragraph tag ending at a blank line. It has no CMake parser.
CMinx The other CMake documentation generator. It derives signatures from the grammar as cmake2doc does, and targets a Sphinx site: reStructuredText is what it emits, and the docstrings are written in it. cmake2doc renders through templates, so it emits either — but a project already built with Sphinx will find CMinx the closer fit.
CMake's own Sphinx domain Where the #[==[.rst: comment style comes from, and how CMake's own modules are documented.
terraform-docs, helm-docs The same problem for another declarative language: a typed table of inputs, injection into an existing README, a config file, a pre-commit hook.
rustdoc Doc examples that are checked rather than trusted, and missing_docs — here @example and --require-docs.
shdoc The same shape of problem for shell: a dynamic language whose interface is only stated in comments.
godoc No @tag vocabulary at all — a doc comment is just the prose directly above a declaration, taken as-is. It relies on the language having no equivalent of cmake_parse_arguments() to drift from; CMake's own argument parsing is exactly what a doc comment can fall out of sync with, which is why cmake2doc checks it.
JSDoc, TypeDoc @param-style tags close to cmake2doc's own, and TypeScript's type system catches a caller passing the wrong type. Neither checks that a documented parameter is one the function actually accepts, which is the gap cmake2doc's checking pass closes for CMake's own untyped, string-based argument parsing.

License

Apache License 2.0 — see LICENSE and NOTICE. cmake2doc started life as a set of scripts inside the userver framework.

About

Documentation generator for cmake projects

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages