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