xref: /qemu/docs/sphinx/qapi_domain.py (revision d25808c2bc7921e5cd245111212ad7e3b6da3849)
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.docfields import Field, GroupedField, TypedField
37from sphinx.util.nodes import make_id, make_refnode
38
39
40if TYPE_CHECKING:
41    from docutils.nodes import Element, Node
42
43    from sphinx.application import Sphinx
44    from sphinx.builders import Builder
45    from sphinx.environment import BuildEnvironment
46    from sphinx.util.typing import OptionSpec
47
48logger = logging.getLogger(__name__)
49
50
51class ObjectEntry(NamedTuple):
52    docname: str
53    node_id: str
54    objtype: str
55    aliased: bool
56
57
58class QAPIXRefRole(XRefRole):
59
60    def process_link(
61        self,
62        env: BuildEnvironment,
63        refnode: Element,
64        has_explicit_title: bool,
65        title: str,
66        target: str,
67    ) -> tuple[str, str]:
68        refnode["qapi:module"] = env.ref_context.get("qapi:module")
69
70        # Cross-references that begin with a tilde adjust the title to
71        # only show the reference without a leading module, even if one
72        # was provided. This is a Sphinx-standard syntax; give it
73        # priority over QAPI-specific type markup below.
74        hide_module = False
75        if target.startswith("~"):
76            hide_module = True
77            target = target[1:]
78
79        # Type names that end with "?" are considered optional
80        # arguments and should be documented as such, but it's not
81        # part of the xref itself.
82        if target.endswith("?"):
83            refnode["qapi:optional"] = True
84            target = target[:-1]
85
86        # Type names wrapped in brackets denote lists. strip the
87        # brackets and remember to add them back later.
88        if target.startswith("[") and target.endswith("]"):
89            refnode["qapi:array"] = True
90            target = target[1:-1]
91
92        if has_explicit_title:
93            # Don't mess with the title at all if it was explicitly set.
94            # Explicit title syntax for references is e.g.
95            # :qapi:type:`target <explicit title>`
96            # and this explicit title overrides everything else here.
97            return title, target
98
99        title = target
100        if hide_module:
101            title = target.split(".")[-1]
102
103        return title, target
104
105
106# Alias for the return of handle_signature(), which is used in several places.
107# (In the Python domain, this is Tuple[str, str] instead.)
108Signature = str
109
110
111class QAPIDescription(ObjectDescription[Signature]):
112    """
113    Generic QAPI description.
114
115    This is meant to be an abstract class, not instantiated
116    directly. This class handles the abstract details of indexing, the
117    TOC, and reference targets for QAPI descriptions.
118    """
119
120    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
121        # Do nothing. The return value here is the "name" of the entity
122        # being documented; for QAPI, this is the same as the
123        # "signature", which is just a name.
124
125        # Normally this method must also populate signode with nodes to
126        # render the signature; here we do nothing instead - the
127        # subclasses will handle this.
128        return sig
129
130    def get_index_text(self, name: Signature) -> Tuple[str, str]:
131        """Return the text for the index entry of the object."""
132
133        # NB: this is used for the global index, not the QAPI index.
134        return ("single", f"{name} (QMP {self.objtype})")
135
136    def add_target_and_index(
137        self, name: Signature, sig: str, signode: desc_signature
138    ) -> None:
139        # name is the return value of handle_signature.
140        # sig is the original, raw text argument to handle_signature.
141        # For QAPI, these are identical, currently.
142
143        assert self.objtype
144
145        # If we're documenting a module, don't include the module as
146        # part of the FQN.
147        modname = ""
148        if self.objtype != "module":
149            modname = self.options.get(
150                "module", self.env.ref_context.get("qapi:module")
151            )
152        fullname = (modname + "." if modname else "") + name
153
154        node_id = make_id(
155            self.env, self.state.document, self.objtype, fullname
156        )
157        signode["ids"].append(node_id)
158
159        self.state.document.note_explicit_target(signode)
160        domain = cast(QAPIDomain, self.env.get_domain("qapi"))
161        domain.note_object(fullname, self.objtype, node_id, location=signode)
162
163        if "no-index-entry" not in self.options:
164            arity, indextext = self.get_index_text(name)
165            assert self.indexnode is not None
166            if indextext:
167                self.indexnode["entries"].append(
168                    (arity, indextext, node_id, "", None)
169                )
170
171    def _object_hierarchy_parts(
172        self, sig_node: desc_signature
173    ) -> Tuple[str, ...]:
174        if "fullname" not in sig_node:
175            return ()
176        modname = sig_node.get("module")
177        fullname = sig_node["fullname"]
178
179        if modname:
180            return (modname, *fullname.split("."))
181
182        return tuple(fullname.split("."))
183
184    def _toc_entry_name(self, sig_node: desc_signature) -> str:
185        # This controls the name in the TOC and on the sidebar.
186
187        # This is the return type of _object_hierarchy_parts().
188        toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ()))
189        if not toc_parts:
190            return ""
191
192        config = self.env.app.config
193        *parents, name = toc_parts
194        if config.toc_object_entries_show_parents == "domain":
195            return sig_node.get("fullname", name)
196        if config.toc_object_entries_show_parents == "hide":
197            return name
198        if config.toc_object_entries_show_parents == "all":
199            return ".".join(parents + [name])
200        return ""
201
202
203class QAPIObject(QAPIDescription):
204    """
205    Description of a generic QAPI object.
206
207    It's not used directly, but is instead subclassed by specific directives.
208    """
209
210    # Inherit some standard options from Sphinx's ObjectDescription
211    option_spec: OptionSpec = (  # type:ignore[misc]
212        ObjectDescription.option_spec.copy()
213    )
214    option_spec.update(
215        {
216            # Borrowed from the Python domain:
217            "module": directives.unchanged,  # Override contextual module name
218            # These are QAPI originals:
219            "since": directives.unchanged,
220            "deprecated": directives.flag,
221            "unstable": directives.flag,
222        }
223    )
224
225    doc_field_types = [
226        # :feat name: descr
227        GroupedField(
228            "feature",
229            label=_("Features"),
230            names=("feat",),
231            can_collapse=False,
232        ),
233    ]
234
235    def get_signature_prefix(self) -> List[nodes.Node]:
236        """Return a prefix to put before the object name in the signature."""
237        assert self.objtype
238        return [
239            KeywordNode("", self.objtype.title()),
240            SpaceNode(" "),
241        ]
242
243    def get_signature_suffix(self) -> List[nodes.Node]:
244        """Return a suffix to put after the object name in the signature."""
245        ret: List[nodes.Node] = []
246
247        if "since" in self.options:
248            ret += [
249                SpaceNode(" "),
250                addnodes.desc_sig_element(
251                    "", f"(Since: {self.options['since']})"
252                ),
253            ]
254
255        return ret
256
257    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
258        """
259        Transform a QAPI definition name into RST nodes.
260
261        This method was originally intended for handling function
262        signatures. In the QAPI domain, however, we only pass the
263        definition name as the directive argument and handle everything
264        else in the content body with field lists.
265
266        As such, the only argument here is "sig", which is just the QAPI
267        definition name.
268        """
269        modname = self.options.get(
270            "module", self.env.ref_context.get("qapi:module")
271        )
272
273        signode["fullname"] = sig
274        signode["module"] = modname
275        sig_prefix = self.get_signature_prefix()
276        if sig_prefix:
277            signode += addnodes.desc_annotation(
278                str(sig_prefix), "", *sig_prefix
279            )
280        signode += addnodes.desc_name(sig, sig)
281        signode += self.get_signature_suffix()
282
283        return sig
284
285    def _add_infopips(self, contentnode: addnodes.desc_content) -> None:
286        # Add various eye-catches and things that go below the signature
287        # bar, but precede the user-defined content.
288        infopips = nodes.container()
289        infopips.attributes["classes"].append("qapi-infopips")
290
291        def _add_pip(source: str, content: str, classname: str) -> None:
292            node = nodes.container(source)
293            node.append(nodes.Text(content))
294            node.attributes["classes"].extend(["qapi-infopip", classname])
295            infopips.append(node)
296
297        if "deprecated" in self.options:
298            _add_pip(
299                ":deprecated:",
300                f"This {self.objtype} is deprecated.",
301                "qapi-deprecated",
302            )
303
304        if "unstable" in self.options:
305            _add_pip(
306                ":unstable:",
307                f"This {self.objtype} is unstable/experimental.",
308                "qapi-unstable",
309            )
310
311        if infopips.children:
312            contentnode.insert(0, infopips)
313
314    def transform_content(self, content_node: addnodes.desc_content) -> None:
315        self._add_infopips(content_node)
316
317
318class QAPICommand(QAPIObject):
319    """Description of a QAPI Command."""
320
321    doc_field_types = QAPIObject.doc_field_types.copy()
322    doc_field_types.extend(
323        [
324            # :arg TypeName ArgName: descr
325            TypedField(
326                "argument",
327                label=_("Arguments"),
328                names=("arg",),
329                can_collapse=False,
330            ),
331            # :error: descr
332            Field(
333                "error",
334                label=_("Errors"),
335                names=("error", "errors"),
336                has_arg=False,
337            ),
338            # :return TypeName: descr
339            GroupedField(
340                "returnvalue",
341                label=_("Return"),
342                names=("return",),
343                can_collapse=True,
344            ),
345        ]
346    )
347
348
349class QAPIEnum(QAPIObject):
350    """Description of a QAPI Enum."""
351
352    doc_field_types = QAPIObject.doc_field_types.copy()
353    doc_field_types.extend(
354        [
355            # :value name: descr
356            GroupedField(
357                "value",
358                label=_("Values"),
359                names=("value",),
360                can_collapse=False,
361            )
362        ]
363    )
364
365
366class QAPIAlternate(QAPIObject):
367    """Description of a QAPI Alternate."""
368
369    doc_field_types = QAPIObject.doc_field_types.copy()
370    doc_field_types.extend(
371        [
372            # :alt type name: descr
373            TypedField(
374                "alternative",
375                label=_("Alternatives"),
376                names=("alt",),
377                can_collapse=False,
378            ),
379        ]
380    )
381
382
383class QAPIObjectWithMembers(QAPIObject):
384    """Base class for Events/Structs/Unions"""
385
386    doc_field_types = QAPIObject.doc_field_types.copy()
387    doc_field_types.extend(
388        [
389            # :member type name: descr
390            TypedField(
391                "member",
392                label=_("Members"),
393                names=("memb",),
394                can_collapse=False,
395            ),
396        ]
397    )
398
399
400class QAPIEvent(QAPIObjectWithMembers):
401    """Description of a QAPI Event."""
402
403
404class QAPIJSONObject(QAPIObjectWithMembers):
405    """Description of a QAPI Object: structs and unions."""
406
407
408class QAPIModule(QAPIDescription):
409    """
410    Directive to mark description of a new module.
411
412    This directive doesn't generate any special formatting, and is just
413    a pass-through for the content body. Named section titles are
414    allowed in the content body.
415
416    Use this directive to create entries for the QAPI module in the
417    global index and the QAPI index; as well as to associate subsequent
418    definitions with the module they are defined in for purposes of
419    search and QAPI index organization.
420
421    :arg: The name of the module.
422    :opt no-index: Don't add cross-reference targets or index entries.
423    :opt no-typesetting: Don't render the content body (but preserve any
424       cross-reference target IDs in the squelched output.)
425
426    Example::
427
428       .. qapi:module:: block-core
429          :no-index:
430          :no-typesetting:
431
432          Lorem ipsum, dolor sit amet ...
433    """
434
435    def run(self) -> List[Node]:
436        modname = self.arguments[0].strip()
437        self.env.ref_context["qapi:module"] = modname
438        ret = super().run()
439
440        # ObjectDescription always creates a visible signature bar. We
441        # want module items to be "invisible", however.
442
443        # Extract the content body of the directive:
444        assert isinstance(ret[-1], addnodes.desc)
445        desc_node = ret.pop(-1)
446        assert isinstance(desc_node.children[1], addnodes.desc_content)
447        ret.extend(desc_node.children[1].children)
448
449        # Re-home node_ids so anchor refs still work:
450        node_ids: List[str]
451        if node_ids := [
452            node_id
453            for el in desc_node.children[0].traverse(nodes.Element)
454            for node_id in cast(List[str], el.get("ids", ()))
455        ]:
456            target_node = nodes.target(ids=node_ids)
457            ret.insert(1, target_node)
458
459        return ret
460
461
462class QAPIIndex(Index):
463    """
464    Index subclass to provide the QAPI definition index.
465    """
466
467    # pylint: disable=too-few-public-methods
468
469    name = "index"
470    localname = _("QAPI Index")
471    shortname = _("QAPI Index")
472
473    def generate(
474        self,
475        docnames: Optional[Iterable[str]] = None,
476    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
477        assert isinstance(self.domain, QAPIDomain)
478        content: Dict[str, List[IndexEntry]] = {}
479        collapse = False
480
481        # list of all object (name, ObjectEntry) pairs, sorted by name
482        # (ignoring the module)
483        objects = sorted(
484            self.domain.objects.items(),
485            key=lambda x: x[0].split(".")[-1].lower(),
486        )
487
488        for objname, obj in objects:
489            if docnames and obj.docname not in docnames:
490                continue
491
492            # Strip the module name out:
493            objname = objname.split(".")[-1]
494
495            # Add an alphabetical entry:
496            entries = content.setdefault(objname[0].upper(), [])
497            entries.append(
498                IndexEntry(
499                    objname, 0, obj.docname, obj.node_id, obj.objtype, "", ""
500                )
501            )
502
503            # Add a categorical entry:
504            category = obj.objtype.title() + "s"
505            entries = content.setdefault(category, [])
506            entries.append(
507                IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "")
508            )
509
510        # alphabetically sort categories; type names first, ABC entries last.
511        sorted_content = sorted(
512            content.items(),
513            key=lambda x: (len(x[0]) == 1, x[0]),
514        )
515        return sorted_content, collapse
516
517
518class QAPIDomain(Domain):
519    """QAPI language domain."""
520
521    name = "qapi"
522    label = "QAPI"
523
524    # This table associates cross-reference object types (key) with an
525    # ObjType instance, which defines the valid cross-reference roles
526    # for each object type.
527    #
528    # e.g., the :qapi:type: cross-reference role can refer to enum,
529    # struct, union, or alternate objects; but :qapi:obj: can refer to
530    # anything. Each object also gets its own targeted cross-reference role.
531    object_types: Dict[str, ObjType] = {
532        "module": ObjType(_("module"), "mod", "any"),
533        "command": ObjType(_("command"), "cmd", "any"),
534        "event": ObjType(_("event"), "event", "any"),
535        "enum": ObjType(_("enum"), "enum", "type", "any"),
536        "object": ObjType(_("object"), "obj", "type", "any"),
537        "alternate": ObjType(_("alternate"), "alt", "type", "any"),
538    }
539
540    # Each of these provides a rST directive,
541    # e.g. .. qapi:module:: block-core
542    directives = {
543        "module": QAPIModule,
544        "command": QAPICommand,
545        "event": QAPIEvent,
546        "enum": QAPIEnum,
547        "object": QAPIJSONObject,
548        "alternate": QAPIAlternate,
549    }
550
551    # These are all cross-reference roles; e.g.
552    # :qapi:cmd:`query-block`. The keys correlate to the names used in
553    # the object_types table values above.
554    roles = {
555        "mod": QAPIXRefRole(),
556        "cmd": QAPIXRefRole(),
557        "event": QAPIXRefRole(),
558        "enum": QAPIXRefRole(),
559        "obj": QAPIXRefRole(),  # specifically structs and unions.
560        "alt": QAPIXRefRole(),
561        # reference any data type (excludes modules, commands, events)
562        "type": QAPIXRefRole(),
563        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
564    }
565
566    # Moved into the data property at runtime;
567    # this is the internal index of reference-able objects.
568    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
569        "objects": {},  # fullname -> ObjectEntry
570    }
571
572    # Index pages to generate; each entry is an Index class.
573    indices = [
574        QAPIIndex,
575    ]
576
577    @property
578    def objects(self) -> Dict[str, ObjectEntry]:
579        ret = self.data.setdefault("objects", {})
580        return ret  # type: ignore[no-any-return]
581
582    def note_object(
583        self,
584        name: str,
585        objtype: str,
586        node_id: str,
587        aliased: bool = False,
588        location: Any = None,
589    ) -> None:
590        """Note a QAPI object for cross reference."""
591        if name in self.objects:
592            other = self.objects[name]
593            if other.aliased and aliased is False:
594                # The original definition found. Override it!
595                pass
596            elif other.aliased is False and aliased:
597                # The original definition is already registered.
598                return
599            else:
600                # duplicated
601                logger.warning(
602                    __(
603                        "duplicate object description of %s, "
604                        "other instance in %s, use :no-index: for one of them"
605                    ),
606                    name,
607                    other.docname,
608                    location=location,
609                )
610        self.objects[name] = ObjectEntry(
611            self.env.docname, node_id, objtype, aliased
612        )
613
614    def clear_doc(self, docname: str) -> None:
615        for fullname, obj in list(self.objects.items()):
616            if obj.docname == docname:
617                del self.objects[fullname]
618
619    def merge_domaindata(
620        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
621    ) -> None:
622        for fullname, obj in otherdata["objects"].items():
623            if obj.docname in docnames:
624                # Sphinx's own python domain doesn't appear to bother to
625                # check for collisions. Assert they don't happen and
626                # we'll fix it if/when the case arises.
627                assert fullname not in self.objects, (
628                    "bug - collision on merge?"
629                    f" {fullname=} {obj=} {self.objects[fullname]=}"
630                )
631                self.objects[fullname] = obj
632
633    def find_obj(
634        self, modname: str, name: str, typ: Optional[str]
635    ) -> list[tuple[str, ObjectEntry]]:
636        """
637        Find a QAPI object for "name", perhaps using the given module.
638
639        Returns a list of (name, object entry) tuples.
640
641        :param modname: The current module context (if any!)
642                        under which we are searching.
643        :param name: The name of the x-ref to resolve;
644                     may or may not include a leading module.
645        :param type: The role name of the x-ref we're resolving, if provided.
646                     (This is absent for "any" lookups.)
647        """
648        if not name:
649            return []
650
651        names: list[str] = []
652        matches: list[tuple[str, ObjectEntry]] = []
653
654        fullname = name
655        if "." in fullname:
656            # We're searching for a fully qualified reference;
657            # ignore the contextual module.
658            pass
659        elif modname:
660            # We're searching for something from somewhere;
661            # try searching the current module first.
662            # e.g. :qapi:cmd:`query-block` or `query-block` is being searched.
663            fullname = f"{modname}.{name}"
664
665        if typ is None:
666            # type isn't specified, this is a generic xref.
667            # search *all* qapi-specific object types.
668            objtypes: List[str] = list(self.object_types)
669        else:
670            # type is specified and will be a role (e.g. obj, mod, cmd)
671            # convert this to eligible object types (e.g. command, module)
672            # using the QAPIDomain.object_types table.
673            objtypes = self.objtypes_for_role(typ, [])
674
675        if name in self.objects and self.objects[name].objtype in objtypes:
676            names = [name]
677        elif (
678            fullname in self.objects
679            and self.objects[fullname].objtype in objtypes
680        ):
681            names = [fullname]
682        else:
683            # exact match wasn't found; e.g. we are searching for
684            # `query-block` from a different (or no) module.
685            searchname = "." + name
686            names = [
687                oname
688                for oname in self.objects
689                if oname.endswith(searchname)
690                and self.objects[oname].objtype in objtypes
691            ]
692
693        matches = [(oname, self.objects[oname]) for oname in names]
694        if len(matches) > 1:
695            matches = [m for m in matches if not m[1].aliased]
696        return matches
697
698    def resolve_xref(
699        self,
700        env: BuildEnvironment,
701        fromdocname: str,
702        builder: Builder,
703        typ: str,
704        target: str,
705        node: pending_xref,
706        contnode: Element,
707    ) -> nodes.reference | None:
708        modname = node.get("qapi:module")
709        matches = self.find_obj(modname, target, typ)
710
711        if not matches:
712            return None
713
714        if len(matches) > 1:
715            logger.warning(
716                __("more than one target found for cross-reference %r: %s"),
717                target,
718                ", ".join(match[0] for match in matches),
719                type="ref",
720                subtype="qapi",
721                location=node,
722            )
723
724        name, obj = matches[0]
725        return make_refnode(
726            builder, fromdocname, obj.docname, obj.node_id, contnode, name
727        )
728
729    def resolve_any_xref(
730        self,
731        env: BuildEnvironment,
732        fromdocname: str,
733        builder: Builder,
734        target: str,
735        node: pending_xref,
736        contnode: Element,
737    ) -> List[Tuple[str, nodes.reference]]:
738        results: List[Tuple[str, nodes.reference]] = []
739        matches = self.find_obj(node.get("qapi:module"), target, None)
740        for name, obj in matches:
741            rolename = self.role_for_objtype(obj.objtype)
742            assert rolename is not None
743            role = f"qapi:{rolename}"
744            refnode = make_refnode(
745                builder, fromdocname, obj.docname, obj.node_id, contnode, name
746            )
747            results.append((role, refnode))
748        return results
749
750
751def setup(app: Sphinx) -> Dict[str, Any]:
752    app.setup_extension("sphinx.directives")
753    app.add_domain(QAPIDomain)
754
755    return {
756        "version": "1.0",
757        "env_version": 1,
758        "parallel_read_safe": True,
759        "parallel_write_safe": True,
760    }
761