From 4d5d27e3e55d2bcb3f0e2a92fe6f552ab8644004 Mon Sep 17 00:00:00 2001 From: Frantisek Hrbata Date: Fri, 12 Sep 2025 15:24:33 +0200 Subject: [PATCH] feat(cmakev2/docs): add esp_docs_cmakev2_extension sphinx extension Add a Sphinx extension that introduces a new `cmakev2` domain with multiple directives, allowing for the automatic extraction of documentation comments from CMake files and their inclusion in the Sphinx-generated documentation. Directives: - `cmakev2:include`: The included CMake file is processed for documentation comments within the `#[[api` and `#]]` marks, which should contain valid reStructuredText markup. - `cmakev2:function`: Creates a CMake function node. All function nodes are sorted by name and placed into the `_cmakev2_functions` section. - `cmakev2:macro`: Creates a CMake macro node. All macro nodes are sorted by name and placed into the `_cmakev2_macros` section. - `cmakev2:variable`: Describes a CMake variable node. All variable nodes are sorted by name and placed into the `_cmakev2_variables` section. Each node can be referenced with `` :cmakev2:ref:`` ``, where the node name is the function, macro, or variable name as used in the related directive. Example: CMake file: ``` #[[api .. cmakev2:function:: idf_flash_binary #]] ``` This function can be referenced with `` :cmakev2:ref:`idf_flash_binary` `` and will be placed in the `.. _cmakev2_functions:` section. The extension is currently located in esp-idf, but in the future, we should consider moving it to esp-docs. Signed-off-by: Frantisek Hrbata --- tools/cmakev2/esp_docs_cmakev2_extension.py | 172 ++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tools/cmakev2/esp_docs_cmakev2_extension.py diff --git a/tools/cmakev2/esp_docs_cmakev2_extension.py b/tools/cmakev2/esp_docs_cmakev2_extension.py new file mode 100644 index 0000000000..4fae4b6acd --- /dev/null +++ b/tools/cmakev2/esp_docs_cmakev2_extension.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +import re + +from docutils import nodes # type: ignore +from docutils.statemachine import StringList # type: ignore +from sphinx import addnodes +from sphinx.addnodes import pending_xref +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.directives import ObjDescT +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain +from sphinx.environment import BuildEnvironment +from sphinx.roles import XRefRole +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import make_refnode +from sphinx.util.nodes import nested_parse_with_titles + +RST_COMMENT_RE = re.compile(r'(?sm)^#\[\[api\n(.*?)\n#\]\]') + + +class CMakeV2IncludeDirective(SphinxDirective): + required_arguments = 1 + + def run(self) -> list: + env = self.env + rel_filename, filename = env.relfn2path(self.arguments[0]) + + # Track the file as a dependency so Sphinx rebuilds if it changes. + env.note_dependency(rel_filename) + + # Read CMakeLists.txt file content. + with open(filename, encoding='utf-8') as f: + raw_content = f.read() + + # Extract all RST comments from the file. + rst_comments = RST_COMMENT_RE.findall(raw_content) + + parsed_nodes = [] + + for rst_comment in rst_comments: + # Temporary node to hold parsed comment. + node = nodes.section() + node.document = self.state.document + + string_list = StringList(rst_comment.splitlines(), source='') + nested_parse_with_titles(self.state, string_list, node) + + parsed_nodes.extend(node.children) + + if not hasattr(env, 'cmakev2_comment_nodes'): + env.cmakev2_comment_nodes = {} + + env.cmakev2_comment_nodes.setdefault(env.docname, []).extend(parsed_nodes) + + return [] + + +class CMakeV2Description(ObjectDescription): + def handle_signature(self, sig: str, signode: addnodes.desc_signature) -> ObjDescT: + signode += addnodes.desc_name(text=sig) + return sig + + def add_target_and_index(self, name: ObjDescT, sig: str, signode: addnodes.desc_signature) -> None: + # Register object target only (no index) + labelid = f'cmakev2-{self.cmakev2_type}-{sig}' + signode['ids'].append(labelid) + self.env.domaindata['cmakev2']['xrefs'][sig] = (self.env.docname, labelid) + + def run(self) -> list[nodes.Node]: + index, node = super().run() + # Add CMakeV2 custom attributes used when function, macro, and variable + # nodes are added to the doctree and sorted. + node['cmakev2-type'] = self.cmakev2_type + node['cmakev2-name'] = self.arguments[0] + return [node] + + +class CMakeV2VariableDirective(CMakeV2Description): + cmakev2_type = 'variable' + + +class CMakeV2FunctionDirective(CMakeV2Description): + cmakev2_type = 'function' + + +class CMakeV2MacroDirective(CMakeV2Description): + cmakev2_type = 'macro' + + +class CMakeV2Domain(Domain): + name = 'cmakev2' + label = 'ESP-IDF build system v2' + + roles = { + 'ref': XRefRole(), + } + + directives = { + 'variable': CMakeV2VariableDirective, + 'function': CMakeV2FunctionDirective, + 'macro': CMakeV2MacroDirective, + 'include': CMakeV2IncludeDirective, + } + + initial_data: dict = { + 'xrefs': {}, + } + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: nodes.Element, + ) -> nodes.Element | None: + xref = self.data['xrefs'].get(target) + if xref: + todocname, labelid = xref + + # Extract clean text from contnode and wrap in non-literal node + text = contnode.astext() + newnode = nodes.emphasis(text, text) + return make_refnode(builder, fromdocname, todocname, labelid, newnode) + return None + + +def insert_cmakev2_comment_nodes(app: Sphinx, doctree: nodes.document) -> None: + env = app.builder.env + # Get nodes parsed and created by CMakeV2IncludeDirective. + pending = getattr(env, 'cmakev2_comment_nodes', {}).get(env.docname, []) + + # Split nodes parsed in CMakeV2IncludeDirective into buckets based on their + # type. + buckets: dict = {'function': [], 'macro': [], 'variable': []} + for node in pending: + buckets[node['cmakev2-type']].append(node) + + # Sort functions, macros, and variables based on their names. + buckets_sorted = {} + for bucket_name, bucket_list in buckets.items(): + buckets_sorted[bucket_name] = sorted(bucket_list, key=lambda x: x['cmakev2-name']) + + # Traverse through the doctree to locate the target section labels (such as + # _cmakev2_variables) and insert the nodes sorted into the appropriate + # section. + for section in doctree.traverse(nodes.section): + if 'cmakev2-variables' in section['ids']: + section.extend(buckets_sorted['variable']) + elif 'cmakev2-functions' in section['ids']: + section.extend(buckets_sorted['function']) + elif 'cmakev2-macros' in section['ids']: + section.extend(buckets_sorted['macro']) + + +def setup(app: Sphinx) -> dict: + app.add_domain(CMakeV2Domain) + # The nodes generated with CMakeV2IncludeDirective need to be placed into the + # proper sections (functions, macros, variables), but the section labels + # are not known during the directive run() method. Place the nodes into + # section in the doctree-read event. + app.connect('doctree-read', insert_cmakev2_comment_nodes) + + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + }