xref: /qemu/docs/sphinx/qapi_domain.py (revision fc8da54ec43cf6302ac496d8fe54832812954679)
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        ]
536    )
537
538
539class QAPIEnum(QAPIObject):
540    """Description of a QAPI Enum."""
541
542    doc_field_types = QAPIObject.doc_field_types.copy()
543    doc_field_types.extend(
544        [
545            # :value name: descr
546            CompatGroupedField(
547                "value",
548                label=_("Values"),
549                names=("value",),
550                can_collapse=False,
551            )
552        ]
553    )
554
555
556class QAPIAlternate(QAPIObject):
557    """Description of a QAPI Alternate."""
558
559    doc_field_types = QAPIObject.doc_field_types.copy()
560    doc_field_types.extend(
561        [
562            # :alt type name: descr
563            CompatTypedField(
564                "alternative",
565                label=_("Alternatives"),
566                names=("alt",),
567                typerolename="type",
568                can_collapse=False,
569            ),
570        ]
571    )
572
573
574class QAPIObjectWithMembers(QAPIObject):
575    """Base class for Events/Structs/Unions"""
576
577    doc_field_types = QAPIObject.doc_field_types.copy()
578    doc_field_types.extend(
579        [
580            # :member type name: descr
581            SpecialTypedField(
582                "member",
583                label=_("Members"),
584                names=("memb",),
585                typerolename="type",
586                can_collapse=False,
587            ),
588        ]
589    )
590
591
592class QAPIEvent(QAPIObjectWithMembers):
593    # pylint: disable=too-many-ancestors
594    """Description of a QAPI Event."""
595
596
597class QAPIJSONObject(QAPIObjectWithMembers):
598    # pylint: disable=too-many-ancestors
599    """Description of a QAPI Object: structs and unions."""
600
601
602class QAPIModule(QAPIDescription):
603    """
604    Directive to mark description of a new module.
605
606    This directive doesn't generate any special formatting, and is just
607    a pass-through for the content body. Named section titles are
608    allowed in the content body.
609
610    Use this directive to create entries for the QAPI module in the
611    global index and the QAPI index; as well as to associate subsequent
612    definitions with the module they are defined in for purposes of
613    search and QAPI index organization.
614
615    :arg: The name of the module.
616    :opt no-index: Don't add cross-reference targets or index entries.
617    :opt no-typesetting: Don't render the content body (but preserve any
618       cross-reference target IDs in the squelched output.)
619
620    Example::
621
622       .. qapi:module:: block-core
623          :no-index:
624          :no-typesetting:
625
626          Lorem ipsum, dolor sit amet ...
627    """
628
629    def run(self) -> List[Node]:
630        modname = self.arguments[0].strip()
631        self.env.ref_context["qapi:module"] = modname
632        ret = super().run()
633
634        # ObjectDescription always creates a visible signature bar. We
635        # want module items to be "invisible", however.
636
637        # Extract the content body of the directive:
638        assert isinstance(ret[-1], addnodes.desc)
639        desc_node = ret.pop(-1)
640        assert isinstance(desc_node.children[1], addnodes.desc_content)
641        ret.extend(desc_node.children[1].children)
642
643        # Re-home node_ids so anchor refs still work:
644        node_ids: List[str]
645        if node_ids := [
646            node_id
647            for el in desc_node.children[0].traverse(nodes.Element)
648            for node_id in cast(List[str], el.get("ids", ()))
649        ]:
650            target_node = nodes.target(ids=node_ids)
651            ret.insert(1, target_node)
652
653        return ret
654
655
656class QAPINamespace(SphinxDirective):
657    has_content = False
658    required_arguments = 1
659
660    def run(self) -> List[Node]:
661        namespace = self.arguments[0].strip()
662        self.env.ref_context["qapi:namespace"] = namespace
663
664        return []
665
666
667class QAPIIndex(Index):
668    """
669    Index subclass to provide the QAPI definition index.
670    """
671
672    # pylint: disable=too-few-public-methods
673
674    name = "index"
675    localname = _("QAPI Index")
676    shortname = _("QAPI Index")
677    namespace = ""
678
679    def generate(
680        self,
681        docnames: Optional[Iterable[str]] = None,
682    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
683        assert isinstance(self.domain, QAPIDomain)
684        content: Dict[str, List[IndexEntry]] = {}
685        collapse = False
686
687        for objname, obj in self.domain.objects.items():
688            if docnames and obj.docname not in docnames:
689                continue
690
691            ns, _mod, name = QAPIDescription.split_fqn(objname)
692
693            if self.namespace != ns:
694                continue
695
696            # Add an alphabetical entry:
697            entries = content.setdefault(name[0].upper(), [])
698            entries.append(
699                IndexEntry(
700                    name, 0, obj.docname, obj.node_id, obj.objtype, "", ""
701                )
702            )
703
704            # Add a categorical entry:
705            category = obj.objtype.title() + "s"
706            entries = content.setdefault(category, [])
707            entries.append(
708                IndexEntry(name, 0, obj.docname, obj.node_id, "", "", "")
709            )
710
711        # Sort entries within each category alphabetically
712        for category in content:
713            content[category] = sorted(content[category])
714
715        # Sort the categories themselves; type names first, ABC entries last.
716        sorted_content = sorted(
717            content.items(),
718            key=lambda x: (len(x[0]) == 1, x[0]),
719        )
720        return sorted_content, collapse
721
722
723class QAPIDomain(Domain):
724    """QAPI language domain."""
725
726    name = "qapi"
727    label = "QAPI"
728
729    # This table associates cross-reference object types (key) with an
730    # ObjType instance, which defines the valid cross-reference roles
731    # for each object type.
732    #
733    # e.g., the :qapi:type: cross-reference role can refer to enum,
734    # struct, union, or alternate objects; but :qapi:obj: can refer to
735    # anything. Each object also gets its own targeted cross-reference role.
736    object_types: Dict[str, ObjType] = {
737        "module": ObjType(_("module"), "mod", "any"),
738        "command": ObjType(_("command"), "cmd", "any"),
739        "event": ObjType(_("event"), "event", "any"),
740        "enum": ObjType(_("enum"), "enum", "type", "any"),
741        "object": ObjType(_("object"), "obj", "type", "any"),
742        "alternate": ObjType(_("alternate"), "alt", "type", "any"),
743    }
744
745    # Each of these provides a rST directive,
746    # e.g. .. qapi:module:: block-core
747    directives = {
748        "namespace": QAPINamespace,
749        "module": QAPIModule,
750        "command": QAPICommand,
751        "event": QAPIEvent,
752        "enum": QAPIEnum,
753        "object": QAPIJSONObject,
754        "alternate": QAPIAlternate,
755    }
756
757    # These are all cross-reference roles; e.g.
758    # :qapi:cmd:`query-block`. The keys correlate to the names used in
759    # the object_types table values above.
760    roles = {
761        "mod": QAPIXRefRole(),
762        "cmd": QAPIXRefRole(),
763        "event": QAPIXRefRole(),
764        "enum": QAPIXRefRole(),
765        "obj": QAPIXRefRole(),  # specifically structs and unions.
766        "alt": QAPIXRefRole(),
767        # reference any data type (excludes modules, commands, events)
768        "type": QAPIXRefRole(),
769        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
770    }
771
772    # Moved into the data property at runtime;
773    # this is the internal index of reference-able objects.
774    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
775        "objects": {},  # fullname -> ObjectEntry
776    }
777
778    # Index pages to generate; each entry is an Index class.
779    indices = [
780        QAPIIndex,
781    ]
782
783    @property
784    def objects(self) -> Dict[str, ObjectEntry]:
785        ret = self.data.setdefault("objects", {})
786        return ret  # type: ignore[no-any-return]
787
788    def setup(self) -> None:
789        namespaces = set(self.env.app.config.qapi_namespaces)
790        for namespace in namespaces:
791            new_index: Type[QAPIIndex] = types.new_class(
792                f"{namespace}Index", bases=(QAPIIndex,)
793            )
794            new_index.name = f"{namespace.lower()}-index"
795            new_index.localname = _(f"{namespace} Index")
796            new_index.shortname = _(f"{namespace} Index")
797            new_index.namespace = namespace
798
799            self.indices.append(new_index)
800
801        super().setup()
802
803    def note_object(
804        self,
805        name: str,
806        objtype: str,
807        node_id: str,
808        aliased: bool = False,
809        location: Any = None,
810    ) -> None:
811        """Note a QAPI object for cross reference."""
812        if name in self.objects:
813            other = self.objects[name]
814            if other.aliased and aliased is False:
815                # The original definition found. Override it!
816                pass
817            elif other.aliased is False and aliased:
818                # The original definition is already registered.
819                return
820            else:
821                # duplicated
822                logger.warning(
823                    __(
824                        "duplicate object description of %s, "
825                        "other instance in %s, use :no-index: for one of them"
826                    ),
827                    name,
828                    other.docname,
829                    location=location,
830                )
831        self.objects[name] = ObjectEntry(
832            self.env.docname, node_id, objtype, aliased
833        )
834
835    def clear_doc(self, docname: str) -> None:
836        for fullname, obj in list(self.objects.items()):
837            if obj.docname == docname:
838                del self.objects[fullname]
839
840    def merge_domaindata(
841        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
842    ) -> None:
843        for fullname, obj in otherdata["objects"].items():
844            if obj.docname in docnames:
845                # Sphinx's own python domain doesn't appear to bother to
846                # check for collisions. Assert they don't happen and
847                # we'll fix it if/when the case arises.
848                assert fullname not in self.objects, (
849                    "bug - collision on merge?"
850                    f" {fullname=} {obj=} {self.objects[fullname]=}"
851                )
852                self.objects[fullname] = obj
853
854    def find_obj(
855        self, namespace: str, modname: str, name: str, typ: Optional[str]
856    ) -> List[Tuple[str, ObjectEntry]]:
857        """
858        Find a QAPI object for "name", maybe using contextual information.
859
860        Returns a list of (name, object entry) tuples.
861
862        :param namespace: The current namespace context (if any!) under
863            which we are searching.
864        :param modname: The current module context (if any!) under
865            which we are searching.
866        :param name: The name of the x-ref to resolve; may or may not
867            include leading context.
868        :param type: The role name of the x-ref we're resolving, if
869            provided. This is absent for "any" role lookups.
870        """
871        if not name:
872            return []
873
874        # ##
875        # what to search for
876        # ##
877
878        parts = list(QAPIDescription.split_fqn(name))
879        explicit = tuple(bool(x) for x in parts)
880
881        # Fill in the blanks where possible:
882        if namespace and not parts[0]:
883            parts[0] = namespace
884        if modname and not parts[1]:
885            parts[1] = modname
886
887        implicit_fqn = ""
888        if all(parts):
889            implicit_fqn = f"{parts[0]}:{parts[1]}.{parts[2]}"
890
891        if typ is None:
892            # :any: lookup, search everything:
893            objtypes: List[str] = list(self.object_types)
894        else:
895            # type is specified and will be a role (e.g. obj, mod, cmd)
896            # convert this to eligible object types (e.g. command, module)
897            # using the QAPIDomain.object_types table.
898            objtypes = self.objtypes_for_role(typ, [])
899
900        # ##
901        # search!
902        # ##
903
904        def _search(needle: str) -> List[str]:
905            if (
906                needle
907                and needle in self.objects
908                and self.objects[needle].objtype in objtypes
909            ):
910                return [needle]
911            return []
912
913        if found := _search(name):
914            # Exact match!
915            pass
916        elif found := _search(implicit_fqn):
917            # Exact match using contextual information to fill in the gaps.
918            pass
919        else:
920            # No exact hits, perform applicable fuzzy searches.
921            searches = []
922
923            esc = tuple(re.escape(s) for s in parts)
924
925            # Try searching for ns:*.name or ns:name
926            if explicit[0] and not explicit[1]:
927                searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
928            # Try searching for *:module.name or module.name
929            if explicit[1] and not explicit[0]:
930                searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
931            # Try searching for context-ns:*.name or context-ns:name
932            if parts[0] and not (explicit[0] or explicit[1]):
933                searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
934            # Try searching for *:context-mod.name or context-mod.name
935            if parts[1] and not (explicit[0] or explicit[1]):
936                searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
937            # Try searching for *:name, *.name, or name
938            if not (explicit[0] or explicit[1]):
939                searches.append(f"(^|:|\\.){esc[2]}$")
940
941            for search in searches:
942                if found := [
943                    oname
944                    for oname in self.objects
945                    if re.search(search, oname)
946                    and self.objects[oname].objtype in objtypes
947                ]:
948                    break
949
950        matches = [(oname, self.objects[oname]) for oname in found]
951        if len(matches) > 1:
952            matches = [m for m in matches if not m[1].aliased]
953        return matches
954
955    def resolve_xref(
956        self,
957        env: BuildEnvironment,
958        fromdocname: str,
959        builder: Builder,
960        typ: str,
961        target: str,
962        node: pending_xref,
963        contnode: Element,
964    ) -> nodes.reference | None:
965        namespace = node.get("qapi:namespace")
966        modname = node.get("qapi:module")
967        matches = self.find_obj(namespace, modname, target, typ)
968
969        if not matches:
970            # Normally, we could pass warn_dangling=True to QAPIXRefRole(),
971            # but that will trigger on references to these built-in types,
972            # which we'd like to ignore instead.
973
974            # Take care of that warning here instead, so long as the
975            # reference isn't to one of our built-in core types.
976            if target not in (
977                "string",
978                "number",
979                "int",
980                "boolean",
981                "null",
982                "value",
983                "q_empty",
984            ):
985                logger.warning(
986                    __("qapi:%s reference target not found: %r"),
987                    typ,
988                    target,
989                    type="ref",
990                    subtype="qapi",
991                    location=node,
992                )
993            return None
994
995        if len(matches) > 1:
996            logger.warning(
997                __("more than one target found for cross-reference %r: %s"),
998                target,
999                ", ".join(match[0] for match in matches),
1000                type="ref",
1001                subtype="qapi",
1002                location=node,
1003            )
1004
1005        name, obj = matches[0]
1006        return make_refnode(
1007            builder, fromdocname, obj.docname, obj.node_id, contnode, name
1008        )
1009
1010    def resolve_any_xref(
1011        self,
1012        env: BuildEnvironment,
1013        fromdocname: str,
1014        builder: Builder,
1015        target: str,
1016        node: pending_xref,
1017        contnode: Element,
1018    ) -> List[Tuple[str, nodes.reference]]:
1019        results: List[Tuple[str, nodes.reference]] = []
1020        matches = self.find_obj(
1021            node.get("qapi:namespace"), node.get("qapi:module"), target, None
1022        )
1023        for name, obj in matches:
1024            rolename = self.role_for_objtype(obj.objtype)
1025            assert rolename is not None
1026            role = f"qapi:{rolename}"
1027            refnode = make_refnode(
1028                builder, fromdocname, obj.docname, obj.node_id, contnode, name
1029            )
1030            results.append((role, refnode))
1031        return results
1032
1033
1034def setup(app: Sphinx) -> Dict[str, Any]:
1035    app.setup_extension("sphinx.directives")
1036    app.add_config_value(
1037        "qapi_allowed_fields",
1038        set(),
1039        "env",  # Setting impacts parsing phase
1040        types=set,
1041    )
1042    app.add_config_value(
1043        "qapi_namespaces",
1044        set(),
1045        "env",
1046        types=set,
1047    )
1048    app.add_domain(QAPIDomain)
1049
1050    return {
1051        "version": "1.0",
1052        "env_version": 1,
1053        "parallel_read_safe": True,
1054        "parallel_write_safe": True,
1055    }
1056