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/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." } ] }, 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 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__":