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