xref: /openbmc/qemu/docs/sphinx/qapi_domain.py (revision 504632dcc63145e6c5297fc1b7f1d76450dd845a)
1"""
2QAPI domain extension.
3"""
4
5# The best laid plans of mice and men, ...
6# pylint: disable=too-many-lines
7
8from __future__ import annotations
9
10import re
11import types
12from typing import (
13    TYPE_CHECKING,
14    List,
15    NamedTuple,
16    Tuple,
17    Type,
18    cast,
19)
20
21from docutils import nodes
22from docutils.parsers.rst import directives
23from sphinx import addnodes
24from sphinx.directives import ObjectDescription
25from sphinx.domains import (
26    Domain,
27    Index,
28    IndexEntry,
29    ObjType,
30)
31from sphinx.locale import _, __
32from sphinx.roles import XRefRole
33from sphinx.util import logging
34from sphinx.util.docutils import SphinxDirective
35from sphinx.util.nodes import make_id, make_refnode
36
37from compat import (
38    CompatField,
39    CompatGroupedField,
40    CompatTypedField,
41    KeywordNode,
42    ParserFix,
43    Signature,
44    SpaceNode,
45)
46
47
48if TYPE_CHECKING:
49    from typing import (
50        AbstractSet,
51        Any,
52        Dict,
53        Iterable,
54        Optional,
55        Union,
56    )
57
58    from docutils.nodes import Element, Node
59    from sphinx.addnodes import desc_signature, pending_xref
60    from sphinx.application import Sphinx
61    from sphinx.builders import Builder
62    from sphinx.environment import BuildEnvironment
63    from sphinx.util.typing import OptionSpec
64
65
66logger = logging.getLogger(__name__)
67
68
69def _unpack_field(
70    field: nodes.Node,
71) -> Tuple[nodes.field_name, nodes.field_body]:
72    """
73    docutils helper: unpack a field node in a type-safe manner.
74    """
75    assert isinstance(field, nodes.field)
76    assert len(field.children) == 2
77    assert isinstance(field.children[0], nodes.field_name)
78    assert isinstance(field.children[1], nodes.field_body)
79    return (field.children[0], field.children[1])
80
81
82class ObjectEntry(NamedTuple):
83    docname: str
84    node_id: str
85    objtype: str
86    aliased: bool
87
88
89class QAPIXRefRole(XRefRole):
90
91    def process_link(
92        self,
93        env: BuildEnvironment,
94        refnode: Element,
95        has_explicit_title: bool,
96        title: str,
97        target: str,
98    ) -> tuple[str, str]:
99        refnode["qapi:namespace"] = env.ref_context.get("qapi:namespace")
100        refnode["qapi:module"] = env.ref_context.get("qapi:module")
101
102        # Cross-references that begin with a tilde adjust the title to
103        # only show the reference without a leading module, even if one
104        # was provided. This is a Sphinx-standard syntax; give it
105        # priority over QAPI-specific type markup below.
106        hide_module = False
107        if target.startswith("~"):
108            hide_module = True
109            target = target[1:]
110
111        # Type names that end with "?" are considered optional
112        # arguments and should be documented as such, but it's not
113        # part of the xref itself.
114        if target.endswith("?"):
115            refnode["qapi:optional"] = True
116            target = target[:-1]
117
118        # Type names wrapped in brackets denote lists. strip the
119        # brackets and remember to add them back later.
120        if target.startswith("[") and target.endswith("]"):
121            refnode["qapi:array"] = True
122            target = target[1:-1]
123
124        if has_explicit_title:
125            # Don't mess with the title at all if it was explicitly set.
126            # Explicit title syntax for references is e.g.
127            # :qapi:type:`target <explicit title>`
128            # and this explicit title overrides everything else here.
129            return title, target
130
131        title = target
132        if hide_module:
133            title = target.split(".")[-1]
134
135        return title, target
136
137    def result_nodes(
138        self,
139        document: nodes.document,
140        env: BuildEnvironment,
141        node: Element,
142        is_ref: bool,
143    ) -> Tuple[List[nodes.Node], List[nodes.system_message]]:
144
145        # node here is the pending_xref node (or whatever nodeclass was
146        # configured at XRefRole class instantiation time).
147        results: List[nodes.Node] = [node]
148
149        if node.get("qapi:array"):
150            results.insert(0, nodes.literal("[", "["))
151            results.append(nodes.literal("]", "]"))
152
153        if node.get("qapi:optional"):
154            results.append(nodes.Text(", "))
155            results.append(nodes.emphasis("?", "optional"))
156
157        return results, []
158
159
160class QAPIDescription(ParserFix):
161    """
162    Generic QAPI description.
163
164    This is meant to be an abstract class, not instantiated
165    directly. This class handles the abstract details of indexing, the
166    TOC, and reference targets for QAPI descriptions.
167    """
168
169    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
170        # pylint: disable=unused-argument
171
172        # Do nothing. The return value here is the "name" of the entity
173        # being documented; for QAPI, this is the same as the
174        # "signature", which is just a name.
175
176        # Normally this method must also populate signode with nodes to
177        # render the signature; here we do nothing instead - the
178        # subclasses will handle this.
179        return sig
180
181    def get_index_text(self, name: Signature) -> Tuple[str, str]:
182        """Return the text for the index entry of the object."""
183
184        # NB: this is used for the global index, not the QAPI index.
185        return ("single", f"{name} (QMP {self.objtype})")
186
187    def _get_context(self) -> Tuple[str, str]:
188        namespace = self.options.get(
189            "namespace", self.env.ref_context.get("qapi:namespace", "")
190        )
191        modname = self.options.get(
192            "module", self.env.ref_context.get("qapi:module", "")
193        )
194
195        return namespace, modname
196
197    def _get_fqn(self, name: Signature) -> str:
198        namespace, modname = self._get_context()
199
200        # If we're documenting a module, don't include the module as
201        # part of the FQN; we ARE the module!
202        if self.objtype == "module":
203            modname = ""
204
205        if modname:
206            name = f"{modname}.{name}"
207        if namespace:
208            name = f"{namespace}:{name}"
209        return name
210
211    def add_target_and_index(
212        self, name: Signature, sig: str, signode: desc_signature
213    ) -> None:
214        # pylint: disable=unused-argument
215
216        # name is the return value of handle_signature.
217        # sig is the original, raw text argument to handle_signature.
218        # For QAPI, these are identical, currently.
219
220        assert self.objtype
221
222        if not (fullname := signode.get("fullname", "")):
223            fullname = self._get_fqn(name)
224
225        node_id = make_id(
226            self.env, self.state.document, self.objtype, fullname
227        )
228        signode["ids"].append(node_id)
229
230        self.state.document.note_explicit_target(signode)
231        domain = cast(QAPIDomain, self.env.get_domain("qapi"))
232        domain.note_object(fullname, self.objtype, node_id, location=signode)
233
234        if "no-index-entry" not in self.options:
235            arity, indextext = self.get_index_text(name)
236            assert self.indexnode is not None
237            if indextext:
238                self.indexnode["entries"].append(
239                    (arity, indextext, node_id, "", None)
240                )
241
242    @staticmethod
243    def split_fqn(name: str) -> Tuple[str, str, str]:
244        if ":" in name:
245            ns, name = name.split(":")
246        else:
247            ns = ""
248
249        if "." in name:
250            module, name = name.split(".")
251        else:
252            module = ""
253
254        return (ns, module, name)
255
256    def _object_hierarchy_parts(
257        self, sig_node: desc_signature
258    ) -> Tuple[str, ...]:
259        if "fullname" not in sig_node:
260            return ()
261        return self.split_fqn(sig_node["fullname"])
262
263    def _toc_entry_name(self, sig_node: desc_signature) -> str:
264        # This controls the name in the TOC and on the sidebar.
265
266        # This is the return type of _object_hierarchy_parts().
267        toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ()))
268        if not toc_parts:
269            return ""
270
271        config = self.env.app.config
272        namespace, modname, name = toc_parts
273
274        if config.toc_object_entries_show_parents == "domain":
275            ret = name
276            if modname and modname != self.env.ref_context.get(
277                "qapi:module", ""
278            ):
279                ret = f"{modname}.{name}"
280            if namespace and namespace != self.env.ref_context.get(
281                "qapi:namespace", ""
282            ):
283                ret = f"{namespace}:{ret}"
284            return ret
285        if config.toc_object_entries_show_parents == "hide":
286            return name
287        if config.toc_object_entries_show_parents == "all":
288            return sig_node.get("fullname", name)
289        return ""
290
291
292class QAPIObject(QAPIDescription):
293    """
294    Description of a generic QAPI object.
295
296    It's not used directly, but is instead subclassed by specific directives.
297    """
298
299    # Inherit some standard options from Sphinx's ObjectDescription
300    option_spec: OptionSpec = (  # type:ignore[misc]
301        ObjectDescription.option_spec.copy()
302    )
303    option_spec.update(
304        {
305            # Context overrides:
306            "namespace": directives.unchanged,
307            "module": directives.unchanged,
308            # These are QAPI originals:
309            "since": directives.unchanged,
310            "ifcond": directives.unchanged,
311            "deprecated": directives.flag,
312            "unstable": directives.flag,
313        }
314    )
315
316    doc_field_types = [
317        # :feat name: descr
318        CompatGroupedField(
319            "feature",
320            label=_("Features"),
321            names=("feat",),
322            can_collapse=False,
323        ),
324    ]
325
326    def get_signature_prefix(self) -> List[nodes.Node]:
327        """Return a prefix to put before the object name in the signature."""
328        assert self.objtype
329        return [
330            KeywordNode("", self.objtype.title()),
331            SpaceNode(" "),
332        ]
333
334    def get_signature_suffix(self) -> List[nodes.Node]:
335        """Return a suffix to put after the object name in the signature."""
336        ret: List[nodes.Node] = []
337
338        if "since" in self.options:
339            ret += [
340                SpaceNode(" "),
341                addnodes.desc_sig_element(
342                    "", f"(Since: {self.options['since']})"
343                ),
344            ]
345
346        return ret
347
348    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
349        """
350        Transform a QAPI definition name into RST nodes.
351
352        This method was originally intended for handling function
353        signatures. In the QAPI domain, however, we only pass the
354        definition name as the directive argument and handle everything
355        else in the content body with field lists.
356
357        As such, the only argument here is "sig", which is just the QAPI
358        definition name.
359        """
360        # No module or domain info allowed in the signature!
361        assert ":" not in sig
362        assert "." not in sig
363
364        namespace, modname = self._get_context()
365        signode["fullname"] = self._get_fqn(sig)
366        signode["namespace"] = namespace
367        signode["module"] = modname
368
369        sig_prefix = self.get_signature_prefix()
370        if sig_prefix:
371            signode += addnodes.desc_annotation(
372                str(sig_prefix), "", *sig_prefix
373            )
374        signode += addnodes.desc_name(sig, sig)
375        signode += self.get_signature_suffix()
376
377        return sig
378
379    def _add_infopips(self, contentnode: addnodes.desc_content) -> None:
380        # Add various eye-catches and things that go below the signature
381        # bar, but precede the user-defined content.
382        infopips = nodes.container()
383        infopips.attributes["classes"].append("qapi-infopips")
384
385        def _add_pip(
386            source: str, content: Union[str, List[nodes.Node]], classname: str
387        ) -> None:
388            node = nodes.container(source)
389            if isinstance(content, str):
390                node.append(nodes.Text(content))
391            else:
392                node.extend(content)
393            node.attributes["classes"].extend(["qapi-infopip", classname])
394            infopips.append(node)
395
396        if "deprecated" in self.options:
397            _add_pip(
398                ":deprecated:",
399                f"This {self.objtype} is deprecated.",
400                "qapi-deprecated",
401            )
402
403        if "unstable" in self.options:
404            _add_pip(
405                ":unstable:",
406                f"This {self.objtype} is unstable/experimental.",
407                "qapi-unstable",
408            )
409
410        if self.options.get("ifcond", ""):
411            ifcond = self.options["ifcond"]
412            _add_pip(
413                f":ifcond: {ifcond}",
414                [
415                    nodes.emphasis("", "Availability"),
416                    nodes.Text(": "),
417                    nodes.literal(ifcond, ifcond),
418                ],
419                "qapi-ifcond",
420            )
421
422        if infopips.children:
423            contentnode.insert(0, infopips)
424
425    def _validate_field(self, field: nodes.field) -> None:
426        """Validate field lists in this QAPI Object Description."""
427        name, _ = _unpack_field(field)
428        allowed_fields = set(self.env.app.config.qapi_allowed_fields)
429
430        field_label = name.astext()
431        if field_label in allowed_fields:
432            # Explicitly allowed field list name, OK.
433            return
434
435        try:
436            # split into field type and argument (if provided)
437            # e.g. `:arg type name: descr` is
438            # field_type = "arg", field_arg = "type name".
439            field_type, field_arg = field_label.split(None, 1)
440        except ValueError:
441            # No arguments provided
442            field_type = field_label
443            field_arg = ""
444
445        typemap = self.get_field_type_map()
446        if field_type in typemap:
447            # This is a special docfield, yet-to-be-processed. Catch
448            # correct names, but incorrect arguments. This mismatch WILL
449            # cause Sphinx to render this field incorrectly (without a
450            # warning), which is never what we want.
451            typedesc = typemap[field_type][0]
452            if typedesc.has_arg != bool(field_arg):
453                msg = f"docfield field list type {field_type!r} "
454                if typedesc.has_arg:
455                    msg += "requires an argument."
456                else:
457                    msg += "takes no arguments."
458                logger.warning(msg, location=field)
459        else:
460            # This is unrecognized entirely. It's valid rST to use
461            # arbitrary fields, but let's ensure the documentation
462            # writer has done this intentionally.
463            valid = ", ".join(sorted(set(typemap) | allowed_fields))
464            msg = (
465                f"Unrecognized field list name {field_label!r}.\n"
466                f"Valid fields for qapi:{self.objtype} are: {valid}\n"
467                "\n"
468                "If this usage is intentional, please add it to "
469                "'qapi_allowed_fields' in docs/conf.py."
470            )
471            logger.warning(msg, location=field)
472
473    def transform_content(self, content_node: addnodes.desc_content) -> None:
474        # This hook runs after before_content and the nested parse, but
475        # before the DocFieldTransformer is executed.
476        super().transform_content(content_node)
477
478        self._add_infopips(content_node)
479
480        # Validate field lists.
481        for child in content_node:
482            if isinstance(child, nodes.field_list):
483                for field in child.children:
484                    assert isinstance(field, nodes.field)
485                    self._validate_field(field)
486
487
488class SpecialTypedField(CompatTypedField):
489    def make_field(self, *args: Any, **kwargs: Any) -> nodes.field:
490        ret = super().make_field(*args, **kwargs)
491
492        # Look for the characteristic " -- " text node that Sphinx
493        # inserts for each TypedField entry ...
494        for node in ret.traverse(lambda n: str(n) == " -- "):
495            par = node.parent
496            if par.children[0].astext() != "q_dummy":
497                continue
498
499            # If the first node's text is q_dummy, this is a dummy
500            # field we want to strip down to just its contents.
501            del par.children[:-1]
502
503        return ret
504
505
506class QAPICommand(QAPIObject):
507    """Description of a QAPI Command."""
508
509    doc_field_types = QAPIObject.doc_field_types.copy()
510    doc_field_types.extend(
511        [
512            # :arg TypeName ArgName: descr
513            SpecialTypedField(
514                "argument",
515                label=_("Arguments"),
516                names=("arg",),
517                typerolename="type",
518                can_collapse=False,
519            ),
520            # :error: descr
521            CompatField(
522                "error",
523                label=_("Errors"),
524                names=("error", "errors"),
525                has_arg=False,
526            ),
527            # :return TypeName: descr
528            CompatGroupedField(
529                "returnvalue",
530                label=_("Return"),
531                rolename="type",
532                names=("return",),
533                can_collapse=True,
534            ),
535            # :return-nodesc: TypeName
536            CompatField(
537                "returnvalue",
538                label=_("Return"),
539                names=("return-nodesc",),
540                bodyrolename="type",
541                has_arg=False,
542            ),
543        ]
544    )
545
546
547class QAPIEnum(QAPIObject):
548    """Description of a QAPI Enum."""
549
550    doc_field_types = QAPIObject.doc_field_types.copy()
551    doc_field_types.extend(
552        [
553            # :value name: descr
554            CompatGroupedField(
555                "value",
556                label=_("Values"),
557                names=("value",),
558                can_collapse=False,
559            )
560        ]
561    )
562
563
564class QAPIAlternate(QAPIObject):
565    """Description of a QAPI Alternate."""
566
567    doc_field_types = QAPIObject.doc_field_types.copy()
568    doc_field_types.extend(
569        [
570            # :alt type name: descr
571            CompatTypedField(
572                "alternative",
573                label=_("Alternatives"),
574                names=("alt",),
575                typerolename="type",
576                can_collapse=False,
577            ),
578        ]
579    )
580
581
582class QAPIObjectWithMembers(QAPIObject):
583    """Base class for Events/Structs/Unions"""
584
585    doc_field_types = QAPIObject.doc_field_types.copy()
586    doc_field_types.extend(
587        [
588            # :member type name: descr
589            SpecialTypedField(
590                "member",
591                label=_("Members"),
592                names=("memb",),
593                typerolename="type",
594                can_collapse=False,
595            ),
596        ]
597    )
598
599
600class QAPIEvent(QAPIObjectWithMembers):
601    # pylint: disable=too-many-ancestors
602    """Description of a QAPI Event."""
603
604
605class QAPIJSONObject(QAPIObjectWithMembers):
606    # pylint: disable=too-many-ancestors
607    """Description of a QAPI Object: structs and unions."""
608
609
610class QAPIModule(QAPIDescription):
611    """
612    Directive to mark description of a new module.
613
614    This directive doesn't generate any special formatting, and is just
615    a pass-through for the content body. Named section titles are
616    allowed in the content body.
617
618    Use this directive to create entries for the QAPI module in the
619    global index and the QAPI index; as well as to associate subsequent
620    definitions with the module they are defined in for purposes of
621    search and QAPI index organization.
622
623    :arg: The name of the module.
624    :opt no-index: Don't add cross-reference targets or index entries.
625    :opt no-typesetting: Don't render the content body (but preserve any
626       cross-reference target IDs in the squelched output.)
627
628    Example::
629
630       .. qapi:module:: block-core
631          :no-index:
632          :no-typesetting:
633
634          Lorem ipsum, dolor sit amet ...
635    """
636
637    def run(self) -> List[Node]:
638        modname = self.arguments[0].strip()
639        self.env.ref_context["qapi:module"] = modname
640        ret = super().run()
641
642        # ObjectDescription always creates a visible signature bar. We
643        # want module items to be "invisible", however.
644
645        # Extract the content body of the directive:
646        assert isinstance(ret[-1], addnodes.desc)
647        desc_node = ret.pop(-1)
648        assert isinstance(desc_node.children[1], addnodes.desc_content)
649        ret.extend(desc_node.children[1].children)
650
651        # Re-home node_ids so anchor refs still work:
652        node_ids: List[str]
653        if node_ids := [
654            node_id
655            for el in desc_node.children[0].traverse(nodes.Element)
656            for node_id in cast(List[str], el.get("ids", ()))
657        ]:
658            target_node = nodes.target(ids=node_ids)
659            ret.insert(1, target_node)
660
661        return ret
662
663
664class QAPINamespace(SphinxDirective):
665    has_content = False
666    required_arguments = 1
667
668    def run(self) -> List[Node]:
669        namespace = self.arguments[0].strip()
670        self.env.ref_context["qapi:namespace"] = namespace
671
672        return []
673
674
675class QAPIIndex(Index):
676    """
677    Index subclass to provide the QAPI definition index.
678    """
679
680    # pylint: disable=too-few-public-methods
681
682    name = "index"
683    localname = _("QAPI Index")
684    shortname = _("QAPI Index")
685    namespace = ""
686
687    def generate(
688        self,
689        docnames: Optional[Iterable[str]] = None,
690    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
691        assert isinstance(self.domain, QAPIDomain)
692        content: Dict[str, List[IndexEntry]] = {}
693        collapse = False
694
695        for objname, obj in self.domain.objects.items():
696            if docnames and obj.docname not in docnames:
697                continue
698
699            ns, _mod, name = QAPIDescription.split_fqn(objname)
700
701            if self.namespace != ns:
702                continue
703
704            # Add an alphabetical entry:
705            entries = content.setdefault(name[0].upper(), [])
706            entries.append(
707                IndexEntry(
708                    name, 0, obj.docname, obj.node_id, obj.objtype, "", ""
709                )
710            )
711
712            # Add a categorical entry:
713            category = obj.objtype.title() + "s"
714            entries = content.setdefault(category, [])
715            entries.append(
716                IndexEntry(name, 0, obj.docname, obj.node_id, "", "", "")
717            )
718
719        # Sort entries within each category alphabetically
720        for category in content:
721            content[category] = sorted(content[category])
722
723        # Sort the categories themselves; type names first, ABC entries last.
724        sorted_content = sorted(
725            content.items(),
726            key=lambda x: (len(x[0]) == 1, x[0]),
727        )
728        return sorted_content, collapse
729
730
731class QAPIDomain(Domain):
732    """QAPI language domain."""
733
734    name = "qapi"
735    label = "QAPI"
736
737    # This table associates cross-reference object types (key) with an
738    # ObjType instance, which defines the valid cross-reference roles
739    # for each object type.
740    #
741    # e.g., the :qapi:type: cross-reference role can refer to enum,
742    # struct, union, or alternate objects; but :qapi:obj: can refer to
743    # anything. Each object also gets its own targeted cross-reference role.
744    object_types: Dict[str, ObjType] = {
745        "module": ObjType(_("module"), "mod", "any"),
746        "command": ObjType(_("command"), "cmd", "any"),
747        "event": ObjType(_("event"), "event", "any"),
748        "enum": ObjType(_("enum"), "enum", "type", "any"),
749        "object": ObjType(_("object"), "obj", "type", "any"),
750        "alternate": ObjType(_("alternate"), "alt", "type", "any"),
751    }
752
753    # Each of these provides a rST directive,
754    # e.g. .. qapi:module:: block-core
755    directives = {
756        "namespace": QAPINamespace,
757        "module": QAPIModule,
758        "command": QAPICommand,
759        "event": QAPIEvent,
760        "enum": QAPIEnum,
761        "object": QAPIJSONObject,
762        "alternate": QAPIAlternate,
763    }
764
765    # These are all cross-reference roles; e.g.
766    # :qapi:cmd:`query-block`. The keys correlate to the names used in
767    # the object_types table values above.
768    roles = {
769        "mod": QAPIXRefRole(),
770        "cmd": QAPIXRefRole(),
771        "event": QAPIXRefRole(),
772        "enum": QAPIXRefRole(),
773        "obj": QAPIXRefRole(),  # specifically structs and unions.
774        "alt": QAPIXRefRole(),
775        # reference any data type (excludes modules, commands, events)
776        "type": QAPIXRefRole(),
777        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
778    }
779
780    # Moved into the data property at runtime;
781    # this is the internal index of reference-able objects.
782    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
783        "objects": {},  # fullname -> ObjectEntry
784    }
785
786    # Index pages to generate; each entry is an Index class.
787    indices = [
788        QAPIIndex,
789    ]
790
791    @property
792    def objects(self) -> Dict[str, ObjectEntry]:
793        ret = self.data.setdefault("objects", {})
794        return ret  # type: ignore[no-any-return]
795
796    def setup(self) -> None:
797        namespaces = set(self.env.app.config.qapi_namespaces)
798        for namespace in namespaces:
799            new_index: Type[QAPIIndex] = types.new_class(
800                f"{namespace}Index", bases=(QAPIIndex,)
801            )
802            new_index.name = f"{namespace.lower()}-index"
803            new_index.localname = _(f"{namespace} Index")
804            new_index.shortname = _(f"{namespace} Index")
805            new_index.namespace = namespace
806
807            self.indices.append(new_index)
808
809        super().setup()
810
811    def note_object(
812        self,
813        name: str,
814        objtype: str,
815        node_id: str,
816        aliased: bool = False,
817        location: Any = None,
818    ) -> None:
819        """Note a QAPI object for cross reference."""
820        if name in self.objects:
821            other = self.objects[name]
822            if other.aliased and aliased is False:
823                # The original definition found. Override it!
824                pass
825            elif other.aliased is False and aliased:
826                # The original definition is already registered.
827                return
828            else:
829                # duplicated
830                logger.warning(
831                    __(
832                        "duplicate object description of %s, "
833                        "other instance in %s, use :no-index: for one of them"
834                    ),
835                    name,
836                    other.docname,
837                    location=location,
838                )
839        self.objects[name] = ObjectEntry(
840            self.env.docname, node_id, objtype, aliased
841        )
842
843    def clear_doc(self, docname: str) -> None:
844        for fullname, obj in list(self.objects.items()):
845            if obj.docname == docname:
846                del self.objects[fullname]
847
848    def merge_domaindata(
849        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
850    ) -> None:
851        for fullname, obj in otherdata["objects"].items():
852            if obj.docname in docnames:
853                # Sphinx's own python domain doesn't appear to bother to
854                # check for collisions. Assert they don't happen and
855                # we'll fix it if/when the case arises.
856                assert fullname not in self.objects, (
857                    "bug - collision on merge?"
858                    f" {fullname=} {obj=} {self.objects[fullname]=}"
859                )
860                self.objects[fullname] = obj
861
862    def find_obj(
863        self, namespace: str, modname: str, name: str, typ: Optional[str]
864    ) -> List[Tuple[str, ObjectEntry]]:
865        """
866        Find a QAPI object for "name", maybe using contextual information.
867
868        Returns a list of (name, object entry) tuples.
869
870        :param namespace: The current namespace context (if any!) under
871            which we are searching.
872        :param modname: The current module context (if any!) under
873            which we are searching.
874        :param name: The name of the x-ref to resolve; may or may not
875            include leading context.
876        :param type: The role name of the x-ref we're resolving, if
877            provided. This is absent for "any" role lookups.
878        """
879        if not name:
880            return []
881
882        # ##
883        # what to search for
884        # ##
885
886        parts = list(QAPIDescription.split_fqn(name))
887        explicit = tuple(bool(x) for x in parts)
888
889        # Fill in the blanks where possible:
890        if namespace and not parts[0]:
891            parts[0] = namespace
892        if modname and not parts[1]:
893            parts[1] = modname
894
895        implicit_fqn = ""
896        if all(parts):
897            implicit_fqn = f"{parts[0]}:{parts[1]}.{parts[2]}"
898
899        if typ is None:
900            # :any: lookup, search everything:
901            objtypes: List[str] = list(self.object_types)
902        else:
903            # type is specified and will be a role (e.g. obj, mod, cmd)
904            # convert this to eligible object types (e.g. command, module)
905            # using the QAPIDomain.object_types table.
906            objtypes = self.objtypes_for_role(typ, [])
907
908        # ##
909        # search!
910        # ##
911
912        def _search(needle: str) -> List[str]:
913            if (
914                needle
915                and needle in self.objects
916                and self.objects[needle].objtype in objtypes
917            ):
918                return [needle]
919            return []
920
921        if found := _search(name):
922            # Exact match!
923            pass
924        elif found := _search(implicit_fqn):
925            # Exact match using contextual information to fill in the gaps.
926            pass
927        else:
928            # No exact hits, perform applicable fuzzy searches.
929            searches = []
930
931            esc = tuple(re.escape(s) for s in parts)
932
933            # Try searching for ns:*.name or ns:name
934            if explicit[0] and not explicit[1]:
935                searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
936            # Try searching for *:module.name or module.name
937            if explicit[1] and not explicit[0]:
938                searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
939            # Try searching for context-ns:*.name or context-ns:name
940            if parts[0] and not (explicit[0] or explicit[1]):
941                searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
942            # Try searching for *:context-mod.name or context-mod.name
943            if parts[1] and not (explicit[0] or explicit[1]):
944                searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
945            # Try searching for *:name, *.name, or name
946            if not (explicit[0] or explicit[1]):
947                searches.append(f"(^|:|\\.){esc[2]}$")
948
949            for search in searches:
950                if found := [
951                    oname
952                    for oname in self.objects
953                    if re.search(search, oname)
954                    and self.objects[oname].objtype in objtypes
955                ]:
956                    break
957
958        matches = [(oname, self.objects[oname]) for oname in found]
959        if len(matches) > 1:
960            matches = [m for m in matches if not m[1].aliased]
961        return matches
962
963    def resolve_xref(
964        self,
965        env: BuildEnvironment,
966        fromdocname: str,
967        builder: Builder,
968        typ: str,
969        target: str,
970        node: pending_xref,
971        contnode: Element,
972    ) -> nodes.reference | None:
973        namespace = node.get("qapi:namespace")
974        modname = node.get("qapi:module")
975        matches = self.find_obj(namespace, modname, target, typ)
976
977        if not matches:
978            # Normally, we could pass warn_dangling=True to QAPIXRefRole(),
979            # but that will trigger on references to these built-in types,
980            # which we'd like to ignore instead.
981
982            # Take care of that warning here instead, so long as the
983            # reference isn't to one of our built-in core types.
984            if target not in (
985                "string",
986                "number",
987                "int",
988                "boolean",
989                "null",
990                "value",
991                "q_empty",
992            ):
993                logger.warning(
994                    __("qapi:%s reference target not found: %r"),
995                    typ,
996                    target,
997                    type="ref",
998                    subtype="qapi",
999                    location=node,
1000                )
1001            return None
1002
1003        if len(matches) > 1:
1004            logger.warning(
1005                __("more than one target found for cross-reference %r: %s"),
1006                target,
1007                ", ".join(match[0] for match in matches),
1008                type="ref",
1009                subtype="qapi",
1010                location=node,
1011            )
1012
1013        name, obj = matches[0]
1014        return make_refnode(
1015            builder, fromdocname, obj.docname, obj.node_id, contnode, name
1016        )
1017
1018    def resolve_any_xref(
1019        self,
1020        env: BuildEnvironment,
1021        fromdocname: str,
1022        builder: Builder,
1023        target: str,
1024        node: pending_xref,
1025        contnode: Element,
1026    ) -> List[Tuple[str, nodes.reference]]:
1027        results: List[Tuple[str, nodes.reference]] = []
1028        matches = self.find_obj(
1029            node.get("qapi:namespace"), node.get("qapi:module"), target, None
1030        )
1031        for name, obj in matches:
1032            rolename = self.role_for_objtype(obj.objtype)
1033            assert rolename is not None
1034            role = f"qapi:{rolename}"
1035            refnode = make_refnode(
1036                builder, fromdocname, obj.docname, obj.node_id, contnode, name
1037            )
1038            results.append((role, refnode))
1039        return results
1040
1041
1042def setup(app: Sphinx) -> Dict[str, Any]:
1043    app.setup_extension("sphinx.directives")
1044    app.add_config_value(
1045        "qapi_allowed_fields",
1046        set(),
1047        "env",  # Setting impacts parsing phase
1048        types=set,
1049    )
1050    app.add_config_value(
1051        "qapi_namespaces",
1052        set(),
1053        "env",
1054        types=set,
1055    )
1056    app.add_domain(QAPIDomain)
1057
1058    return {
1059        "version": "1.0",
1060        "env_version": 1,
1061        "parallel_read_safe": True,
1062        "parallel_write_safe": True,
1063    }
1064