1"""
2Zephyr Extension
3################
4
5Copyright (c) 2023-2025 The Linux Foundation
6SPDX-License-Identifier: Apache-2.0
7
8This extension adds a new ``zephyr`` domain for handling the documentation of various entities
9specific to the Zephyr RTOS project (ex. code samples).
10
11Directives
12----------
13
14- ``zephyr:code-sample::`` - Defines a code sample.
15- ``zephyr:code-sample-category::`` - Defines a category for grouping code samples.
16- ``zephyr:code-sample-listing::`` - Shows a listing of code samples found in a given category.
17- ``zephyr:board-catalog::`` - Shows a listing of boards supported by Zephyr.
18- ``zephyr:board::`` - Flags a document as being the documentation page for a board.
19
20Roles
21-----
22
23- ``:zephyr:code-sample:`` - References a code sample.
24- ``:zephyr:code-sample-category:`` - References a code sample category.
25- ``:zephyr:board:`` - References a board.
26
27"""
28
29import json
30import re
31import sys
32from collections.abc import Iterator
33from os import path
34from pathlib import Path
35from typing import Any
36
37from anytree import ChildResolverError, Node, PreOrderIter, Resolver, search
38from docutils import nodes
39from docutils.parsers.rst import directives, roles
40from docutils.statemachine import StringList
41from sphinx import addnodes
42from sphinx.application import Sphinx
43from sphinx.domains import Domain, ObjType
44from sphinx.environment import BuildEnvironment
45from sphinx.roles import XRefRole
46from sphinx.transforms import SphinxTransform
47from sphinx.transforms.post_transforms import SphinxPostTransform
48from sphinx.util import logging
49from sphinx.util.docutils import SphinxDirective, switch_source_input
50from sphinx.util.nodes import NodeMatcher, make_refnode
51from sphinx.util.parsing import nested_parse_to_nodes
52from sphinx.util.template import SphinxRenderer
53
54from zephyr.doxybridge import DoxygenGroupDirective
55from zephyr.gh_utils import gh_link_get_url
56
57__version__ = "0.2.0"
58
59
60sys.path.insert(0, str(Path(__file__).parents[4] / "scripts/dts/python-devicetree/src"))
61sys.path.insert(0, str(Path(__file__).parents[3] / "_scripts"))
62
63from gen_boards_catalog import get_catalog
64
65ZEPHYR_BASE = Path(__file__).parents[4]
66TEMPLATES_DIR = Path(__file__).parent / "templates"
67RESOURCES_DIR = Path(__file__).parent / "static"
68
69# Load and parse binding types from text file
70BINDINGS_TXT_PATH = ZEPHYR_BASE / "dts" / "bindings" / "binding-types.txt"
71ACRONYM_PATTERN = re.compile(r'([a-zA-Z0-9-]+)\s*\((.*?)\)')
72BINDING_TYPE_TO_DOCUTILS_NODE = {}
73
74
75def parse_text_with_acronyms(text):
76    """Parse text that may contain acronyms into a list of nodes."""
77    result = nodes.inline()
78    last_end = 0
79
80    for match in ACRONYM_PATTERN.finditer(text):
81        # Add any text before the acronym
82        if match.start() > last_end:
83            result += nodes.Text(text[last_end : match.start()])
84
85        # Add the acronym
86        abbr, explanation = match.groups()
87        result += nodes.abbreviation(abbr, abbr, explanation=explanation)
88        last_end = match.end()
89
90    # Add any remaining text
91    if last_end < len(text):
92        result += nodes.Text(text[last_end:])
93
94    return result
95
96
97with open(BINDINGS_TXT_PATH) as f:
98    for line in f:
99        line = line.strip()
100        if not line or line.startswith('#'):
101            continue
102
103        key, value = line.split('\t', 1)
104        BINDING_TYPE_TO_DOCUTILS_NODE[key] = parse_text_with_acronyms(value)
105
106logger = logging.getLogger(__name__)
107
108
109class CodeSampleNode(nodes.Element):
110    pass
111
112
113class RelatedCodeSamplesNode(nodes.Element):
114    pass
115
116
117class CodeSampleCategoryNode(nodes.Element):
118    pass
119
120
121class CodeSampleListingNode(nodes.Element):
122    pass
123
124
125class BoardNode(nodes.Element):
126    pass
127
128
129class ConvertCodeSampleNode(SphinxTransform):
130    default_priority = 100
131
132    def apply(self):
133        matcher = NodeMatcher(CodeSampleNode)
134        for node in self.document.traverse(matcher):
135            self.convert_node(node)
136
137    def convert_node(self, node):
138        """
139        Transforms a `CodeSampleNode` into a `nodes.section` named after the code sample name.
140
141        Moves all sibling nodes that are after the `CodeSampleNode` in the document under this new
142        section.
143
144        Adds a "See Also" section at the end with links to all relevant APIs as per the samples's
145        `relevant-api` attribute.
146        """
147        parent = node.parent
148        siblings_to_move = []
149        if parent is not None:
150            index = parent.index(node)
151            siblings_to_move = parent.children[index + 1 :]
152
153            # Create a new section
154            new_section = nodes.section(ids=[node["id"]])
155            new_section += nodes.title(text=node["name"])
156
157            gh_link = gh_link_get_url(self.app, self.env.docname)
158            gh_link_button = nodes.raw(
159                "",
160                f"""
161                <a href="{gh_link}/.." class="btn btn-info fa fa-github"
162                    target="_blank" style="text-align: center;">
163                    Browse source code on GitHub
164                </a>
165                """,
166                format="html",
167            )
168            new_section += nodes.paragraph("", "", gh_link_button)
169
170            # Move the sibling nodes under the new section
171            new_section.extend(siblings_to_move)
172
173            # Replace the custom node with the new section
174            node.replace_self(new_section)
175
176            # Remove the moved siblings from their original parent
177            for sibling in siblings_to_move:
178                parent.remove(sibling)
179
180            # Add a "See Also" section at the end with links to relevant APIs
181            if node["relevant-api"]:
182                see_also_section = nodes.section(ids=["see-also"])
183                see_also_section += nodes.title(text="See also")
184
185                for api in node["relevant-api"]:
186                    desc_node = addnodes.desc()
187                    desc_node["domain"] = "c"
188                    desc_node["objtype"] = "group"
189
190                    title_signode = addnodes.desc_signature()
191                    api_xref = addnodes.pending_xref(
192                        "",
193                        refdomain="c",
194                        reftype="group",
195                        reftarget=api,
196                        refwarn=True,
197                    )
198                    api_xref += nodes.Text(api)
199                    title_signode += api_xref
200                    desc_node += title_signode
201                    see_also_section += desc_node
202
203                new_section += see_also_section
204
205            # Set sample description as the meta description of the document for improved SEO
206            meta_description = nodes.meta()
207            meta_description["name"] = "description"
208            meta_description["content"] = node.children[0].astext()
209            node.document += meta_description
210
211            # Similarly, add a node with JSON-LD markup (only renders in HTML output) describing
212            # the code sample.
213            json_ld = nodes.raw(
214                "",
215                f"""<script type="application/ld+json">
216                {json.dumps({
217                    "@context": "http://schema.org",
218                    "@type": "SoftwareSourceCode",
219                    "name": node['name'],
220                    "description": node.children[0].astext(),
221                    "codeSampleType": "full",
222                    "codeRepository": gh_link_get_url(self.app, self.env.docname)
223                })}
224                </script>""",
225                format="html",
226            )
227            node.document += json_ld
228
229
230class ConvertCodeSampleCategoryNode(SphinxTransform):
231    default_priority = 100
232
233    def apply(self):
234        matcher = NodeMatcher(CodeSampleCategoryNode)
235        for node in self.document.traverse(matcher):
236            self.convert_node(node)
237
238    def convert_node(self, node):
239        # move all the siblings of the category node underneath the section it contains
240        parent = node.parent
241        siblings_to_move = []
242        if parent is not None:
243            index = parent.index(node)
244            siblings_to_move = parent.children[index + 1 :]
245
246            node.children[0].extend(siblings_to_move)
247            for sibling in siblings_to_move:
248                parent.remove(sibling)
249
250        # note document as needing toc patching
251        self.document["needs_toc_patch"] = True
252
253        # finally, replace the category node with the section it contains
254        node.replace_self(node.children[0])
255
256
257class ConvertBoardNode(SphinxTransform):
258    default_priority = 100
259
260    def apply(self):
261        matcher = NodeMatcher(BoardNode)
262        for node in self.document.traverse(matcher):
263            self.convert_node(node)
264
265    def convert_node(self, node):
266        parent = node.parent
267        siblings_to_move = []
268        if parent is not None:
269            index = parent.index(node)
270            siblings_to_move = parent.children[index + 1 :]
271
272            new_section = nodes.section(ids=[node["id"]])
273            new_section += nodes.title(text=node["full_name"])
274
275            # create a sidebar with all the board details
276            sidebar = nodes.sidebar(classes=["board-overview"])
277            new_section += sidebar
278            sidebar += nodes.title(text="Board Overview")
279
280            if node["image"] is not None:
281                figure = nodes.figure()
282                # set a scale of 100% to indicate we want a link to the full-size image
283                figure += nodes.image(uri=f"/{node['image']}", scale=100)
284                figure += nodes.caption(text=node["full_name"])
285                sidebar += figure
286
287            field_list = nodes.field_list()
288            sidebar += field_list
289
290            details = [
291                ("Name", nodes.literal(text=node["id"])),
292                ("Vendor", node["vendor"]),
293                ("Architecture", ", ".join(node["archs"])),
294                ("SoC", ", ".join(node["socs"])),
295            ]
296
297            for property_name, value in details:
298                field = nodes.field()
299                field_name = nodes.field_name(text=property_name)
300                field_body = nodes.field_body()
301                if isinstance(value, nodes.Node):
302                    field_body += value
303                else:
304                    field_body += nodes.paragraph(text=value)
305                field += field_name
306                field += field_body
307                field_list += field
308
309            gh_link = gh_link_get_url(self.app, self.env.docname)
310            gh_link_button = nodes.raw(
311                "",
312                f"""
313                <div id="board-github-link">
314                    <a href="{gh_link}/../.." class="btn btn-info fa fa-github"
315                        target="_blank">
316                        Browse board sources
317                    </a>
318                </div>
319                """,
320                format="html",
321            )
322            sidebar += gh_link_button
323
324            # Move the sibling nodes under the new section
325            new_section.extend(siblings_to_move)
326
327            # Replace the custom node with the new section
328            node.replace_self(new_section)
329
330            # Remove the moved siblings from their original parent
331            for sibling in siblings_to_move:
332                parent.remove(sibling)
333
334
335class CodeSampleCategoriesTocPatching(SphinxPostTransform):
336    default_priority = 5  # needs to run *before* ReferencesResolver
337
338    def output_sample_categories_list_items(self, tree, container: nodes.Node):
339        list_item = nodes.list_item()
340        compact_paragraph = addnodes.compact_paragraph()
341        # find docname for tree.category["id"]
342        docname = self.env.domaindata["zephyr"]["code-samples-categories"][tree.category["id"]][
343            "docname"
344        ]
345        reference = nodes.reference(
346            "",
347            "",
348            *[nodes.Text(tree.category["name"])],
349            internal=True,
350            refuri=docname,
351            anchorname="",
352            classes=["category-link"],
353        )
354        compact_paragraph += reference
355        list_item += compact_paragraph
356
357        sorted_children = sorted(tree.children, key=lambda x: x.category["name"])
358
359        # add bullet list for children (if there are any, i.e. there are subcategories or at least
360        # one code sample in the category)
361        if sorted_children or any(
362            code_sample.get("category") == tree.category["id"]
363            for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
364        ):
365            bullet_list = nodes.bullet_list()
366            for child in sorted_children:
367                self.output_sample_categories_list_items(child, bullet_list)
368
369            for code_sample in sorted(
370                [
371                    code_sample
372                    for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
373                    if code_sample.get("category") == tree.category["id"]
374                ],
375                key=lambda x: x["name"].casefold(),
376            ):
377                li = nodes.list_item()
378                sample_xref = nodes.reference(
379                    "",
380                    "",
381                    *[nodes.Text(code_sample["name"])],
382                    internal=True,
383                    refuri=code_sample["docname"],
384                    anchorname="",
385                    classes=["code-sample-link"],
386                )
387                sample_xref["reftitle"] = code_sample["description"].astext()
388                compact_paragraph = addnodes.compact_paragraph()
389                compact_paragraph += sample_xref
390                li += compact_paragraph
391                bullet_list += li
392
393            list_item += bullet_list
394
395        container += list_item
396
397    def run(self, **kwargs: Any) -> None:
398        if not self.document.get("needs_toc_patch"):
399            return
400
401        code_samples_categories_tree = self.env.domaindata["zephyr"]["code-samples-categories-tree"]
402
403        category = search.find(
404            code_samples_categories_tree,
405            lambda node: hasattr(node, "category") and node.category["docname"] == self.env.docname,
406        )
407
408        bullet_list = nodes.bullet_list()
409        self.output_sample_categories_list_items(category, bullet_list)
410
411        self.env.tocs[self.env.docname] = bullet_list
412
413
414class ProcessCodeSampleListingNode(SphinxPostTransform):
415    default_priority = 5  # needs to run *before* ReferencesResolver
416
417    def output_sample_categories_sections(self, tree, container: nodes.Node, show_titles=False):
418        if show_titles:
419            section = nodes.section(ids=[tree.category["id"]])
420
421            link = make_refnode(
422                self.env.app.builder,
423                self.env.docname,
424                tree.category["docname"],
425                targetid=None,
426                child=nodes.Text(tree.category["name"]),
427            )
428            title = nodes.title("", "", link)
429            section += title
430            container += section
431        else:
432            section = container
433
434        # list samples from this category
435        list = create_code_sample_list(
436            [
437                code_sample
438                for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
439                if code_sample.get("category") == tree.category["id"]
440            ]
441        )
442        section += list
443
444        sorted_children = sorted(tree.children, key=lambda x: x.name)
445        for child in sorted_children:
446            self.output_sample_categories_sections(child, section, show_titles=True)
447
448    def run(self, **kwargs: Any) -> None:
449        matcher = NodeMatcher(CodeSampleListingNode)
450
451        for node in self.document.traverse(matcher):
452            self.env.domaindata["zephyr"]["has_code_sample_listing"][self.env.docname] = True
453
454            code_samples_categories = self.env.domaindata["zephyr"]["code-samples-categories"]
455            code_samples_categories_tree = self.env.domaindata["zephyr"][
456                "code-samples-categories-tree"
457            ]
458
459            container = nodes.container()
460            container["classes"].append("code-sample-listing")
461
462            if self.env.app.builder.format == "html" and node["live-search"]:
463                search_input = nodes.raw(
464                    "",
465                    """
466                    <div class="cs-search-bar">
467                      <input type="text" class="cs-search-input"
468                             placeholder="Filter code samples..." onkeyup="filterSamples(this)">
469                      <i class="fa fa-search"></i>
470                    </div>
471                    """,
472                    format="html",
473                )
474                container += search_input
475
476            for category in node["categories"]:
477                if category not in code_samples_categories:
478                    logger.error(
479                        f"Category {category} not found in code samples categories",
480                        location=(self.env.docname, node.line),
481                    )
482                    continue
483
484                category_node = search.find(
485                    code_samples_categories_tree,
486                    lambda node, category=category: hasattr(node, "category")
487                    and node.category["id"] == category,
488                )
489                self.output_sample_categories_sections(category_node, container)
490
491            node.replace_self(container)
492
493
494def create_code_sample_list(code_samples):
495    """
496    Creates a bullet list (`nodes.bullet_list`) of code samples from a list of code samples.
497
498    The list is alphabetically sorted (case-insensitive) by the code sample name.
499    """
500
501    ul = nodes.bullet_list(classes=["code-sample-list"])
502
503    for code_sample in sorted(code_samples, key=lambda x: x["name"].casefold()):
504        li = nodes.list_item()
505
506        sample_xref = addnodes.pending_xref(
507            "",
508            refdomain="zephyr",
509            reftype="code-sample",
510            reftarget=code_sample["id"],
511            refwarn=True,
512        )
513        sample_xref += nodes.Text(code_sample["name"])
514        li += nodes.inline("", "", sample_xref, classes=["code-sample-name"])
515
516        li += nodes.inline(
517            text=code_sample["description"].astext(),
518            classes=["code-sample-description"],
519        )
520
521        ul += li
522
523    return ul
524
525
526class ProcessRelatedCodeSamplesNode(SphinxPostTransform):
527    default_priority = 5  # before ReferencesResolver
528
529    def run(self, **kwargs: Any) -> None:
530        matcher = NodeMatcher(RelatedCodeSamplesNode)
531        for node in self.document.traverse(matcher):
532            id = node["id"]  # the ID of the node is the name of the doxygen group for which we
533            # want to list related code samples
534
535            code_samples = self.env.domaindata["zephyr"]["code-samples"].values()
536            # Filter out code samples that don't reference this doxygen group
537            code_samples = [
538                code_sample for code_sample in code_samples if id in code_sample["relevant-api"]
539            ]
540
541            if len(code_samples) > 0:
542                admonition = nodes.admonition()
543                admonition += nodes.title(text="Related code samples")
544                admonition["classes"].append("related-code-samples")
545                admonition["classes"].append("dropdown")  # used by sphinx-togglebutton extension
546                admonition["classes"].append("toggle-shown")  # show the content by default
547
548                sample_list = create_code_sample_list(code_samples)
549                admonition += sample_list
550
551                # replace node with the newly created admonition
552                node.replace_self(admonition)
553            else:
554                # remove node if there are no code samples
555                node.replace_self([])
556
557
558class CodeSampleDirective(SphinxDirective):
559    """
560    A directive for creating a code sample node in the Zephyr documentation.
561    """
562
563    required_arguments = 1  # ID
564    optional_arguments = 0
565    option_spec = {"name": directives.unchanged, "relevant-api": directives.unchanged}
566    has_content = True
567
568    def run(self):
569        code_sample_id = self.arguments[0]
570        env = self.state.document.settings.env
571        code_samples = env.domaindata["zephyr"]["code-samples"]
572
573        if code_sample_id in code_samples:
574            logger.warning(
575                f"Code sample {code_sample_id} already exists. "
576                f"Other instance in {code_samples[code_sample_id]['docname']}",
577                location=(env.docname, self.lineno),
578            )
579
580        name = self.options.get("name", code_sample_id)
581        relevant_api_list = self.options.get("relevant-api", "").split()
582
583        # Create a node for description and populate it with parsed content
584        description_node = nodes.container(ids=[f"{code_sample_id}-description"])
585        self.state.nested_parse(self.content, self.content_offset, description_node)
586
587        code_sample = {
588            "id": code_sample_id,
589            "name": name,
590            "description": description_node,
591            "relevant-api": relevant_api_list,
592            "docname": env.docname,
593        }
594
595        domain = env.get_domain("zephyr")
596        domain.add_code_sample(code_sample)
597
598        # Create an instance of the custom node
599        code_sample_node = CodeSampleNode()
600        code_sample_node["id"] = code_sample_id
601        code_sample_node["name"] = name
602        code_sample_node["relevant-api"] = relevant_api_list
603        code_sample_node += description_node
604
605        return [code_sample_node]
606
607
608class CodeSampleCategoryDirective(SphinxDirective):
609    required_arguments = 1  # Category ID
610    optional_arguments = 0
611    option_spec = {
612        "name": directives.unchanged,
613        "show-listing": directives.flag,
614        "live-search": directives.flag,
615        "glob": directives.unchanged,
616    }
617    has_content = True  # Category description
618    final_argument_whitespace = True
619
620    def run(self):
621        env = self.state.document.settings.env
622        id = self.arguments[0]
623        name = self.options.get("name", id)
624
625        category_node = CodeSampleCategoryNode()
626        category_node["id"] = id
627        category_node["name"] = name
628        category_node["docname"] = env.docname
629
630        description_node = self.parse_content_to_nodes()
631        category_node["description"] = description_node
632
633        code_sample_category = {
634            "docname": env.docname,
635            "id": id,
636            "name": name,
637        }
638
639        # Add the category to the domain
640        domain = env.get_domain("zephyr")
641        domain.add_code_sample_category(code_sample_category)
642
643        # Fake a toctree directive to ensure the code-sample-category directive implicitly acts as
644        # a toctree and correctly mounts whatever relevant documents under it in the global toc
645        lines = [
646            name,
647            "#" * len(name),
648            "",
649            ".. toctree::",
650            "   :titlesonly:",
651            "   :glob:",
652            "   :hidden:",
653            "   :maxdepth: 1",
654            "",
655            f"   {self.options['glob']}" if "glob" in self.options else "   */*",
656            "",
657        ]
658        stringlist = StringList(lines, source=env.docname)
659
660        with switch_source_input(self.state, stringlist):
661            parsed_section = nested_parse_to_nodes(self.state, stringlist)[0]
662
663        category_node += parsed_section
664
665        parsed_section += description_node
666
667        if "show-listing" in self.options:
668            listing_node = CodeSampleListingNode()
669            listing_node["categories"] = [id]
670            listing_node["live-search"] = "live-search" in self.options
671            parsed_section += listing_node
672
673        return [category_node]
674
675
676class CodeSampleListingDirective(SphinxDirective):
677    has_content = False
678    required_arguments = 0
679    optional_arguments = 0
680    option_spec = {
681        "categories": directives.unchanged_required,
682        "live-search": directives.flag,
683    }
684
685    def run(self):
686        code_sample_listing_node = CodeSampleListingNode()
687        code_sample_listing_node["categories"] = self.options.get("categories").split()
688        code_sample_listing_node["live-search"] = "live-search" in self.options
689
690        return [code_sample_listing_node]
691
692
693class BoardDirective(SphinxDirective):
694    has_content = False
695    required_arguments = 1
696    optional_arguments = 0
697
698    def run(self):
699        # board_name is passed as the directive argument
700        board_name = self.arguments[0]
701
702        boards = self.env.domaindata["zephyr"]["boards"]
703        vendors = self.env.domaindata["zephyr"]["vendors"]
704
705        if board_name not in boards:
706            logger.warning(
707                f"Board {board_name} does not seem to be a valid board name.",
708                location=(self.env.docname, self.lineno),
709            )
710            return []
711        elif "docname" in boards[board_name]:
712            logger.warning(
713                f"Board {board_name} is already documented in {boards[board_name]['docname']}.",
714                location=(self.env.docname, self.lineno),
715            )
716            return []
717        else:
718            self.env.domaindata["zephyr"]["has_board"][self.env.docname] = True
719            board = boards[board_name]
720            # flag board in the domain data as now having a documentation page so that it can be
721            # cross-referenced etc.
722            board["docname"] = self.env.docname
723
724            board_node = BoardNode(id=board_name)
725            board_node["full_name"] = board["full_name"]
726            board_node["vendor"] = vendors.get(board["vendor"], board["vendor"])
727            board_node["supported_features"] = board["supported_features"]
728            board_node["archs"] = board["archs"]
729            board_node["socs"] = board["socs"]
730            board_node["image"] = board["image"]
731            return [board_node]
732
733
734class BoardCatalogDirective(SphinxDirective):
735    has_content = False
736    required_arguments = 0
737    optional_arguments = 0
738
739    def run(self):
740        if self.env.app.builder.format == "html":
741            self.env.domaindata["zephyr"]["has_board_catalog"][self.env.docname] = True
742
743            domain_data = self.env.domaindata["zephyr"]
744            renderer = SphinxRenderer([TEMPLATES_DIR])
745            rendered = renderer.render(
746                "board-catalog.html",
747                {
748                    "boards": domain_data["boards"],
749                    "vendors": domain_data["vendors"],
750                    "socs": domain_data["socs"],
751                    "hw_features_present": self.env.app.config.zephyr_generate_hw_features,
752                },
753            )
754            return [nodes.raw("", rendered, format="html")]
755        else:
756            return [nodes.paragraph(text="Board catalog is only available in HTML.")]
757
758
759class BoardSupportedHardwareDirective(SphinxDirective):
760    """A directive for showing the supported hardware features of a board."""
761
762    has_content = False
763    required_arguments = 0
764    optional_arguments = 0
765
766    def run(self):
767        env = self.env
768        docname = env.docname
769
770        matcher = NodeMatcher(BoardNode)
771        board_nodes = list(self.state.document.traverse(matcher))
772        if not board_nodes:
773            logger.warning(
774                "board-supported-hw directive must be used in a board documentation page.",
775                location=(docname, self.lineno),
776            )
777            return []
778
779        board_node = board_nodes[0]
780        supported_features = board_node["supported_features"]
781        result_nodes = []
782
783        paragraph = nodes.paragraph()
784        paragraph += nodes.Text("The ")
785        paragraph += nodes.literal(text=board_node["id"])
786        paragraph += nodes.Text(" board supports the hardware features listed below.")
787        result_nodes.append(paragraph)
788
789        if not env.app.config.zephyr_generate_hw_features:
790            note = nodes.admonition()
791            note += nodes.title(text="Note")
792            note["classes"].append("warning")
793            note += nodes.paragraph(
794                text="The list of supported hardware features was not generated. Run a full "
795                "documentation build for the required metadata to be available."
796            )
797            result_nodes.append(note)
798            return result_nodes
799
800        html_contents = """<div class="legend admonition">
801  <dl class="supported-hardware field-list">
802    <dt>
803      <span class="location-chip onchip">on-chip</span> /
804      <span class="location-chip onboard">on-board</span>
805    </dt>
806    <dd>
807      Feature integrated in the SoC / present on the board.
808    </dd>
809    <dt>
810      <span class="count okay-count">2</span> /
811      <span class="count disabled-count">2</span>
812    </dt>
813    <dd>
814      Number of instances that are enabled / disabled. <br/>
815      Click on the label to see the first instance of this feature in the board/SoC DTS files.
816    </dd>
817    <dt>
818      <code class="docutils literal notranslate"><span class="pre">vnd,foo</span></code>
819    </dt>
820    <dd>
821      Compatible string for the Devicetree binding matching the feature. <br/>
822      Click on the link to view the binding documentation.
823    </dd>
824  </dl>
825</div>"""
826        result_nodes.append(nodes.raw("", html_contents, format="html"))
827
828        for target, features in sorted(supported_features.items()):
829            if not features:
830                continue
831
832            target_heading = nodes.section(ids=[f"{board_node['id']}-{target}-hw-features"])
833            heading = nodes.title()
834            heading += nodes.literal(text=target)
835            heading += nodes.Text(" target")
836            target_heading += heading
837            result_nodes.append(target_heading)
838
839            table = nodes.table(classes=["colwidths-given", "hardware-features"])
840            tgroup = nodes.tgroup(cols=4)
841
842            tgroup += nodes.colspec(colwidth=15, classes=["type"])
843            tgroup += nodes.colspec(colwidth=12, classes=["location"])
844            tgroup += nodes.colspec(colwidth=53, classes=["description"])
845            tgroup += nodes.colspec(colwidth=20, classes=["compatible"])
846
847            thead = nodes.thead()
848            row = nodes.row()
849            headers = ["Type", "Location", "Description", "Compatible"]
850            for header in headers:
851                entry = nodes.entry(classes=[header.lower()])
852                entry += nodes.paragraph(text=header)
853                row += entry
854            thead += row
855            tgroup += thead
856
857            tbody = nodes.tbody()
858
859            def feature_sort_key(feature):
860                # Put "CPU" first. Later updates might also give priority to features
861                # like "sensor"s, for example.
862                if feature == "cpu":
863                    return (0, feature)
864                return (1, feature)
865
866            sorted_features = sorted(features.keys(), key=feature_sort_key)
867
868            for feature in sorted_features:
869                items = list(features[feature].items())
870                num_items = len(items)
871
872                for i, (key, value) in enumerate(items):
873                    row = nodes.row()
874
875                    # TYPE column
876                    if i == 0:
877                        type_entry = nodes.entry(morerows=num_items - 1, classes=["type"])
878                        type_entry += nodes.paragraph(
879                            "",
880                            "",
881                            BINDING_TYPE_TO_DOCUTILS_NODE.get(
882                                feature, nodes.Text(feature)
883                            ).deepcopy(),
884                        )
885                        row += type_entry
886
887                    # LOCATION column
888                    location_entry = nodes.entry(classes=["location"])
889                    location_para = nodes.paragraph()
890
891                    if "board" in value["locations"]:
892                        location_chip = nodes.inline(
893                            classes=["location-chip", "onboard"],
894                            text="on-board",
895                        )
896                        location_para += location_chip
897                    elif "soc" in value["locations"]:
898                        location_chip = nodes.inline(
899                            classes=["location-chip", "onchip"],
900                            text="on-chip",
901                        )
902                        location_para += location_chip
903
904                    location_entry += location_para
905                    row += location_entry
906
907                    # DESCRIPTION column
908                    desc_entry = nodes.entry(classes=["description"])
909                    desc_para = nodes.paragraph(classes=["status"])
910                    desc_para += nodes.Text(value["description"])
911
912                    # Add count indicators for okay and not-okay instances
913                    okay_nodes = value.get("okay_nodes", [])
914                    disabled_nodes = value.get("disabled_nodes", [])
915
916                    role_fn, _ = roles.role(
917                        "zephyr_file", self.state_machine.language, self.lineno, self.state.reporter
918                    )
919
920                    def create_count_indicator(nodes_list, class_type, role_function=role_fn):
921                        if not nodes_list:
922                            return None
923
924                        count = len(nodes_list)
925
926                        if role_function is None:
927                            return nodes.inline(
928                                classes=["count", f"{class_type}-count"], text=str(count)
929                            )
930
931                        # Create a reference to the first node in the list
932                        first_node = nodes_list[0]
933                        file_ref = f"{count} <{first_node['filename']}#L{first_node['lineno']}>"
934
935                        role_nodes, _ = role_function(
936                            "zephyr_file", file_ref, file_ref, self.lineno, self.state.inliner
937                        )
938
939                        count_node = role_nodes[0]
940                        count_node["classes"] = ["count", f"{class_type}-count"]
941
942                        return count_node
943
944                    desc_para += create_count_indicator(okay_nodes, "okay")
945                    desc_para += create_count_indicator(disabled_nodes, "disabled")
946
947                    desc_entry += desc_para
948                    row += desc_entry
949
950                    # COMPATIBLE column
951                    compatible_entry = nodes.entry(classes=["compatible"])
952                    xref = addnodes.pending_xref(
953                        "",
954                        refdomain="std",
955                        reftype="dtcompatible",
956                        reftarget=key,
957                        refexplicit=False,
958                        refwarn=(not value.get("custom_binding", False)),
959                    )
960                    xref += nodes.literal(text=key)
961                    compatible_entry += nodes.paragraph("", "", xref)
962                    row += compatible_entry
963
964                    tbody += row
965
966            tgroup += tbody
967            table += tgroup
968            result_nodes.append(table)
969
970        return result_nodes
971
972
973class ZephyrDomain(Domain):
974    """Zephyr domain"""
975
976    name = "zephyr"
977    label = "Zephyr"
978
979    roles = {
980        "code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
981        "code-sample-category": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
982        "board": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
983    }
984
985    directives = {
986        "code-sample": CodeSampleDirective,
987        "code-sample-listing": CodeSampleListingDirective,
988        "code-sample-category": CodeSampleCategoryDirective,
989        "board-catalog": BoardCatalogDirective,
990        "board": BoardDirective,
991        "board-supported-hw": BoardSupportedHardwareDirective,
992    }
993
994    object_types: dict[str, ObjType] = {
995        "code-sample": ObjType("code sample", "code-sample"),
996        "code-sample-category": ObjType("code sample category", "code-sample-category"),
997        "board": ObjType("board", "board"),
998    }
999
1000    initial_data: dict[str, Any] = {
1001        "code-samples": {},  # id -> code sample data
1002        "code-samples-categories": {},  # id -> code sample category data
1003        "code-samples-categories-tree": Node("samples"),
1004        # keep track of documents containing special directives
1005        "has_code_sample_listing": {},  # docname -> bool
1006        "has_board_catalog": {},  # docname -> bool
1007        "has_board": {},  # docname -> bool
1008    }
1009
1010    def clear_doc(self, docname: str) -> None:
1011        self.data["code-samples"] = {
1012            sample_id: sample_data
1013            for sample_id, sample_data in self.data["code-samples"].items()
1014            if sample_data["docname"] != docname
1015        }
1016
1017        self.data["code-samples-categories"] = {
1018            category_id: category_data
1019            for category_id, category_data in self.data["code-samples-categories"].items()
1020            if category_data["docname"] != docname
1021        }
1022
1023        # TODO clean up the anytree as well
1024
1025        self.data["has_code_sample_listing"].pop(docname, None)
1026        self.data["has_board_catalog"].pop(docname, None)
1027        self.data["has_board"].pop(docname, None)
1028
1029    def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
1030        self.data["code-samples"].update(otherdata["code-samples"])
1031        self.data["code-samples-categories"].update(otherdata["code-samples-categories"])
1032
1033        # self.data["boards"] contains all the boards right from builder-inited time, but it still
1034        # potentially needs merging since a board's docname property is set by BoardDirective to
1035        # indicate the board is documented in a specific document.
1036        for board_name, board in otherdata["boards"].items():
1037            if "docname" in board:
1038                self.data["boards"][board_name]["docname"] = board["docname"]
1039
1040        # merge category trees by adding all the categories found in the "other" tree that to
1041        # self tree
1042        other_tree = otherdata["code-samples-categories-tree"]
1043        categories = [n for n in PreOrderIter(other_tree) if hasattr(n, "category")]
1044        for category in categories:
1045            category_path = f"/{'/'.join(n.name for n in category.path)}"
1046            self.add_category_to_tree(
1047                category_path,
1048                category.category["id"],
1049                category.category["name"],
1050                category.category["docname"],
1051            )
1052
1053        for docname in docnames:
1054            self.data["has_code_sample_listing"][docname] = otherdata[
1055                "has_code_sample_listing"
1056            ].get(docname, False)
1057            self.data["has_board_catalog"][docname] = otherdata["has_board_catalog"].get(
1058                docname, False
1059            )
1060            self.data["has_board"][docname] = otherdata["has_board"].get(docname, False)
1061
1062    def get_objects(self):
1063        for _, code_sample in self.data["code-samples"].items():
1064            yield (
1065                code_sample["id"],
1066                code_sample["name"],
1067                "code-sample",
1068                code_sample["docname"],
1069                code_sample["id"],
1070                1,
1071            )
1072
1073        for _, code_sample_category in self.data["code-samples-categories"].items():
1074            yield (
1075                code_sample_category["id"],
1076                code_sample_category["name"],
1077                "code-sample-category",
1078                code_sample_category["docname"],
1079                code_sample_category["id"],
1080                1,
1081            )
1082
1083        for _, board in self.data["boards"].items():
1084            # only boards that do have a documentation page are to be considered as valid objects
1085            if "docname" in board:
1086                yield (
1087                    board["name"],
1088                    board["full_name"],
1089                    "board",
1090                    board["docname"],
1091                    board["name"],
1092                    1,
1093                )
1094
1095    # used by Sphinx Immaterial theme
1096    def get_object_synopses(self) -> Iterator[tuple[tuple[str, str], str]]:
1097        for _, code_sample in self.data["code-samples"].items():
1098            yield (
1099                (code_sample["docname"], code_sample["id"]),
1100                code_sample["description"].astext(),
1101            )
1102
1103    def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
1104        if type == "code-sample":
1105            elem = self.data["code-samples"].get(target)
1106        elif type == "code-sample-category":
1107            elem = self.data["code-samples-categories"].get(target)
1108        elif type == "board":
1109            elem = self.data["boards"].get(target)
1110        else:
1111            return
1112
1113        if elem and "docname" in elem:
1114            if not node.get("refexplicit"):
1115                contnode = [nodes.Text(elem["name"] if type != "board" else elem["full_name"])]
1116
1117            return make_refnode(
1118                builder,
1119                fromdocname,
1120                elem["docname"],
1121                elem["id"] if type != "board" else elem["name"],
1122                contnode,
1123                elem["description"].astext() if type == "code-sample" else None,
1124            )
1125
1126    def add_code_sample(self, code_sample):
1127        self.data["code-samples"][code_sample["id"]] = code_sample
1128
1129    def add_code_sample_category(self, code_sample_category):
1130        self.data["code-samples-categories"][code_sample_category["id"]] = code_sample_category
1131        self.add_category_to_tree(
1132            path.dirname(code_sample_category["docname"]),
1133            code_sample_category["id"],
1134            code_sample_category["name"],
1135            code_sample_category["docname"],
1136        )
1137
1138    def add_category_to_tree(
1139        self, category_path: str, category_id: str, category_name: str, docname: str
1140    ) -> Node:
1141        resolver = Resolver("name")
1142        tree = self.data["code-samples-categories-tree"]
1143
1144        if not category_path.startswith("/"):
1145            category_path = "/" + category_path
1146
1147        # node either already exists (and we update it to make it a category node), or we need to
1148        # create it
1149        try:
1150            node = resolver.get(tree, category_path)
1151            if hasattr(node, "category") and node.category["id"] != category_id:
1152                raise ValueError(
1153                    f"Can't add code sample category {category_id} as category "
1154                    f"{node.category['id']} is already defined in {node.category['docname']}. "
1155                    "You may only have one category per path."
1156                )
1157        except ChildResolverError as e:
1158            path_of_last_existing_node = f"/{'/'.join(n.name for n in e.node.path)}"
1159            common_path = path.commonpath([path_of_last_existing_node, category_path])
1160            remaining_path = path.relpath(category_path, common_path)
1161
1162            # Add missing nodes under the last existing node
1163            for node_name in remaining_path.split("/"):
1164                e.node = Node(node_name, parent=e.node)
1165
1166            node = e.node
1167
1168        node.category = {"id": category_id, "name": category_name, "docname": docname}
1169
1170        return tree
1171
1172
1173class CustomDoxygenGroupDirective(DoxygenGroupDirective):
1174    """Monkey patch for Breathe's DoxygenGroupDirective."""
1175
1176    def run(self) -> list[Node]:
1177        nodes = super().run()
1178
1179        if self.config.zephyr_breathe_insert_related_samples:
1180            return [*nodes, RelatedCodeSamplesNode(id=self.arguments[0])]
1181        else:
1182            return nodes
1183
1184
1185def compute_sample_categories_hierarchy(app: Sphinx, env: BuildEnvironment) -> None:
1186    domain = env.get_domain("zephyr")
1187    code_samples = domain.data["code-samples"]
1188
1189    category_tree = env.domaindata["zephyr"]["code-samples-categories-tree"]
1190    resolver = Resolver("name")
1191    for code_sample in code_samples.values():
1192        try:
1193            # Try to get the node at the specified path
1194            node = resolver.get(category_tree, "/" + path.dirname(code_sample["docname"]))
1195        except ChildResolverError as e:
1196            # starting with e.node and up, find the first node that has a category
1197            node = e.node
1198            while not hasattr(node, "category"):
1199                node = node.parent
1200            code_sample["category"] = node.category["id"]
1201
1202
1203def install_static_assets_as_needed(
1204    app: Sphinx, pagename: str, templatename: str, context: dict[str, Any], doctree: nodes.Node
1205) -> None:
1206    if app.env.domaindata["zephyr"]["has_code_sample_listing"].get(pagename, False):
1207        app.add_css_file("css/codesample-livesearch.css")
1208        app.add_js_file("js/codesample-livesearch.js")
1209
1210    if app.env.domaindata["zephyr"]["has_board_catalog"].get(pagename, False):
1211        app.add_css_file("css/board-catalog.css")
1212        app.add_js_file("js/board-catalog.js")
1213
1214    if app.env.domaindata["zephyr"]["has_board"].get(pagename, False):
1215        app.add_css_file("css/board.css")
1216        app.add_js_file("js/board.js")
1217
1218
1219def load_board_catalog_into_domain(app: Sphinx) -> None:
1220    board_catalog = get_catalog(
1221        generate_hw_features=(
1222            app.builder.format == "html" and app.config.zephyr_generate_hw_features
1223        )
1224    )
1225    app.env.domaindata["zephyr"]["boards"] = board_catalog["boards"]
1226    app.env.domaindata["zephyr"]["vendors"] = board_catalog["vendors"]
1227    app.env.domaindata["zephyr"]["socs"] = board_catalog["socs"]
1228
1229
1230def setup(app):
1231    app.add_config_value("zephyr_breathe_insert_related_samples", False, "env")
1232    app.add_config_value("zephyr_generate_hw_features", False, "env")
1233
1234    app.add_domain(ZephyrDomain)
1235
1236    app.add_transform(ConvertCodeSampleNode)
1237    app.add_transform(ConvertCodeSampleCategoryNode)
1238    app.add_transform(ConvertBoardNode)
1239
1240    app.add_post_transform(ProcessCodeSampleListingNode)
1241    app.add_post_transform(CodeSampleCategoriesTocPatching)
1242    app.add_post_transform(ProcessRelatedCodeSamplesNode)
1243
1244    app.connect(
1245        "builder-inited",
1246        (lambda app: app.config.html_static_path.append(RESOURCES_DIR.as_posix())),
1247    )
1248    app.connect("builder-inited", load_board_catalog_into_domain)
1249
1250    app.connect("html-page-context", install_static_assets_as_needed)
1251    app.connect("env-updated", compute_sample_categories_hierarchy)
1252
1253    # monkey-patching of the DoxygenGroupDirective
1254    app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True)
1255
1256    return {
1257        "version": __version__,
1258        "parallel_read_safe": True,
1259        "parallel_write_safe": True,
1260    }
1261