xref: /openbmc/qemu/docs/sphinx/qapi_domain.py (revision bac3f1313c2d1dca49bd5499965d8cee0d7bb98f)
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        }
221    )
222
223    doc_field_types = [
224        # :feat name: descr
225        GroupedField(
226            "feature",
227            label=_("Features"),
228            names=("feat",),
229            can_collapse=False,
230        ),
231    ]
232
233    def get_signature_prefix(self) -> List[nodes.Node]:
234        """Return a prefix to put before the object name in the signature."""
235        assert self.objtype
236        return [
237            KeywordNode("", self.objtype.title()),
238            SpaceNode(" "),
239        ]
240
241    def get_signature_suffix(self) -> List[nodes.Node]:
242        """Return a suffix to put after the object name in the signature."""
243        ret: List[nodes.Node] = []
244
245        if "since" in self.options:
246            ret += [
247                SpaceNode(" "),
248                addnodes.desc_sig_element(
249                    "", f"(Since: {self.options['since']})"
250                ),
251            ]
252
253        return ret
254
255    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
256        """
257        Transform a QAPI definition name into RST nodes.
258
259        This method was originally intended for handling function
260        signatures. In the QAPI domain, however, we only pass the
261        definition name as the directive argument and handle everything
262        else in the content body with field lists.
263
264        As such, the only argument here is "sig", which is just the QAPI
265        definition name.
266        """
267        modname = self.options.get(
268            "module", self.env.ref_context.get("qapi:module")
269        )
270
271        signode["fullname"] = sig
272        signode["module"] = modname
273        sig_prefix = self.get_signature_prefix()
274        if sig_prefix:
275            signode += addnodes.desc_annotation(
276                str(sig_prefix), "", *sig_prefix
277            )
278        signode += addnodes.desc_name(sig, sig)
279        signode += self.get_signature_suffix()
280
281        return sig
282
283
284class QAPICommand(QAPIObject):
285    """Description of a QAPI Command."""
286
287    doc_field_types = QAPIObject.doc_field_types.copy()
288    doc_field_types.extend(
289        [
290            # :arg TypeName ArgName: descr
291            TypedField(
292                "argument",
293                label=_("Arguments"),
294                names=("arg",),
295                can_collapse=False,
296            ),
297            # :error: descr
298            Field(
299                "error",
300                label=_("Errors"),
301                names=("error", "errors"),
302                has_arg=False,
303            ),
304            # :return TypeName: descr
305            GroupedField(
306                "returnvalue",
307                label=_("Return"),
308                names=("return",),
309                can_collapse=True,
310            ),
311        ]
312    )
313
314
315class QAPIEnum(QAPIObject):
316    """Description of a QAPI Enum."""
317
318    doc_field_types = QAPIObject.doc_field_types.copy()
319    doc_field_types.extend(
320        [
321            # :value name: descr
322            GroupedField(
323                "value",
324                label=_("Values"),
325                names=("value",),
326                can_collapse=False,
327            )
328        ]
329    )
330
331
332class QAPIAlternate(QAPIObject):
333    """Description of a QAPI Alternate."""
334
335    doc_field_types = QAPIObject.doc_field_types.copy()
336    doc_field_types.extend(
337        [
338            # :alt type name: descr
339            TypedField(
340                "alternative",
341                label=_("Alternatives"),
342                names=("alt",),
343                can_collapse=False,
344            ),
345        ]
346    )
347
348
349class QAPIModule(QAPIDescription):
350    """
351    Directive to mark description of a new module.
352
353    This directive doesn't generate any special formatting, and is just
354    a pass-through for the content body. Named section titles are
355    allowed in the content body.
356
357    Use this directive to create entries for the QAPI module in the
358    global index and the QAPI index; as well as to associate subsequent
359    definitions with the module they are defined in for purposes of
360    search and QAPI index organization.
361
362    :arg: The name of the module.
363    :opt no-index: Don't add cross-reference targets or index entries.
364    :opt no-typesetting: Don't render the content body (but preserve any
365       cross-reference target IDs in the squelched output.)
366
367    Example::
368
369       .. qapi:module:: block-core
370          :no-index:
371          :no-typesetting:
372
373          Lorem ipsum, dolor sit amet ...
374    """
375
376    def run(self) -> List[Node]:
377        modname = self.arguments[0].strip()
378        self.env.ref_context["qapi:module"] = modname
379        ret = super().run()
380
381        # ObjectDescription always creates a visible signature bar. We
382        # want module items to be "invisible", however.
383
384        # Extract the content body of the directive:
385        assert isinstance(ret[-1], addnodes.desc)
386        desc_node = ret.pop(-1)
387        assert isinstance(desc_node.children[1], addnodes.desc_content)
388        ret.extend(desc_node.children[1].children)
389
390        # Re-home node_ids so anchor refs still work:
391        node_ids: List[str]
392        if node_ids := [
393            node_id
394            for el in desc_node.children[0].traverse(nodes.Element)
395            for node_id in cast(List[str], el.get("ids", ()))
396        ]:
397            target_node = nodes.target(ids=node_ids)
398            ret.insert(1, target_node)
399
400        return ret
401
402
403class QAPIIndex(Index):
404    """
405    Index subclass to provide the QAPI definition index.
406    """
407
408    # pylint: disable=too-few-public-methods
409
410    name = "index"
411    localname = _("QAPI Index")
412    shortname = _("QAPI Index")
413
414    def generate(
415        self,
416        docnames: Optional[Iterable[str]] = None,
417    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
418        assert isinstance(self.domain, QAPIDomain)
419        content: Dict[str, List[IndexEntry]] = {}
420        collapse = False
421
422        # list of all object (name, ObjectEntry) pairs, sorted by name
423        # (ignoring the module)
424        objects = sorted(
425            self.domain.objects.items(),
426            key=lambda x: x[0].split(".")[-1].lower(),
427        )
428
429        for objname, obj in objects:
430            if docnames and obj.docname not in docnames:
431                continue
432
433            # Strip the module name out:
434            objname = objname.split(".")[-1]
435
436            # Add an alphabetical entry:
437            entries = content.setdefault(objname[0].upper(), [])
438            entries.append(
439                IndexEntry(
440                    objname, 0, obj.docname, obj.node_id, obj.objtype, "", ""
441                )
442            )
443
444            # Add a categorical entry:
445            category = obj.objtype.title() + "s"
446            entries = content.setdefault(category, [])
447            entries.append(
448                IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "")
449            )
450
451        # alphabetically sort categories; type names first, ABC entries last.
452        sorted_content = sorted(
453            content.items(),
454            key=lambda x: (len(x[0]) == 1, x[0]),
455        )
456        return sorted_content, collapse
457
458
459class QAPIDomain(Domain):
460    """QAPI language domain."""
461
462    name = "qapi"
463    label = "QAPI"
464
465    # This table associates cross-reference object types (key) with an
466    # ObjType instance, which defines the valid cross-reference roles
467    # for each object type.
468    #
469    # e.g., the :qapi:type: cross-reference role can refer to enum,
470    # struct, union, or alternate objects; but :qapi:obj: can refer to
471    # anything. Each object also gets its own targeted cross-reference role.
472    object_types: Dict[str, ObjType] = {
473        "module": ObjType(_("module"), "mod", "any"),
474        "command": ObjType(_("command"), "cmd", "any"),
475        "enum": ObjType(_("enum"), "enum", "type", "any"),
476        "alternate": ObjType(_("alternate"), "alt", "type", "any"),
477    }
478
479    # Each of these provides a rST directive,
480    # e.g. .. qapi:module:: block-core
481    directives = {
482        "module": QAPIModule,
483        "command": QAPICommand,
484        "enum": QAPIEnum,
485        "alternate": QAPIAlternate,
486    }
487
488    # These are all cross-reference roles; e.g.
489    # :qapi:cmd:`query-block`. The keys correlate to the names used in
490    # the object_types table values above.
491    roles = {
492        "mod": QAPIXRefRole(),
493        "cmd": QAPIXRefRole(),
494        "enum": QAPIXRefRole(),
495        "alt": QAPIXRefRole(),
496        # reference any data type (excludes modules, commands, events)
497        "type": QAPIXRefRole(),
498        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
499    }
500
501    # Moved into the data property at runtime;
502    # this is the internal index of reference-able objects.
503    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
504        "objects": {},  # fullname -> ObjectEntry
505    }
506
507    # Index pages to generate; each entry is an Index class.
508    indices = [
509        QAPIIndex,
510    ]
511
512    @property
513    def objects(self) -> Dict[str, ObjectEntry]:
514        ret = self.data.setdefault("objects", {})
515        return ret  # type: ignore[no-any-return]
516
517    def note_object(
518        self,
519        name: str,
520        objtype: str,
521        node_id: str,
522        aliased: bool = False,
523        location: Any = None,
524    ) -> None:
525        """Note a QAPI object for cross reference."""
526        if name in self.objects:
527            other = self.objects[name]
528            if other.aliased and aliased is False:
529                # The original definition found. Override it!
530                pass
531            elif other.aliased is False and aliased:
532                # The original definition is already registered.
533                return
534            else:
535                # duplicated
536                logger.warning(
537                    __(
538                        "duplicate object description of %s, "
539                        "other instance in %s, use :no-index: for one of them"
540                    ),
541                    name,
542                    other.docname,
543                    location=location,
544                )
545        self.objects[name] = ObjectEntry(
546            self.env.docname, node_id, objtype, aliased
547        )
548
549    def clear_doc(self, docname: str) -> None:
550        for fullname, obj in list(self.objects.items()):
551            if obj.docname == docname:
552                del self.objects[fullname]
553
554    def merge_domaindata(
555        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
556    ) -> None:
557        for fullname, obj in otherdata["objects"].items():
558            if obj.docname in docnames:
559                # Sphinx's own python domain doesn't appear to bother to
560                # check for collisions. Assert they don't happen and
561                # we'll fix it if/when the case arises.
562                assert fullname not in self.objects, (
563                    "bug - collision on merge?"
564                    f" {fullname=} {obj=} {self.objects[fullname]=}"
565                )
566                self.objects[fullname] = obj
567
568    def find_obj(
569        self, modname: str, name: str, typ: Optional[str]
570    ) -> list[tuple[str, ObjectEntry]]:
571        """
572        Find a QAPI object for "name", perhaps using the given module.
573
574        Returns a list of (name, object entry) tuples.
575
576        :param modname: The current module context (if any!)
577                        under which we are searching.
578        :param name: The name of the x-ref to resolve;
579                     may or may not include a leading module.
580        :param type: The role name of the x-ref we're resolving, if provided.
581                     (This is absent for "any" lookups.)
582        """
583        if not name:
584            return []
585
586        names: list[str] = []
587        matches: list[tuple[str, ObjectEntry]] = []
588
589        fullname = name
590        if "." in fullname:
591            # We're searching for a fully qualified reference;
592            # ignore the contextual module.
593            pass
594        elif modname:
595            # We're searching for something from somewhere;
596            # try searching the current module first.
597            # e.g. :qapi:cmd:`query-block` or `query-block` is being searched.
598            fullname = f"{modname}.{name}"
599
600        if typ is None:
601            # type isn't specified, this is a generic xref.
602            # search *all* qapi-specific object types.
603            objtypes: List[str] = list(self.object_types)
604        else:
605            # type is specified and will be a role (e.g. obj, mod, cmd)
606            # convert this to eligible object types (e.g. command, module)
607            # using the QAPIDomain.object_types table.
608            objtypes = self.objtypes_for_role(typ, [])
609
610        if name in self.objects and self.objects[name].objtype in objtypes:
611            names = [name]
612        elif (
613            fullname in self.objects
614            and self.objects[fullname].objtype in objtypes
615        ):
616            names = [fullname]
617        else:
618            # exact match wasn't found; e.g. we are searching for
619            # `query-block` from a different (or no) module.
620            searchname = "." + name
621            names = [
622                oname
623                for oname in self.objects
624                if oname.endswith(searchname)
625                and self.objects[oname].objtype in objtypes
626            ]
627
628        matches = [(oname, self.objects[oname]) for oname in names]
629        if len(matches) > 1:
630            matches = [m for m in matches if not m[1].aliased]
631        return matches
632
633    def resolve_xref(
634        self,
635        env: BuildEnvironment,
636        fromdocname: str,
637        builder: Builder,
638        typ: str,
639        target: str,
640        node: pending_xref,
641        contnode: Element,
642    ) -> nodes.reference | None:
643        modname = node.get("qapi:module")
644        matches = self.find_obj(modname, target, typ)
645
646        if not matches:
647            return None
648
649        if len(matches) > 1:
650            logger.warning(
651                __("more than one target found for cross-reference %r: %s"),
652                target,
653                ", ".join(match[0] for match in matches),
654                type="ref",
655                subtype="qapi",
656                location=node,
657            )
658
659        name, obj = matches[0]
660        return make_refnode(
661            builder, fromdocname, obj.docname, obj.node_id, contnode, name
662        )
663
664    def resolve_any_xref(
665        self,
666        env: BuildEnvironment,
667        fromdocname: str,
668        builder: Builder,
669        target: str,
670        node: pending_xref,
671        contnode: Element,
672    ) -> List[Tuple[str, nodes.reference]]:
673        results: List[Tuple[str, nodes.reference]] = []
674        matches = self.find_obj(node.get("qapi:module"), target, None)
675        for name, obj in matches:
676            rolename = self.role_for_objtype(obj.objtype)
677            assert rolename is not None
678            role = f"qapi:{rolename}"
679            refnode = make_refnode(
680                builder, fromdocname, obj.docname, obj.node_id, contnode, name
681            )
682            results.append((role, refnode))
683        return results
684
685
686def setup(app: Sphinx) -> Dict[str, Any]:
687    app.setup_extension("sphinx.directives")
688    app.add_domain(QAPIDomain)
689
690    return {
691        "version": "1.0",
692        "env_version": 1,
693        "parallel_read_safe": True,
694        "parallel_write_safe": True,
695    }
696