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