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