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