From a9918d5708353a6dcdd8d5ae5c83f3923a78ac80 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 29 Oct 2026 16:35:21 +0100 Subject: [PATCH 1/3] fix: @sa and @arg keep their text The commands reference documents `@sa` as an alias of `@see` and `@arg` as an alias of `@li`, but the doc-comment extractor handled neither. Clang gives each of them its own command ID, which reached the branch that ignores unhandled block commands, so the paragraph after the command disappeared without a warning. We now handle them in the same `case` as `@see` and `@li`. --- src/mrdocs/AST/ExtractDocComment.cpp | 2 + tests/golden/fixtures/javadoc/lists/arg.cpp | 13 ++ tests/golden/fixtures/javadoc/lists/arg.xml | 156 +++++++++++++++ tests/golden/fixtures/javadoc/see/sa.adoc | 99 +++++++++ tests/golden/fixtures/javadoc/see/sa.cpp | 18 ++ tests/golden/fixtures/javadoc/see/sa.xml | 211 ++++++++++++++++++++ tests/golden/fixtures/javadoc/see/sa.yml | 2 + 7 files changed, 501 insertions(+) create mode 100644 tests/golden/fixtures/javadoc/lists/arg.cpp create mode 100644 tests/golden/fixtures/javadoc/lists/arg.xml create mode 100644 tests/golden/fixtures/javadoc/see/sa.adoc create mode 100644 tests/golden/fixtures/javadoc/see/sa.cpp create mode 100644 tests/golden/fixtures/javadoc/see/sa.xml create mode 100644 tests/golden/fixtures/javadoc/see/sa.yml diff --git a/src/mrdocs/AST/ExtractDocComment.cpp b/src/mrdocs/AST/ExtractDocComment.cpp index bb6c15609da..8b87a4d0428 100644 --- a/src/mrdocs/AST/ExtractDocComment.cpp +++ b/src/mrdocs/AST/ExtractDocComment.cpp @@ -1717,6 +1717,7 @@ class DocCommentVisitor return; } case T::KCI_li: + case T::KCI_arg: { if (jd_.Document.empty() || !jd_.Document.back()->isList()) { @@ -1735,6 +1736,7 @@ class DocCommentVisitor return; } case T::KCI_see: + case T::KCI_sa: { auto see = parseBlock(std::in_place_type); jd_.sees.push_back(std::move(see)); diff --git a/tests/golden/fixtures/javadoc/lists/arg.cpp b/tests/golden/fixtures/javadoc/lists/arg.cpp new file mode 100644 index 00000000000..58d1d869293 --- /dev/null +++ b/tests/golden/fixtures/javadoc/lists/arg.cpp @@ -0,0 +1,13 @@ +/** A function + + @arg Point 1 + @arg Point 2 + + Another paragraph. + + @li Point 3 + @arg Point 4 + @li Point 5 + \arg Point 6 +*/ +void f(); diff --git a/tests/golden/fixtures/javadoc/lists/arg.xml b/tests/golden/fixtures/javadoc/lists/arg.xml new file mode 100644 index 00000000000..6617445a02f --- /dev/null +++ b/tests/golden/fixtures/javadoc/lists/arg.xml @@ -0,0 +1,156 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 3WB9tVatMdT8usB7kG91HHDMUYwU + + + + f + f + + + + arg.cpp + arg.cpp + 13 + 1 + + + + + function + 3WB9tVatMdT8usB7kG91HHDMUYwU + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + + + list + + + + + paragraph + + + text + Point 1 + + + + + + + + + paragraph + + + text + Point 2 + + + + + + + unordered + + + paragraph + + + text + Another paragraph. + + + + + list + + + + + paragraph + + + text + Point 3 + + + + + + + + + paragraph + + + text + Point 4 + + + + + + + + + paragraph + + + text + Point 5 + + + + + + + + + paragraph + + + text + Point 6 + + + + + + + unordered + + + + brief + + + text + A function + + + + + + + named + + + identifier + void + + + void + + + normal + + diff --git a/tests/golden/fixtures/javadoc/see/sa.adoc b/tests/golden/fixtures/javadoc/see/sa.adoc new file mode 100644 index 00000000000..face7026d00 --- /dev/null +++ b/tests/golden/fixtures/javadoc/see/sa.adoc @@ -0,0 +1,99 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Namespaces + +[cols="1"] +|=== +| Name +| link:#A[`A`] +|=== + + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#f[`f`] +| A function +| link:#g[`g`] +| Another function +|=== + + +[#A] +== A + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#A-h[`h`] +| A function in a namespace +|=== + + +[#A-h] +== link:#A[A]::h + +A function in a namespace + +=== Synopsis + +Declared in `<sa.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +h(); +---- + +[#f] +== f + +A function + +=== Synopsis + +Declared in `<sa.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +f(); +---- + +=== See Also + +g + +g + +A::h + +g + +link:#g[`g`] + +[#g] +== g + +Another function + +=== Synopsis + +Declared in `<sa.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +g(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/javadoc/see/sa.cpp b/tests/golden/fixtures/javadoc/see/sa.cpp new file mode 100644 index 00000000000..4c012aa5019 --- /dev/null +++ b/tests/golden/fixtures/javadoc/see/sa.cpp @@ -0,0 +1,18 @@ +/** A function + + @see g + @sa g + @sa A::h + \sa g + @sa @ref g +*/ +void f(); + +/** Another function */ +void g(); + +namespace A +{ + /** A function in a namespace */ + void h(); +} diff --git a/tests/golden/fixtures/javadoc/see/sa.xml b/tests/golden/fixtures/javadoc/see/sa.xml new file mode 100644 index 00000000000..5016f8784a0 --- /dev/null +++ b/tests/golden/fixtures/javadoc/see/sa.xml @@ -0,0 +1,211 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 2xwtzebgDk4MeocbdM1v5JoV4Xvj + 3WB9tVatMdT8usB7kG91HHDMUYwU 4An92kkqG8S7Ca73ZvRsDcPWQnS3 + + + + A + A + + + + sa.cpp + sa.cpp + 14 + 1 + + + + namespace + 2xwtzebgDk4MeocbdM1v5JoV4Xvj + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + 2xpyifs7TWj9zhL7iYX9ZhBtVXro + + + + h + h + + + + sa.cpp + sa.cpp + 17 + 5 + + + + + function + 2xpyifs7TWj9zhL7iYX9ZhBtVXro + regular + 2xwtzebgDk4MeocbdM1v5JoV4Xvj + + + brief + + + text + A function in a namespace + + + + + + + named + + + identifier + void + + + void + + + normal + + + f + f + + + + sa.cpp + sa.cpp + 9 + 1 + + + + + function + 3WB9tVatMdT8usB7kG91HHDMUYwU + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + + brief + + + text + A function + + + + + + see + + + text + g + + + + + see + + + text + g + + + + + see + + + text + A::h + + + + + see + + + text + g + + + + + see + + + reference + g + 4An92kkqG8S7Ca73ZvRsDcPWQnS3 + + + + + + + + named + + + identifier + void + + + void + + + normal + + + g + g + + + + sa.cpp + sa.cpp + 12 + 1 + + + + + function + 4An92kkqG8S7Ca73ZvRsDcPWQnS3 + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + + brief + + + text + Another function + + + + + + + named + + + identifier + void + + + void + + + normal + + diff --git a/tests/golden/fixtures/javadoc/see/sa.yml b/tests/golden/fixtures/javadoc/see/sa.yml new file mode 100644 index 00000000000..d5fd5e08fab --- /dev/null +++ b/tests/golden/fixtures/javadoc/see/sa.yml @@ -0,0 +1,2 @@ +generator: [adoc, xml] +multipage: false From 0b74fe018a4ca5a95d576b479610a65ad5892458 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 29 Oct 2026 16:35:21 +0100 Subject: [PATCH 2/3] docs: list only the commands the parser handles in the commands reference The commands registry, from which the commands reference is generated, documented three forms that don't work. `@link ... @endlink` is a verbatim block in Clang's command table that only HeaderDoc's `@/link` closes, so everything after `@link` becomes a code block. `@strong` is not a command, so `@strong word` renders as the text "strong word". And the quoted text in `@ref "text"` is printed literally after the link. We now document none of them, and the Cross-reference entry says to write `@ref ` in place of `@link`. The registry also gains `@dot`, `@msc` and `@startuml`, which the parser already renders as code blocks, and the Footnote Definition and Math Block entries now point to the examples that already show them. --- .../ROOT/partials/commands-registry.json | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/modules/ROOT/partials/commands-registry.json b/docs/modules/ROOT/partials/commands-registry.json index b387061dc6f..6593f9c3f65 100644 --- a/docs/modules/ROOT/partials/commands-registry.json +++ b/docs/modules/ROOT/partials/commands-registry.json @@ -96,14 +96,17 @@ "kind": "Block", "syntaxes": [ "@code[{.lang}] ... @endcode", - "@verbatim ... @endverbatim" + "@verbatim ... @endverbatim", + "@dot ... @enddot", + "@msc ... @endmsc", + "@startuml ... @enduml" ], "partial": "commands/code-block.adoc", "examples": [ "commands/code-block" ], "see_also": [], - "description": "Inserts a block of preformatted code into the documentation.\nEverything between the opening and closing tags is preserved verbatim, including whitespace and indentation.\n\n`@code` and `@verbatim` produce the same output.\nUse `@code` for source code and `@verbatim` for other preformatted text, though MrDocs treats them identically.", + "description": "Inserts a block of preformatted code into the documentation.\nEverything between the opening and closing tags is preserved verbatim, including whitespace and indentation.\n\n`@code` and `@verbatim` produce the same output.\nUse `@code` for source code and `@verbatim` for other preformatted text, though MrDocs treats them identically.\n\n`@dot`, `@msc`, and `@startuml` also produce a code block, tagged with the language `dot`, `msc`, and `plantuml` respectively.", "parameters": [ { "name": "lang", @@ -113,7 +116,7 @@ { "name": "code", "type": "Code", - "description": "The verbatim source between the opening command and `@endcode`/`@endverbatim`." + "description": "The verbatim source between the opening command and its closing command." } ] }, @@ -216,7 +219,9 @@ "[^label]: text" ], "partial": "commands/footnote-definition.adoc", - "examples": [], + "examples": [ + "commands/footnote" + ], "see_also": [ "Footnote Reference" ], @@ -243,7 +248,9 @@ "\\f[ expression \\f]" ], "partial": "commands/math-block.adoc", - "examples": [], + "examples": [ + "commands/math" + ], "see_also": [ "Math" ], @@ -582,7 +589,6 @@ "**text**", "__text__", "@b ", - "@strong ", "text" ], "partial": "commands/strong.adoc", @@ -592,7 +598,7 @@ "see_also": [ "Emphasis" ], - "description": "Renders its content in bold (strong) style.\nUse `**` or `__` around the bold text.\nThe Doxygen commands `@b` and `@strong` bold the single word that follows them.", + "description": "Renders its content in bold (strong) style.\nUse `**` or `__` around the bold text.\nThe Doxygen command `@b` bolds the single word that follows it.", "parameters": [ { "name": "word", @@ -816,8 +822,7 @@ "heading": "Cross-reference", "kind": "Inline", "syntaxes": [ - "@ref [\"text\"]", - "@link ... @endlink" + "@ref " ], "partial": "commands/cross-reference.adoc", "examples": [ @@ -827,17 +832,12 @@ "Link", "See-also" ], - "description": "Creates a cross-reference to another documented symbol.\nThe `@ref` command takes a symbol name (optionally namespace-qualified) and produces a link to that symbol's documentation page.\nQualified names use `::` separators (e.g., `@ref A::f1`, `@ref ::A::f1`).\nOperator names are supported (e.g., `@ref F::operator+`).", + "description": "Creates a cross-reference to another documented symbol.\nThe `@ref` command takes a symbol name (optionally namespace-qualified) and produces a link to that symbol's documentation page.\nQualified names use `::` separators (e.g., `@ref A::f1`, `@ref ::A::f1`).\nOperator names are supported (e.g., `@ref F::operator+`).\n\nDoxygen's `@link text @endlink` is not supported: Clang closes `@link` only at `@/link`, so `@endlink` never ends it and the rest of the comment becomes a code block. Write `@ref ` instead.", "parameters": [ { "name": "name", "type": "Symbol", "description": "Qualified name of the symbol to link to." - }, - { - "name": "text", - "type": "Text", - "description": "Optional display text for the link." } ] }, From 33d988faaec79348a5371123db531668dfd75405 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 29 Oct 2026 16:35:22 +0100 Subject: [PATCH 3/3] ci: check the commands registry against the parser utils/docs/check_commands_registry.py is meant to keep the commands registry in sync with the doc-comment parser, but no CI job ran it, and it had fallen out of step with the registry: it still expected the `node_types` and `preview` fields and the example locations of an earlier layout, and failed with 181 problems, nearly all of them false. We now run it in the utility tests job, with its checks updated to the registry's current shape. It also checked only one direction, that every command the parser handles is documented, which let the registry document `@sa`, `@arg`, `@strong` and `@link` while the parser ignored them. It now also checks that every command a documented syntax names is one the parser handles. --- .github/workflows/ci-utility-tests.yml | 3 + utils/docs/check_commands_registry.py | 389 +++++++++++++++---------- 2 files changed, 242 insertions(+), 150 deletions(-) diff --git a/.github/workflows/ci-utility-tests.yml b/.github/workflows/ci-utility-tests.yml index 04f23fc4bb2..f7343d81528 100644 --- a/.github/workflows/ci-utility-tests.yml +++ b/.github/workflows/ci-utility-tests.yml @@ -79,6 +79,9 @@ jobs: - name: Check bootstrap options partial is up to date run: python3 ./utils/docs/generate_bootstrap_options.py --check + - name: Check commands registry is in sync with the parser + run: python3 ./utils/docs/check_commands_registry.py + # When the Coverage matrix entry was skipped because the develop cache is # warm, fetch develop's LCOV report and re-upload it tagged with the PR's # commit SHA. Codecov sees zero patch lines and unchanged project totals, diff --git a/utils/docs/check_commands_registry.py b/utils/docs/check_commands_registry.py index 64ae6ef45e6..8230432223f 100644 --- a/utils/docs/check_commands_registry.py +++ b/utils/docs/check_commands_registry.py @@ -1,19 +1,32 @@ #!/usr/bin/env python3 """Check that the commands registry stays in sync with the source. -Four checks: +Five checks: 1. Every node type declared in BlockNodes.inc, BlockCommandNodes.inc, - or InlineNodes.inc has a registry entry (or is marked Planned). + or InlineNodes.inc has a registry entry. An entry names its node + type in `node_type`, spelled as the node name plus `Block` or + `Inline` (`Param` in BlockCommandNodes.inc is `ParamBlock`). Planned + entries count: the node exists even if the parser does not yet + produce it. 2. Every parser-dispatched command name from the C++ source appears in some entry's `syntaxes` list. - 3. Every entry carries the prose payload the page needs: a - `description` string for every entry, and a `preview` block - (with `title` and `caption`) for every non-Planned entry. - 4. Every non-Planned entry has at least one example, and each example - resolves to its golden-test mirror under tests/golden/fixtures/ - snippets/, its Antora source under docs/modules/ROOT/examples/, - and its rendered .adoc next to that source. + 3. Every entry carries what commands-registry-extension.js needs to + render it: a `heading`, a `description` string, and a `partial` of + the form commands/.adoc (the extension only generates a + per-entry partial for entries whose `partial` has that shape), with + each slug unique. + 4. Every non-Planned entry has at least one example, and its first + example resolves to the .cpp source and the golden-generated .adoc + output under tests/golden/fixtures/snippets/commands/, the two files + the generated partial includes (the Antora playbook mounts + tests/golden/fixtures/snippets as example$snippets). + 5. Every registry syntax that begins with `@` names a command + the parser dispatches (the reverse of check 2). Only the opening + command of a syntax is checked: in `@code ... @endcode` the closer + `@endcode` is part of the same construct and is not looked up. + Syntaxes that do not begin with `@` (Markdown, HTML tags, the + backslash math spellings) are not covered. Runs in the utility-tests CI step. Reads source files only; does not need mrdocs built. @@ -57,25 +70,26 @@ def parse_inc_file(path): return types -def collect_declared_types(root): - """Return the set of node type names declared in the three .inc files.""" - block_inc = os.path.join( - root, "include/mrdocs/Metadata/DocComment/Block/BlockNodes.inc" - ) - block_cmd_inc = os.path.join( - root, "include/mrdocs/Metadata/DocComment/Block/BlockCommandNodes.inc" - ) - inline_inc = os.path.join( - root, "include/mrdocs/Metadata/DocComment/Inline/InlineNodes.inc" - ) +# Each .inc file with the suffix that turns one of its names into the +# `node_type` spelling used by the registry. The suffix is needed because +# the block and inline node lists both declare `Code` and `Math`. +NODE_INC_FILES = ( + ("include/mrdocs/Metadata/DocComment/Block/BlockNodes.inc", "Block"), + ("include/mrdocs/Metadata/DocComment/Block/BlockCommandNodes.inc", "Block"), + ("include/mrdocs/Metadata/DocComment/Inline/InlineNodes.inc", "Inline"), +) + +def collect_declared_types(root): + """Return the registry `node_type` spelling of every declared node type.""" types = set() - for p in (block_inc, block_cmd_inc, inline_inc): - types.update(parse_inc_file(p)) + for rel_path, suffix in NODE_INC_FILES: + for name in parse_inc_file(os.path.join(root, rel_path)): + types.add(name + suffix) # `Text` holds plain text inside another node; users do not type it as # a command, so it has no registry entry. - types.discard("Text") + types.discard("TextInline") return types @@ -84,6 +98,33 @@ def collect_declared_types(root): # 2. Parse source files for parser-dispatched command strings # --------------------------------------------------------------------------- +# Commands that the registry documents and that ExtractDocComment.cpp +# handles without ever naming them, so the source scans below cannot see +# them and check 5 would wrongly report them. This is not a list of every +# command Clang delivers by comment kind (other verbatim block commands +# also end up as code blocks). Walking Clang's `CommandTraits` at script +# time would require building Clang, so each entry is listed here with the +# code path that handles it. Add an entry only after tracing that path. +# A command the extractor ignores must not be listed, whether Clang knows it +# but no `case` handles it, or Clang does not know it at all (`@strong`). +KIND_HANDLED_COMMANDS = { + # `ParamCommandComment`: `visitNode` -> `visitParam`. + "param", + # `TParamCommandComment`: `visitNode` -> `visitTParam`. + "tparam", + # `VerbatimBlockComment`: `visitNode` -> `visitVerbatimBlock`, whose final + # branch turns a verbatim block without a special name into a + # `CodeBlock` with no language. + "verbatim", + # `InlineCommandComment`: the default branch of `visitInlineCommand` maps + # Clang's render kind through `convertStyle`: bold to `StrongInline` + # for `@b`, monospaced to `CodeInline` for `@c` and `@p`. + "b", + "c", + "p", +} + + def extract_kci_commands(path): """Extract KCI_xxx command names from ExtractDocComment.cpp.""" cmds = set() @@ -99,9 +140,9 @@ def extract_runtime_commands(path): Two places to look: - 1. The customFlagCommands[] array (today: `functionobject`, `functor`). - Reading entries from the array body means adding a new flag command - to the array picks it up here too. + 1. The hand-written entries of the `customFlagCommands[]` array (today: + the `functor` alias). The array's other entries come from + FlagCommands.inc, which `extract_flag_commands` reads. 2. Inline `name == "xxx"` comparisons. In ExtractDocComment.cpp this hits the admonition checks (`name == "tip"`, `"important"`, @@ -127,6 +168,16 @@ def extract_runtime_commands(path): return cmds +def extract_flag_commands(path): + """Extract the lowercase command names from FlagCommands.inc. + + ExtractDocComment.cpp expands each `INFO(PascalName)` entry into a + `customFlagCommands[]` entry spelled in lowercase, so `FunctionObject` + becomes `functionobject`. + """ + return {name.lower() for name in parse_inc_file(path)} + + def extract_krules_syntaxes(path): """Pull inline syntax delimiters from the kRules table.""" with open(path, encoding="utf-8") as f: @@ -173,6 +224,9 @@ def collect_dispatched_commands(root): parse_inlines_path = os.path.join( root, "src/mrdocs/Metadata/Finalizers/DocComment/parseInlines.hpp" ) + flag_inc_path = os.path.join( + root, "include/mrdocs/Metadata/DocComment/FlagCommands.inc" + ) commands = set() @@ -182,18 +236,12 @@ def collect_dispatched_commands(root): # Runtime string checks (customFlagCommands plus inline name == "..." tests). commands.update(extract_runtime_commands(extract_path)) - # Render-kind commands (`@b`, `@strong`, `@c`, `@p`): the default branch - # in visitInlineCommand routes them by Clang's render kind, so there is - # no `KCI_` constant or string literal to grep. Hard-coded here because - # walking CommandTraits at script time would require building Clang. - commands.update({"b", "strong", "c", "p"}) + # Flag commands (`@functionobject`, `@seebelow`, ...) come from the + # FlagCommands.inc X-macro table. + commands.update(extract_flag_commands(flag_inc_path)) - # Clang aliases without their own KCI_ identifier: `@sa` aliases `@see` - # and `@arg` aliases `@li`. Clang routes both to the aliased command's - # KCI_ constant, so the dispatch never mentions them by name. Hard-coded - # for the same reason as above. (`@related` has its own `KCI_related` - # constant; extract_kci_commands finds it without help.) - commands.update({"sa", "arg"}) + # Commands routed by comment kind or render kind rather than by name. + commands.update(KIND_HANDLED_COMMANDS) # kRules carries the inline delimiters (Markdown plus HTML opening tags # like ``, ``, `
`). The Markdown ones (`*`, `**`, `~~`, @@ -238,12 +286,8 @@ def load_registry(root): def registry_node_types(entries): - """Return every `node_types` value across the registry as one set.""" - types = set() - for e in entries: - for nt in e.get("node_types", []): - types.add(nt) - return types + """Return every `node_type` value across the registry as one set.""" + return {e["node_type"] for e in entries if e.get("node_type")} def registry_all_syntaxes(entries): @@ -262,27 +306,30 @@ def syntax_to_command_name(syntax): "@brief" -> "brief" "@code / @endcode" -> "code" + "@param[] " -> "param" "text" -> "" "
" -> "
" "*text*" -> "*text*" (Markdown; left as-is) """ s = syntax.strip() - if s.startswith("@"): - # Strip the `@`; for `@code / @endcode`, keep the first command. - name = s[1:].split()[0].rstrip(",/") - return name + result = s - m = re.match(r"<(\w+)", s) + # Keep the first command only, without its `[...]` or `{...}` suffix. + m = re.match(r"@(\w+)", s) if m: - return f"<{m.group(1)}>" + result = m.group(1) + else: + m = re.match(r"<(\w+)", s) + if m: + result = f"<{m.group(1)}>" - # Markdown delimiter or freeform string: return unchanged. - return s + # Markdown delimiter or freeform string: returned unchanged. + return result # --------------------------------------------------------------------------- -# 4. The four checks +# 4. The checks # --------------------------------------------------------------------------- def check_node_types_covered(declared_types, entries): @@ -327,82 +374,114 @@ def check_commands_covered(dispatched_commands, html_commands, entries): return errors -def check_payload_present(entries, root): - """Check 3: every entry carries the prose payload the page needs. +def check_syntaxes_dispatched(dispatched_commands, entries): + """Check 5: every `@word` registry syntax names a dispatched command. - `description` is required everywhere. `preview.title` and - `preview.caption` are required for non-Planned entries (Planned - entries have no example, so they have no preview block). + The reverse of check 2: a registry entry must not advertise a command + the parser ignores. Only the opening command of each syntax is looked + up, so `@code ... @endcode` is checked as `code`. """ - del root # No filesystem reads here; the registry itself is the source. errors = [] + for e in entries: + for s in e.get("syntaxes", []): + cmd = syntax_to_command_name(s) + if s.strip().startswith("@") and cmd not in dispatched_commands: + errors.append( + f" Registry syntax '{s}' in entry " + f"'{e.get('heading', '?')}' names command '@{cmd}', " + f"which the parser does not dispatch" + ) + return errors + + +def check_payload_present(entries): + """Check 3: every entry carries what the registry extension renders. + + commands-registry-extension.js turns each entry into a partial named + after the slug in its `partial` field, headed by `heading` and + followed by `description`. An entry whose `partial` does not match + commands/.adoc gets no per-entry partial (the reference page + still renders it, through the aggregate), and when two entries share a + slug only one of them gets its per-entry partial. + """ + errors = [] + slugs = {} for e in entries: heading = e.get("heading", "?") + if not e.get("heading"): + errors.append(f" Entry '{heading}' has no `heading`") if not e.get("description"): errors.append(f" Entry '{heading}' has no `description`") - if "Planned" in e.get("status", ""): - continue - preview = e.get("preview") or {} - if not preview.get("title"): - errors.append(f" Entry '{heading}' has no `preview.title`") - if not preview.get("caption"): - errors.append(f" Entry '{heading}' has no `preview.caption`") + errors.extend(partial_errors(e, slugs)) + return errors + + +def partial_errors(entry, slugs): + """Return the problems with an entry's `partial`, recording its slug.""" + heading = entry.get("heading", "?") + errors = [] + m = re.match(r"^commands/(.+)\.adoc$", entry.get("partial") or "") + if not m: + errors.append( + f" Entry '{heading}' has no `partial` of the form " + f"commands/.adoc" + ) + elif m.group(1) in slugs: + errors.append( + f" Entry '{heading}' reuses the partial of entry " + f"'{slugs[m.group(1)]}'" + ) + else: + slugs[m.group(1)] = heading return errors def check_examples_exist(entries, root): - """Check 4: Every non-Planned entry has at least one example, and every - referenced example resolves to a real snippet under - tests/golden/fixtures/snippets/. - - Per spec R9, examples are mandatory for non-Planned entries; missing - examples are an error, not a warning. Each example also has to back - a partial that includes it via `include::example$commands/.cpp[]`, - so the Antora-side source at docs/modules/ROOT/examples/commands/ - must exist too. + """Check 4: every non-Planned entry has at least one example, and its + first example resolves to a .cpp source and a .adoc output under + tests/golden/fixtures/snippets/commands/. + + Examples are mandatory for non-Planned entries; a missing example is an + error, not a warning. The extension includes only the first example of + an entry, as example$snippets/commands/.cpp and .adoc, where + is the example minus a leading commands/ (the Antora playbook + mounts tests/golden/fixtures/snippets as example$snippets). The golden + tests regenerate the .adoc from the .cpp. """ errors = [] - snippets_dir = os.path.join(root, "tests/golden/fixtures/snippets") - antora_examples_dir = os.path.join( - root, "docs/modules/ROOT/examples") for e in entries: - examples = e.get("examples", []) - status = e.get("status", "") - heading = e.get("heading", "?") + if not e.get("planned"): + errors.extend(entry_example_errors(e, root)) + return errors + + +def entry_example_errors(entry, root): + """Return the problems with a non-Planned entry's example.""" + examples = entry.get("examples") or [] + if examples: + errors = example_file_errors( + examples[0], entry.get("heading", "?"), root) + else: + errors = [ + f" Entry '{entry.get('heading', '?')}' has no examples and is " + f"not Planned (at least one example is required)" + ] + return errors - # Planned entries are exempt from the example requirement - if "Planned" in status: - continue - if not examples: +def example_file_errors(example, heading, root): + """Return one error per missing file of an example. + + The path is built the way commands-registry-extension.js builds it. + """ + slug = example.removeprefix("commands/") + errors = [] + for ext in (".cpp", ".adoc"): + rel = f"tests/golden/fixtures/snippets/commands/{slug}{ext}" + if not os.path.isfile(os.path.join(root, rel)): errors.append( - f" Entry '{heading}' has no examples and is not Planned " - f"(at least one example is required)" + f" Example '{example}' for entry '{heading}' is missing {rel}" ) - continue - - for ex in examples: - mirror_cpp = os.path.join(snippets_dir, ex + ".cpp") - antora_cpp = os.path.join(antora_examples_dir, ex + ".cpp") - rendered = os.path.join(antora_examples_dir, ex + ".rendered.adoc") - if not os.path.isfile(mirror_cpp): - errors.append( - f" Example '{ex}' for entry '{heading}' is missing its " - f"golden-test mirror at " - f"tests/golden/fixtures/snippets/{ex}.cpp" - ) - if not os.path.isfile(antora_cpp): - errors.append( - f" Example '{ex}' for entry '{heading}' is missing its " - f"Antora source at docs/modules/ROOT/examples/{ex}.cpp" - ) - if not os.path.isfile(rendered): - errors.append( - f" Example '{ex}' for entry '{heading}' is missing its " - f"rendered output at " - f"docs/modules/ROOT/examples/{ex}.rendered.adoc " - f"(run `python utils/render_snippets.py --update`)" - ) return errors @@ -410,55 +489,65 @@ def check_examples_exist(entries, root): # Main # --------------------------------------------------------------------------- -def main(): - args = parse_args() - root = args.repo_root - - print("Commands registry coverage check") - print(f"Repository root: {root}") - print() +def build_checks(root): + """Return the checks as (title, errors, total, noun) rows. + `total` describes what was examined and `noun` names what the errors + count, so each row prints as `OK (, )`. + """ declared_types = collect_declared_types(root) - dispatched_commands, html_commands = collect_dispatched_commands(root) + dispatched, html_commands = collect_dispatched_commands(root) entries = load_registry(root) + return ( + ("declared node types have a registry entry", + check_node_types_covered(declared_types, entries), + f"{len(declared_types)} types", "gaps"), + ("parser-dispatched commands appear in the registry", + check_commands_covered(dispatched, html_commands, entries), + f"{len(dispatched) + len(html_commands)} commands", "gaps"), + ("entries carry what the registry extension renders", + check_payload_present(entries), + f"{len(entries)} entries", "missing"), + ("example files exist (source and rendered output)", + check_examples_exist(entries, root), + f"{len(entries)} entries", "missing"), + ("registry syntaxes name dispatched commands", + check_syntaxes_dispatched(dispatched, entries), + f"{len(entries)} entries", "undispatched"), + ) - all_errors = [] - print("Check 1: declared node types have a registry entry...") - errs = check_node_types_covered(declared_types, entries) - all_errors.extend(errs) - print(f" {'FAIL' if errs else 'OK'} ({len(declared_types)} types, {len(errs)} gaps)") - for e in errs: - print(e) - - print("Check 2: parser-dispatched commands appear in the registry...") - errs = check_commands_covered(dispatched_commands, html_commands, entries) - all_errors.extend(errs) - cmd_count = len(dispatched_commands) + len(html_commands) - print(f" {'FAIL' if errs else 'OK'} ({cmd_count} commands, {len(errs)} gaps)") - for e in errs: - print(e) - - print("Check 3: entries carry description + preview payload...") - errs = check_payload_present(entries, root) - all_errors.extend(errs) - print(f" {'FAIL' if errs else 'OK'} ({len(entries)} entries, {len(errs)} missing)") - for e in errs: - print(e) - - print("Check 4: example files exist (mirror, source, rendered)...") - errs = check_examples_exist(entries, root) - all_errors.extend(errs) - print(f" {'FAIL' if errs else 'OK'} ({len(entries)} entries, {len(errs)} missing)") - for e in errs: - print(e) +def report_check(number, check): + """Print one check's outcome and return its errors.""" + title, errors, total, noun = check + print(f"Check {number}: {title}...") + print(f" {'FAIL' if errors else 'OK'} ({total}, {len(errors)} {noun})") + for error in errors: + print(error) + return errors + +def report_summary(all_errors): + """Print the final verdict and return the process exit code.""" print() if all_errors: print(f"FAILED: {len(all_errors)} issue(s) found") - return 1 - print("PASSED: all checks passed") - return 0 + result = 1 + else: + print("PASSED: all checks passed") + result = 0 + return result + + +def main(): + args = parse_args() + print("Commands registry coverage check") + print(f"Repository root: {args.repo_root}") + print() + all_errors = [] + for number, check in enumerate(build_checks(args.repo_root), start=1): + all_errors.extend(report_check(number, check)) + return report_summary(all_errors) if __name__ == "__main__":