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