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