xref: /openbmc/qemu/docs/sphinx/qapi_domain.py (revision 8799c3641a869bb04387ccc4922a66bbd5fcd67e)
1"""
2QAPI domain extension.
3"""
4
5from __future__ import annotations
6
7from typing import (
8    TYPE_CHECKING,
9    AbstractSet,
10    Any,
11    Dict,
12    Iterable,
13    List,
14    NamedTuple,
15    Optional,
16    Tuple,
17    cast,
18)
19
20from docutils import nodes
21from docutils.parsers.rst import directives
22
23from compat import KeywordNode, SpaceNode
24from sphinx import addnodes
25from sphinx.addnodes import desc_signature, pending_xref
26from sphinx.directives import ObjectDescription
27from sphinx.domains import (
28    Domain,
29    Index,
30    IndexEntry,
31    ObjType,
32)
33from sphinx.locale import _, __
34from sphinx.roles import XRefRole
35from sphinx.util import logging
36from sphinx.util.nodes import make_id, make_refnode
37
38
39if TYPE_CHECKING:
40    from docutils.nodes import Element, Node
41
42    from sphinx.application import Sphinx
43    from sphinx.builders import Builder
44    from sphinx.environment import BuildEnvironment
45    from sphinx.util.typing import OptionSpec
46
47logger = logging.getLogger(__name__)
48
49
50class ObjectEntry(NamedTuple):
51    docname: str
52    node_id: str
53    objtype: str
54    aliased: bool
55
56
57class QAPIXRefRole(XRefRole):
58
59    def process_link(
60        self,
61        env: BuildEnvironment,
62        refnode: Element,
63        has_explicit_title: bool,
64        title: str,
65        target: str,
66    ) -> tuple[str, str]:
67        refnode["qapi:module"] = env.ref_context.get("qapi:module")
68
69        # Cross-references that begin with a tilde adjust the title to
70        # only show the reference without a leading module, even if one
71        # was provided. This is a Sphinx-standard syntax; give it
72        # priority over QAPI-specific type markup below.
73        hide_module = False
74        if target.startswith("~"):
75            hide_module = True
76            target = target[1:]
77
78        # Type names that end with "?" are considered optional
79        # arguments and should be documented as such, but it's not
80        # part of the xref itself.
81        if target.endswith("?"):
82            refnode["qapi:optional"] = True
83            target = target[:-1]
84
85        # Type names wrapped in brackets denote lists. strip the
86        # brackets and remember to add them back later.
87        if target.startswith("[") and target.endswith("]"):
88            refnode["qapi:array"] = True
89            target = target[1:-1]
90
91        if has_explicit_title:
92            # Don't mess with the title at all if it was explicitly set.
93            # Explicit title syntax for references is e.g.
94            # :qapi:type:`target <explicit title>`
95            # and this explicit title overrides everything else here.
96            return title, target
97
98        title = target
99        if hide_module:
100            title = target.split(".")[-1]
101
102        return title, target
103
104
105# Alias for the return of handle_signature(), which is used in several places.
106# (In the Python domain, this is Tuple[str, str] instead.)
107Signature = str
108
109
110class QAPIDescription(ObjectDescription[Signature]):
111    """
112    Generic QAPI description.
113
114    This is meant to be an abstract class, not instantiated
115    directly. This class handles the abstract details of indexing, the
116    TOC, and reference targets for QAPI descriptions.
117    """
118
119    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
120        # Do nothing. The return value here is the "name" of the entity
121        # being documented; for QAPI, this is the same as the
122        # "signature", which is just a name.
123
124        # Normally this method must also populate signode with nodes to
125        # render the signature; here we do nothing instead - the
126        # subclasses will handle this.
127        return sig
128
129    def get_index_text(self, name: Signature) -> Tuple[str, str]:
130        """Return the text for the index entry of the object."""
131
132        # NB: this is used for the global index, not the QAPI index.
133        return ("single", f"{name} (QMP {self.objtype})")
134
135    def add_target_and_index(
136        self, name: Signature, sig: str, signode: desc_signature
137    ) -> None:
138        # name is the return value of handle_signature.
139        # sig is the original, raw text argument to handle_signature.
140        # For QAPI, these are identical, currently.
141
142        assert self.objtype
143
144        # If we're documenting a module, don't include the module as
145        # part of the FQN.
146        modname = ""
147        if self.objtype != "module":
148            modname = self.options.get(
149                "module", self.env.ref_context.get("qapi:module")
150            )
151        fullname = (modname + "." if modname else "") + name
152
153        node_id = make_id(
154            self.env, self.state.document, self.objtype, fullname
155        )
156        signode["ids"].append(node_id)
157
158        self.state.document.note_explicit_target(signode)
159        domain = cast(QAPIDomain, self.env.get_domain("qapi"))
160        domain.note_object(fullname, self.objtype, node_id, location=signode)
161
162        if "no-index-entry" not in self.options:
163            arity, indextext = self.get_index_text(name)
164            assert self.indexnode is not None
165            if indextext:
166                self.indexnode["entries"].append(
167                    (arity, indextext, node_id, "", None)
168                )
169
170    def _object_hierarchy_parts(
171        self, sig_node: desc_signature
172    ) -> Tuple[str, ...]:
173        if "fullname" not in sig_node:
174            return ()
175        modname = sig_node.get("module")
176        fullname = sig_node["fullname"]
177
178        if modname:
179            return (modname, *fullname.split("."))
180
181        return tuple(fullname.split("."))
182
183    def _toc_entry_name(self, sig_node: desc_signature) -> str:
184        # This controls the name in the TOC and on the sidebar.
185
186        # This is the return type of _object_hierarchy_parts().
187        toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ()))
188        if not toc_parts:
189            return ""
190
191        config = self.env.app.config
192        *parents, name = toc_parts
193        if config.toc_object_entries_show_parents == "domain":
194            return sig_node.get("fullname", name)
195        if config.toc_object_entries_show_parents == "hide":
196            return name
197        if config.toc_object_entries_show_parents == "all":
198            return ".".join(parents + [name])
199        return ""
200
201
202class QAPIObject(QAPIDescription):
203    """
204    Description of a generic QAPI object.
205
206    It's not used directly, but is instead subclassed by specific directives.
207    """
208
209    # Inherit some standard options from Sphinx's ObjectDescription
210    option_spec: OptionSpec = (  # type:ignore[misc]
211        ObjectDescription.option_spec.copy()
212    )
213    option_spec.update(
214        {
215            # Borrowed from the Python domain:
216            "module": directives.unchanged,  # Override contextual module name
217        }
218    )
219
220    def get_signature_prefix(self) -> List[nodes.Node]:
221        """Return a prefix to put before the object name in the signature."""
222        assert self.objtype
223        return [
224            KeywordNode("", self.objtype.title()),
225            SpaceNode(" "),
226        ]
227
228    def get_signature_suffix(self) -> List[nodes.Node]:
229        """Return a suffix to put after the object name in the signature."""
230        return []
231
232    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
233        """
234        Transform a QAPI definition name into RST nodes.
235
236        This method was originally intended for handling function
237        signatures. In the QAPI domain, however, we only pass the
238        definition name as the directive argument and handle everything
239        else in the content body with field lists.
240
241        As such, the only argument here is "sig", which is just the QAPI
242        definition name.
243        """
244        modname = self.options.get(
245            "module", self.env.ref_context.get("qapi:module")
246        )
247
248        signode["fullname"] = sig
249        signode["module"] = modname
250        sig_prefix = self.get_signature_prefix()
251        if sig_prefix:
252            signode += addnodes.desc_annotation(
253                str(sig_prefix), "", *sig_prefix
254            )
255        signode += addnodes.desc_name(sig, sig)
256        signode += self.get_signature_suffix()
257
258        return sig
259
260
261class QAPIModule(QAPIDescription):
262    """
263    Directive to mark description of a new module.
264
265    This directive doesn't generate any special formatting, and is just
266    a pass-through for the content body. Named section titles are
267    allowed in the content body.
268
269    Use this directive to create entries for the QAPI module in the
270    global index and the QAPI index; as well as to associate subsequent
271    definitions with the module they are defined in for purposes of
272    search and QAPI index organization.
273
274    :arg: The name of the module.
275    :opt no-index: Don't add cross-reference targets or index entries.
276    :opt no-typesetting: Don't render the content body (but preserve any
277       cross-reference target IDs in the squelched output.)
278
279    Example::
280
281       .. qapi:module:: block-core
282          :no-index:
283          :no-typesetting:
284
285          Lorem ipsum, dolor sit amet ...
286    """
287
288    def run(self) -> List[Node]:
289        modname = self.arguments[0].strip()
290        self.env.ref_context["qapi:module"] = modname
291        ret = super().run()
292
293        # ObjectDescription always creates a visible signature bar. We
294        # want module items to be "invisible", however.
295
296        # Extract the content body of the directive:
297        assert isinstance(ret[-1], addnodes.desc)
298        desc_node = ret.pop(-1)
299        assert isinstance(desc_node.children[1], addnodes.desc_content)
300        ret.extend(desc_node.children[1].children)
301
302        # Re-home node_ids so anchor refs still work:
303        node_ids: List[str]
304        if node_ids := [
305            node_id
306            for el in desc_node.children[0].traverse(nodes.Element)
307            for node_id in cast(List[str], el.get("ids", ()))
308        ]:
309            target_node = nodes.target(ids=node_ids)
310            ret.insert(1, target_node)
311
312        return ret
313
314
315class QAPIIndex(Index):
316    """
317    Index subclass to provide the QAPI definition index.
318    """
319
320    # pylint: disable=too-few-public-methods
321
322    name = "index"
323    localname = _("QAPI Index")
324    shortname = _("QAPI Index")
325
326    def generate(
327        self,
328        docnames: Optional[Iterable[str]] = None,
329    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
330        assert isinstance(self.domain, QAPIDomain)
331        content: Dict[str, List[IndexEntry]] = {}
332        collapse = False
333
334        # list of all object (name, ObjectEntry) pairs, sorted by name
335        # (ignoring the module)
336        objects = sorted(
337            self.domain.objects.items(),
338            key=lambda x: x[0].split(".")[-1].lower(),
339        )
340
341        for objname, obj in objects:
342            if docnames and obj.docname not in docnames:
343                continue
344
345            # Strip the module name out:
346            objname = objname.split(".")[-1]
347
348            # Add an alphabetical entry:
349            entries = content.setdefault(objname[0].upper(), [])
350            entries.append(
351                IndexEntry(
352                    objname, 0, obj.docname, obj.node_id, obj.objtype, "", ""
353                )
354            )
355
356            # Add a categorical entry:
357            category = obj.objtype.title() + "s"
358            entries = content.setdefault(category, [])
359            entries.append(
360                IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "")
361            )
362
363        # alphabetically sort categories; type names first, ABC entries last.
364        sorted_content = sorted(
365            content.items(),
366            key=lambda x: (len(x[0]) == 1, x[0]),
367        )
368        return sorted_content, collapse
369
370
371class QAPIDomain(Domain):
372    """QAPI language domain."""
373
374    name = "qapi"
375    label = "QAPI"
376
377    # This table associates cross-reference object types (key) with an
378    # ObjType instance, which defines the valid cross-reference roles
379    # for each object type.
380    object_types: Dict[str, ObjType] = {
381        "module": ObjType(_("module"), "mod", "any"),
382    }
383
384    # Each of these provides a rST directive,
385    # e.g. .. qapi:module:: block-core
386    directives = {
387        "module": QAPIModule,
388    }
389
390    # These are all cross-reference roles; e.g.
391    # :qapi:cmd:`query-block`. The keys correlate to the names used in
392    # the object_types table values above.
393    roles = {
394        "mod": QAPIXRefRole(),
395        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
396    }
397
398    # Moved into the data property at runtime;
399    # this is the internal index of reference-able objects.
400    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
401        "objects": {},  # fullname -> ObjectEntry
402    }
403
404    # Index pages to generate; each entry is an Index class.
405    indices = [
406        QAPIIndex,
407    ]
408
409    @property
410    def objects(self) -> Dict[str, ObjectEntry]:
411        ret = self.data.setdefault("objects", {})
412        return ret  # type: ignore[no-any-return]
413
414    def note_object(
415        self,
416        name: str,
417        objtype: str,
418        node_id: str,
419        aliased: bool = False,
420        location: Any = None,
421    ) -> None:
422        """Note a QAPI object for cross reference."""
423        if name in self.objects:
424            other = self.objects[name]
425            if other.aliased and aliased is False:
426                # The original definition found. Override it!
427                pass
428            elif other.aliased is False and aliased:
429                # The original definition is already registered.
430                return
431            else:
432                # duplicated
433                logger.warning(
434                    __(
435                        "duplicate object description of %s, "
436                        "other instance in %s, use :no-index: for one of them"
437                    ),
438                    name,
439                    other.docname,
440                    location=location,
441                )
442        self.objects[name] = ObjectEntry(
443            self.env.docname, node_id, objtype, aliased
444        )
445
446    def clear_doc(self, docname: str) -> None:
447        for fullname, obj in list(self.objects.items()):
448            if obj.docname == docname:
449                del self.objects[fullname]
450
451    def merge_domaindata(
452        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
453    ) -> None:
454        for fullname, obj in otherdata["objects"].items():
455            if obj.docname in docnames:
456                # Sphinx's own python domain doesn't appear to bother to
457                # check for collisions. Assert they don't happen and
458                # we'll fix it if/when the case arises.
459                assert fullname not in self.objects, (
460                    "bug - collision on merge?"
461                    f" {fullname=} {obj=} {self.objects[fullname]=}"
462                )
463                self.objects[fullname] = obj
464
465    def find_obj(
466        self, modname: str, name: str, typ: Optional[str]
467    ) -> list[tuple[str, ObjectEntry]]:
468        """
469        Find a QAPI object for "name", perhaps using the given module.
470
471        Returns a list of (name, object entry) tuples.
472
473        :param modname: The current module context (if any!)
474                        under which we are searching.
475        :param name: The name of the x-ref to resolve;
476                     may or may not include a leading module.
477        :param type: The role name of the x-ref we're resolving, if provided.
478                     (This is absent for "any" lookups.)
479        """
480        if not name:
481            return []
482
483        names: list[str] = []
484        matches: list[tuple[str, ObjectEntry]] = []
485
486        fullname = name
487        if "." in fullname:
488            # We're searching for a fully qualified reference;
489            # ignore the contextual module.
490            pass
491        elif modname:
492            # We're searching for something from somewhere;
493            # try searching the current module first.
494            # e.g. :qapi:cmd:`query-block` or `query-block` is being searched.
495            fullname = f"{modname}.{name}"
496
497        if typ is None:
498            # type isn't specified, this is a generic xref.
499            # search *all* qapi-specific object types.
500            objtypes: List[str] = list(self.object_types)
501        else:
502            # type is specified and will be a role (e.g. obj, mod, cmd)
503            # convert this to eligible object types (e.g. command, module)
504            # using the QAPIDomain.object_types table.
505            objtypes = self.objtypes_for_role(typ, [])
506
507        if name in self.objects and self.objects[name].objtype in objtypes:
508            names = [name]
509        elif (
510            fullname in self.objects
511            and self.objects[fullname].objtype in objtypes
512        ):
513            names = [fullname]
514        else:
515            # exact match wasn't found; e.g. we are searching for
516            # `query-block` from a different (or no) module.
517            searchname = "." + name
518            names = [
519                oname
520                for oname in self.objects
521                if oname.endswith(searchname)
522                and self.objects[oname].objtype in objtypes
523            ]
524
525        matches = [(oname, self.objects[oname]) for oname in names]
526        if len(matches) > 1:
527            matches = [m for m in matches if not m[1].aliased]
528        return matches
529
530    def resolve_xref(
531        self,
532        env: BuildEnvironment,
533        fromdocname: str,
534        builder: Builder,
535        typ: str,
536        target: str,
537        node: pending_xref,
538        contnode: Element,
539    ) -> nodes.reference | None:
540        modname = node.get("qapi:module")
541        matches = self.find_obj(modname, target, typ)
542
543        if not matches:
544            return None
545
546        if len(matches) > 1:
547            logger.warning(
548                __("more than one target found for cross-reference %r: %s"),
549                target,
550                ", ".join(match[0] for match in matches),
551                type="ref",
552                subtype="qapi",
553                location=node,
554            )
555
556        name, obj = matches[0]
557        return make_refnode(
558            builder, fromdocname, obj.docname, obj.node_id, contnode, name
559        )
560
561    def resolve_any_xref(
562        self,
563        env: BuildEnvironment,
564        fromdocname: str,
565        builder: Builder,
566        target: str,
567        node: pending_xref,
568        contnode: Element,
569    ) -> List[Tuple[str, nodes.reference]]:
570        results: List[Tuple[str, nodes.reference]] = []
571        matches = self.find_obj(node.get("qapi:module"), target, None)
572        for name, obj in matches:
573            rolename = self.role_for_objtype(obj.objtype)
574            assert rolename is not None
575            role = f"qapi:{rolename}"
576            refnode = make_refnode(
577                builder, fromdocname, obj.docname, obj.node_id, contnode, name
578            )
579            results.append((role, refnode))
580        return results
581
582
583def setup(app: Sphinx) -> Dict[str, Any]:
584    app.setup_extension("sphinx.directives")
585    app.add_domain(QAPIDomain)
586
587    return {
588        "version": "1.0",
589        "env_version": 1,
590        "parallel_read_safe": True,
591        "parallel_write_safe": True,
592    }
593