xref: /openbmc/qemu/docs/sphinx/qapi_domain.py (revision 758bbdcd1212bb0b7369fea249114ad02d4b7fd0)
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        }
218    )
219
220    def get_signature_prefix(self) -> List[nodes.Node]:
221        """Return a prefix to put before the object name in the signature."""
222        assert self.objtype
223        return [
224            KeywordNode("", self.objtype.title()),
225            SpaceNode(" "),
226        ]
227
228    def get_signature_suffix(self) -> List[nodes.Node]:
229        """Return a suffix to put after the object name in the signature."""
230        return []
231
232    def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
233        """
234        Transform a QAPI definition name into RST nodes.
235
236        This method was originally intended for handling function
237        signatures. In the QAPI domain, however, we only pass the
238        definition name as the directive argument and handle everything
239        else in the content body with field lists.
240
241        As such, the only argument here is "sig", which is just the QAPI
242        definition name.
243        """
244        modname = self.options.get(
245            "module", self.env.ref_context.get("qapi:module")
246        )
247
248        signode["fullname"] = sig
249        signode["module"] = modname
250        sig_prefix = self.get_signature_prefix()
251        if sig_prefix:
252            signode += addnodes.desc_annotation(
253                str(sig_prefix), "", *sig_prefix
254            )
255        signode += addnodes.desc_name(sig, sig)
256        signode += self.get_signature_suffix()
257
258        return sig
259
260
261class QAPICommand(QAPIObject):
262    """Description of a QAPI Command."""
263
264    # Nothing unique for now! Changed in later commits O:-)
265
266
267class QAPIModule(QAPIDescription):
268    """
269    Directive to mark description of a new module.
270
271    This directive doesn't generate any special formatting, and is just
272    a pass-through for the content body. Named section titles are
273    allowed in the content body.
274
275    Use this directive to create entries for the QAPI module in the
276    global index and the QAPI index; as well as to associate subsequent
277    definitions with the module they are defined in for purposes of
278    search and QAPI index organization.
279
280    :arg: The name of the module.
281    :opt no-index: Don't add cross-reference targets or index entries.
282    :opt no-typesetting: Don't render the content body (but preserve any
283       cross-reference target IDs in the squelched output.)
284
285    Example::
286
287       .. qapi:module:: block-core
288          :no-index:
289          :no-typesetting:
290
291          Lorem ipsum, dolor sit amet ...
292    """
293
294    def run(self) -> List[Node]:
295        modname = self.arguments[0].strip()
296        self.env.ref_context["qapi:module"] = modname
297        ret = super().run()
298
299        # ObjectDescription always creates a visible signature bar. We
300        # want module items to be "invisible", however.
301
302        # Extract the content body of the directive:
303        assert isinstance(ret[-1], addnodes.desc)
304        desc_node = ret.pop(-1)
305        assert isinstance(desc_node.children[1], addnodes.desc_content)
306        ret.extend(desc_node.children[1].children)
307
308        # Re-home node_ids so anchor refs still work:
309        node_ids: List[str]
310        if node_ids := [
311            node_id
312            for el in desc_node.children[0].traverse(nodes.Element)
313            for node_id in cast(List[str], el.get("ids", ()))
314        ]:
315            target_node = nodes.target(ids=node_ids)
316            ret.insert(1, target_node)
317
318        return ret
319
320
321class QAPIIndex(Index):
322    """
323    Index subclass to provide the QAPI definition index.
324    """
325
326    # pylint: disable=too-few-public-methods
327
328    name = "index"
329    localname = _("QAPI Index")
330    shortname = _("QAPI Index")
331
332    def generate(
333        self,
334        docnames: Optional[Iterable[str]] = None,
335    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
336        assert isinstance(self.domain, QAPIDomain)
337        content: Dict[str, List[IndexEntry]] = {}
338        collapse = False
339
340        # list of all object (name, ObjectEntry) pairs, sorted by name
341        # (ignoring the module)
342        objects = sorted(
343            self.domain.objects.items(),
344            key=lambda x: x[0].split(".")[-1].lower(),
345        )
346
347        for objname, obj in objects:
348            if docnames and obj.docname not in docnames:
349                continue
350
351            # Strip the module name out:
352            objname = objname.split(".")[-1]
353
354            # Add an alphabetical entry:
355            entries = content.setdefault(objname[0].upper(), [])
356            entries.append(
357                IndexEntry(
358                    objname, 0, obj.docname, obj.node_id, obj.objtype, "", ""
359                )
360            )
361
362            # Add a categorical entry:
363            category = obj.objtype.title() + "s"
364            entries = content.setdefault(category, [])
365            entries.append(
366                IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "")
367            )
368
369        # alphabetically sort categories; type names first, ABC entries last.
370        sorted_content = sorted(
371            content.items(),
372            key=lambda x: (len(x[0]) == 1, x[0]),
373        )
374        return sorted_content, collapse
375
376
377class QAPIDomain(Domain):
378    """QAPI language domain."""
379
380    name = "qapi"
381    label = "QAPI"
382
383    # This table associates cross-reference object types (key) with an
384    # ObjType instance, which defines the valid cross-reference roles
385    # for each object type.
386    object_types: Dict[str, ObjType] = {
387        "module": ObjType(_("module"), "mod", "any"),
388        "command": ObjType(_("command"), "cmd", "any"),
389    }
390
391    # Each of these provides a rST directive,
392    # e.g. .. qapi:module:: block-core
393    directives = {
394        "module": QAPIModule,
395        "command": QAPICommand,
396    }
397
398    # These are all cross-reference roles; e.g.
399    # :qapi:cmd:`query-block`. The keys correlate to the names used in
400    # the object_types table values above.
401    roles = {
402        "mod": QAPIXRefRole(),
403        "cmd": QAPIXRefRole(),
404        "any": QAPIXRefRole(),  # reference *any* type of QAPI object.
405    }
406
407    # Moved into the data property at runtime;
408    # this is the internal index of reference-able objects.
409    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
410        "objects": {},  # fullname -> ObjectEntry
411    }
412
413    # Index pages to generate; each entry is an Index class.
414    indices = [
415        QAPIIndex,
416    ]
417
418    @property
419    def objects(self) -> Dict[str, ObjectEntry]:
420        ret = self.data.setdefault("objects", {})
421        return ret  # type: ignore[no-any-return]
422
423    def note_object(
424        self,
425        name: str,
426        objtype: str,
427        node_id: str,
428        aliased: bool = False,
429        location: Any = None,
430    ) -> None:
431        """Note a QAPI object for cross reference."""
432        if name in self.objects:
433            other = self.objects[name]
434            if other.aliased and aliased is False:
435                # The original definition found. Override it!
436                pass
437            elif other.aliased is False and aliased:
438                # The original definition is already registered.
439                return
440            else:
441                # duplicated
442                logger.warning(
443                    __(
444                        "duplicate object description of %s, "
445                        "other instance in %s, use :no-index: for one of them"
446                    ),
447                    name,
448                    other.docname,
449                    location=location,
450                )
451        self.objects[name] = ObjectEntry(
452            self.env.docname, node_id, objtype, aliased
453        )
454
455    def clear_doc(self, docname: str) -> None:
456        for fullname, obj in list(self.objects.items()):
457            if obj.docname == docname:
458                del self.objects[fullname]
459
460    def merge_domaindata(
461        self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
462    ) -> None:
463        for fullname, obj in otherdata["objects"].items():
464            if obj.docname in docnames:
465                # Sphinx's own python domain doesn't appear to bother to
466                # check for collisions. Assert they don't happen and
467                # we'll fix it if/when the case arises.
468                assert fullname not in self.objects, (
469                    "bug - collision on merge?"
470                    f" {fullname=} {obj=} {self.objects[fullname]=}"
471                )
472                self.objects[fullname] = obj
473
474    def find_obj(
475        self, modname: str, name: str, typ: Optional[str]
476    ) -> list[tuple[str, ObjectEntry]]:
477        """
478        Find a QAPI object for "name", perhaps using the given module.
479
480        Returns a list of (name, object entry) tuples.
481
482        :param modname: The current module context (if any!)
483                        under which we are searching.
484        :param name: The name of the x-ref to resolve;
485                     may or may not include a leading module.
486        :param type: The role name of the x-ref we're resolving, if provided.
487                     (This is absent for "any" lookups.)
488        """
489        if not name:
490            return []
491
492        names: list[str] = []
493        matches: list[tuple[str, ObjectEntry]] = []
494
495        fullname = name
496        if "." in fullname:
497            # We're searching for a fully qualified reference;
498            # ignore the contextual module.
499            pass
500        elif modname:
501            # We're searching for something from somewhere;
502            # try searching the current module first.
503            # e.g. :qapi:cmd:`query-block` or `query-block` is being searched.
504            fullname = f"{modname}.{name}"
505
506        if typ is None:
507            # type isn't specified, this is a generic xref.
508            # search *all* qapi-specific object types.
509            objtypes: List[str] = list(self.object_types)
510        else:
511            # type is specified and will be a role (e.g. obj, mod, cmd)
512            # convert this to eligible object types (e.g. command, module)
513            # using the QAPIDomain.object_types table.
514            objtypes = self.objtypes_for_role(typ, [])
515
516        if name in self.objects and self.objects[name].objtype in objtypes:
517            names = [name]
518        elif (
519            fullname in self.objects
520            and self.objects[fullname].objtype in objtypes
521        ):
522            names = [fullname]
523        else:
524            # exact match wasn't found; e.g. we are searching for
525            # `query-block` from a different (or no) module.
526            searchname = "." + name
527            names = [
528                oname
529                for oname in self.objects
530                if oname.endswith(searchname)
531                and self.objects[oname].objtype in objtypes
532            ]
533
534        matches = [(oname, self.objects[oname]) for oname in names]
535        if len(matches) > 1:
536            matches = [m for m in matches if not m[1].aliased]
537        return matches
538
539    def resolve_xref(
540        self,
541        env: BuildEnvironment,
542        fromdocname: str,
543        builder: Builder,
544        typ: str,
545        target: str,
546        node: pending_xref,
547        contnode: Element,
548    ) -> nodes.reference | None:
549        modname = node.get("qapi:module")
550        matches = self.find_obj(modname, target, typ)
551
552        if not matches:
553            return None
554
555        if len(matches) > 1:
556            logger.warning(
557                __("more than one target found for cross-reference %r: %s"),
558                target,
559                ", ".join(match[0] for match in matches),
560                type="ref",
561                subtype="qapi",
562                location=node,
563            )
564
565        name, obj = matches[0]
566        return make_refnode(
567            builder, fromdocname, obj.docname, obj.node_id, contnode, name
568        )
569
570    def resolve_any_xref(
571        self,
572        env: BuildEnvironment,
573        fromdocname: str,
574        builder: Builder,
575        target: str,
576        node: pending_xref,
577        contnode: Element,
578    ) -> List[Tuple[str, nodes.reference]]:
579        results: List[Tuple[str, nodes.reference]] = []
580        matches = self.find_obj(node.get("qapi:module"), target, None)
581        for name, obj in matches:
582            rolename = self.role_for_objtype(obj.objtype)
583            assert rolename is not None
584            role = f"qapi:{rolename}"
585            refnode = make_refnode(
586                builder, fromdocname, obj.docname, obj.node_id, contnode, name
587            )
588            results.append((role, refnode))
589        return results
590
591
592def setup(app: Sphinx) -> Dict[str, Any]:
593    app.setup_extension("sphinx.directives")
594    app.add_domain(QAPIDomain)
595
596    return {
597        "version": "1.0",
598        "env_version": 1,
599        "parallel_read_safe": True,
600        "parallel_write_safe": True,
601    }
602