1"""
2Zephyr Extension
3################
4
5Copyright (c) 2023 The Linux Foundation
6SPDX-License-Identifier: Apache-2.0
7
8Introduction
9============
10
11This extension adds a new ``zephyr`` domain for handling the documentation of various entities
12specific to the Zephyr RTOS project (ex. code samples).
13
14Directives
15----------
16
17- ``zephyr:code-sample::`` - Defines a code sample.
18  The directive takes an ID as the main argument, and accepts ``:name:`` (human-readable short name
19  of the sample) and ``:relevant-api:`` (a space separated list of Doxygen group(s) for APIs the
20  code sample is a good showcase of) as options.
21  The content of the directive is used as the description of the code sample.
22
23  Example:
24
25  ```
26  .. zephyr:code-sample:: blinky
27     :name: Blinky
28     :relevant-api: gpio_interface
29
30     Blink an LED forever using the GPIO API.
31 ```
32
33Roles
34-----
35
36- ``:zephyr:code-sample:`` - References a code sample.
37  The role takes the ID of the code sample as the argument. The role renders as a link to the code
38  sample, and the link text is the name of the code sample (or a custom text if an explicit name is
39  provided).
40
41  Example:
42
43  ```
44  Check out :zephyr:code-sample:`sample-foo` for an example of how to use the foo API. You may
45  also be interested in :zephyr:code-sample:`this one <sample-bar>`.
46  ```
47
48"""
49from typing import Any, Dict, Iterator, List, Tuple
50
51from breathe.directives.content_block import DoxygenGroupDirective
52from docutils import nodes
53from docutils.nodes import Node
54from docutils.parsers.rst import Directive, directives
55from sphinx import addnodes
56from sphinx.domains import Domain, ObjType
57from sphinx.roles import XRefRole
58from sphinx.transforms import SphinxTransform
59from sphinx.transforms.post_transforms import SphinxPostTransform
60from sphinx.util import logging
61from sphinx.util.nodes import NodeMatcher, make_refnode
62
63__version__ = "0.1.0"
64
65logger = logging.getLogger(__name__)
66
67
68class CodeSampleNode(nodes.Element):
69    pass
70
71
72class RelatedCodeSamplesNode(nodes.Element):
73    pass
74
75
76class ConvertCodeSampleNode(SphinxTransform):
77    default_priority = 100
78
79    def apply(self):
80        matcher = NodeMatcher(CodeSampleNode)
81        for node in self.document.traverse(matcher):
82            self.convert_node(node)
83
84    def convert_node(self, node):
85        """
86        Transforms a `CodeSampleNode` into a `nodes.section` named after the code sample name.
87
88        Moves all sibling nodes that are after the `CodeSampleNode` in the documement under this new
89        section.
90        """
91        parent = node.parent
92        siblings_to_move = []
93        if parent is not None:
94            index = parent.index(node)
95            siblings_to_move = parent.children[index + 1 :]
96
97            # Create a new section
98            new_section = nodes.section(ids=[node["id"]])
99            new_section += nodes.title(text=node["name"])
100
101            # Move existing content from the custom node to the new section
102            new_section.extend(node.children)
103
104            # Move the sibling nodes under the new section
105            new_section.extend(siblings_to_move)
106
107            # Replace the custom node with the new section
108            node.replace_self(new_section)
109
110            # Remove the moved siblings from their original parent
111            for sibling in siblings_to_move:
112                parent.remove(sibling)
113
114
115class ProcessRelatedCodeSamplesNode(SphinxPostTransform):
116    default_priority = 5  # before ReferencesResolver
117
118    def run(self, **kwargs: Any) -> None:
119        matcher = NodeMatcher(RelatedCodeSamplesNode)
120        for node in self.document.traverse(matcher):
121            id = node["id"]  # the ID of the node is the name of the doxygen group for which we
122            # want to list related code samples
123
124            code_samples = self.env.domaindata["zephyr"]["code-samples"].values()
125            # Filter out code samples that don't reference this doxygen group
126            code_samples = [
127                code_sample for code_sample in code_samples if id in code_sample["relevant-api"]
128            ]
129
130            if len(code_samples) > 0:
131                admonition = nodes.admonition()
132                admonition += nodes.title(text="Related code samples")
133                admonition["collapsible"] = "" # used by sphinx-immaterial theme
134                admonition["classes"].append("related-code-samples")
135                admonition["classes"].append("dropdown") # used by sphinx-togglebutton extension
136                sample_ul = nodes.bullet_list()
137                for code_sample in sorted(code_samples, key=lambda x: x["name"]):
138                    sample_para = nodes.paragraph()
139                    sample_xref = addnodes.pending_xref(
140                        "",
141                        refdomain="zephyr",
142                        reftype="code-sample",
143                        reftarget=code_sample["id"],
144                        refwarn=True,
145                    )
146                    sample_xref += nodes.inline(text=code_sample["name"])
147                    sample_para += sample_xref
148                    sample_para += nodes.inline(text=" - ")
149                    sample_para += nodes.inline(text=code_sample["description"].astext())
150                    sample_li = nodes.list_item()
151                    sample_li += sample_para
152                    sample_ul += sample_li
153                admonition += sample_ul
154
155                # replace node with the newly created admonition
156                node.replace_self(admonition)
157            else:
158                # remove node if there are no code samples
159                node.replace_self([])
160
161
162class CodeSampleDirective(Directive):
163    """
164    A directive for creating a code sample node in the Zephyr documentation.
165    """
166
167    required_arguments = 1  # ID
168    optional_arguments = 0
169    option_spec = {"name": directives.unchanged, "relevant-api": directives.unchanged}
170    has_content = True
171
172    def run(self):
173        code_sample_id = self.arguments[0]
174        env = self.state.document.settings.env
175        code_samples = env.domaindata["zephyr"]["code-samples"]
176
177        if code_sample_id in code_samples:
178            logger.warning(
179                f"Code sample {code_sample_id} already exists. "
180                f"Other instance in {code_samples[code_sample_id]['docname']}",
181                location=(env.docname, self.lineno),
182            )
183
184        name = self.options.get("name", code_sample_id)
185        relevant_api_list = self.options.get("relevant-api", "").split()
186
187        # Create a node for description and populate it with parsed content
188        description_node = nodes.container(ids=[f"{code_sample_id}-description"])
189        self.state.nested_parse(self.content, self.content_offset, description_node)
190
191        code_sample = {
192            "id": code_sample_id,
193            "name": name,
194            "description": description_node,
195            "relevant-api": relevant_api_list,
196            "docname": env.docname,
197        }
198
199        domain = env.get_domain("zephyr")
200        domain.add_code_sample(code_sample)
201
202        # Create an instance of the custom node
203        code_sample_node = CodeSampleNode()
204        code_sample_node["id"] = code_sample_id
205        code_sample_node["name"] = name
206
207        return [code_sample_node]
208
209
210class ZephyrDomain(Domain):
211    """Zephyr domain"""
212
213    name = "zephyr"
214    label = "Zephyr Project"
215
216    roles = {
217        "code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
218    }
219
220    directives = {"code-sample": CodeSampleDirective}
221
222    object_types: Dict[str, ObjType] = {
223        "code-sample": ObjType("code sample", "code-sample"),
224    }
225
226    initial_data: Dict[str, Any] = {"code-samples": {}}
227
228    def clear_doc(self, docname: str) -> None:
229        self.data["code-samples"] = {
230            sample_id: sample_data
231            for sample_id, sample_data in self.data["code-samples"].items()
232            if sample_data["docname"] != docname
233        }
234
235    def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None:
236        self.data["code-samples"].update(otherdata["code-samples"])
237
238    def get_objects(self):
239        for _, code_sample in self.data["code-samples"].items():
240            yield (
241                code_sample["name"],
242                code_sample["name"],
243                "code sample",
244                code_sample["docname"],
245                code_sample["id"],
246                1,
247            )
248
249    # used by Sphinx Immaterial theme
250    def get_object_synopses(self) -> Iterator[Tuple[Tuple[str, str], str]]:
251        for _, code_sample in self.data["code-samples"].items():
252            yield (
253                (code_sample["docname"], code_sample["id"]),
254                code_sample["description"].astext(),
255            )
256
257    def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
258        if type == "code-sample":
259            code_sample_info = self.data["code-samples"].get(target)
260            if code_sample_info:
261                if not node.get("refexplicit"):
262                    contnode = [nodes.Text(code_sample_info["name"])]
263
264                return make_refnode(
265                    builder,
266                    fromdocname,
267                    code_sample_info["docname"],
268                    code_sample_info["id"],
269                    contnode,
270                    code_sample_info["description"].astext(),
271                )
272
273    def add_code_sample(self, code_sample):
274        self.data["code-samples"][code_sample["id"]] = code_sample
275
276
277class CustomDoxygenGroupDirective(DoxygenGroupDirective):
278    """Monkey patch for Breathe's DoxygenGroupDirective."""
279
280    def run(self) -> List[Node]:
281        nodes = super().run()
282        return [RelatedCodeSamplesNode(id=self.arguments[0]), *nodes]
283
284
285def setup(app):
286    app.add_domain(ZephyrDomain)
287
288    app.add_transform(ConvertCodeSampleNode)
289    app.add_post_transform(ProcessRelatedCodeSamplesNode)
290
291    # monkey-patching of Breathe's DoxygenGroupDirective
292    app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True)
293
294    return {
295        "version": __version__,
296        "parallel_read_safe": True,
297        "parallel_write_safe": True,
298    }
299