master
py 1,063 lines 33.5 KB
Raw
1 """
2 QAPI domain extension.
3 """
4
5 # The best laid plans of mice and men, ...
6 # pylint: disable=too-many-lines
7
8 from __future__ import annotations
9
10 import re
11 import types
12 from typing import (
13 TYPE_CHECKING,
14 List,
15 NamedTuple,
16 Tuple,
17 Type,
18 cast,
19 )
20
21 from docutils import nodes
22 from docutils.parsers.rst import directives
23 from sphinx import addnodes
24 from sphinx.directives import ObjectDescription
25 from sphinx.domains import (
26 Domain,
27 Index,
28 IndexEntry,
29 ObjType,
30 )
31 from sphinx.locale import _, __
32 from sphinx.roles import XRefRole
33 from sphinx.util import logging
34 from sphinx.util.docutils import SphinxDirective
35 from sphinx.util.nodes import make_id, make_refnode
36
37 from compat import (
38 CompatField,
39 CompatGroupedField,
40 CompatTypedField,
41 KeywordNode,
42 ParserFix,
43 Signature,
44 SpaceNode,
45 )
46
47
48 if TYPE_CHECKING:
49 from typing import (
50 AbstractSet,
51 Any,
52 Dict,
53 Iterable,
54 Optional,
55 Union,
56 )
57
58 from docutils.nodes import Element, Node
59 from sphinx.addnodes import desc_signature, pending_xref
60 from sphinx.application import Sphinx
61 from sphinx.builders import Builder
62 from sphinx.environment import BuildEnvironment
63 from sphinx.util.typing import OptionSpec
64
65
66 logger = logging.getLogger(__name__)
67
68
69 def _unpack_field(
70 field: nodes.Node,
71 ) -> Tuple[nodes.field_name, nodes.field_body]:
72 """
73 docutils helper: unpack a field node in a type-safe manner.
74 """
75 assert isinstance(field, nodes.field)
76 assert len(field.children) == 2
77 assert isinstance(field.children[0], nodes.field_name)
78 assert isinstance(field.children[1], nodes.field_body)
79 return (field.children[0], field.children[1])
80
81
82 class ObjectEntry(NamedTuple):
83 docname: str
84 node_id: str
85 objtype: str
86 aliased: bool
87
88
89 class QAPIXRefRole(XRefRole):
90
91 def process_link(
92 self,
93 env: BuildEnvironment,
94 refnode: Element,
95 has_explicit_title: bool,
96 title: str,
97 target: str,
98 ) -> tuple[str, str]:
99 refnode["qapi:namespace"] = env.ref_context.get("qapi:namespace")
100 refnode["qapi:module"] = env.ref_context.get("qapi:module")
101
102 # Cross-references that begin with a tilde adjust the title to
103 # only show the reference without a leading module, even if one
104 # was provided. This is a Sphinx-standard syntax; give it
105 # priority over QAPI-specific type markup below.
106 hide_module = False
107 if target.startswith("~"):
108 hide_module = True
109 target = target[1:]
110
111 # Type names that end with "?" are considered optional
112 # arguments and should be documented as such, but it's not
113 # part of the xref itself.
114 if target.endswith("?"):
115 refnode["qapi:optional"] = True
116 target = target[:-1]
117
118 # Type names wrapped in brackets denote lists. strip the
119 # brackets and remember to add them back later.
120 if target.startswith("[") and target.endswith("]"):
121 refnode["qapi:array"] = True
122 target = target[1:-1]
123
124 if has_explicit_title:
125 # Don't mess with the title at all if it was explicitly set.
126 # Explicit title syntax for references is e.g.
127 # :qapi:type:`target <explicit title>`
128 # and this explicit title overrides everything else here.
129 return title, target
130
131 title = target
132 if hide_module:
133 title = target.split(".")[-1]
134
135 return title, target
136
137 def result_nodes(
138 self,
139 document: nodes.document,
140 env: BuildEnvironment,
141 node: Element,
142 is_ref: bool,
143 ) -> Tuple[List[nodes.Node], List[nodes.system_message]]:
144
145 # node here is the pending_xref node (or whatever nodeclass was
146 # configured at XRefRole class instantiation time).
147 results: List[nodes.Node] = [node]
148
149 if node.get("qapi:array"):
150 results.insert(0, nodes.literal("[", "["))
151 results.append(nodes.literal("]", "]"))
152
153 if node.get("qapi:optional"):
154 results.append(nodes.Text(", "))
155 results.append(nodes.emphasis("?", "optional"))
156
157 return results, []
158
159
160 class QAPIDescription(ParserFix):
161 """
162 Generic QAPI description.
163
164 This is meant to be an abstract class, not instantiated
165 directly. This class handles the abstract details of indexing, the
166 TOC, and reference targets for QAPI descriptions.
167 """
168
169 def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
170 # pylint: disable=unused-argument
171
172 # Do nothing. The return value here is the "name" of the entity
173 # being documented; for QAPI, this is the same as the
174 # "signature", which is just a name.
175
176 # Normally this method must also populate signode with nodes to
177 # render the signature; here we do nothing instead - the
178 # subclasses will handle this.
179 return sig
180
181 def get_index_text(self, name: Signature) -> Tuple[str, str]:
182 """Return the text for the index entry of the object."""
183
184 # NB: this is used for the global index, not the QAPI index.
185 return ("single", f"{name} (QMP {self.objtype})")
186
187 def _get_context(self) -> Tuple[str, str]:
188 namespace = self.options.get(
189 "namespace", self.env.ref_context.get("qapi:namespace", "")
190 )
191 modname = self.options.get(
192 "module", self.env.ref_context.get("qapi:module", "")
193 )
194
195 return namespace, modname
196
197 def _get_fqn(self, name: Signature) -> str:
198 namespace, modname = self._get_context()
199
200 # If we're documenting a module, don't include the module as
201 # part of the FQN; we ARE the module!
202 if self.objtype == "module":
203 modname = ""
204
205 if modname:
206 name = f"{modname}.{name}"
207 if namespace:
208 name = f"{namespace}:{name}"
209 return name
210
211 def add_target_and_index(
212 self, name: Signature, sig: str, signode: desc_signature
213 ) -> None:
214 # pylint: disable=unused-argument
215
216 # name is the return value of handle_signature.
217 # sig is the original, raw text argument to handle_signature.
218 # For QAPI, these are identical, currently.
219
220 assert self.objtype
221
222 if not (fullname := signode.get("fullname", "")):
223 fullname = self._get_fqn(name)
224
225 node_id = make_id(
226 self.env, self.state.document, self.objtype, fullname
227 )
228 signode["ids"].append(node_id)
229
230 self.state.document.note_explicit_target(signode)
231 domain = cast(QAPIDomain, self.env.get_domain("qapi"))
232 domain.note_object(fullname, self.objtype, node_id, location=signode)
233
234 if "no-index-entry" not in self.options:
235 arity, indextext = self.get_index_text(name)
236 assert self.indexnode is not None
237 if indextext:
238 self.indexnode["entries"].append(
239 (arity, indextext, node_id, "", None)
240 )
241
242 @staticmethod
243 def split_fqn(name: str) -> Tuple[str, str, str]:
244 if ":" in name:
245 ns, name = name.split(":")
246 else:
247 ns = ""
248
249 if "." in name:
250 module, name = name.split(".")
251 else:
252 module = ""
253
254 return (ns, module, name)
255
256 def _object_hierarchy_parts(
257 self, sig_node: desc_signature
258 ) -> Tuple[str, ...]:
259 if "fullname" not in sig_node:
260 return ()
261 return self.split_fqn(sig_node["fullname"])
262
263 def _toc_entry_name(self, sig_node: desc_signature) -> str:
264 # This controls the name in the TOC and on the sidebar.
265
266 # This is the return type of _object_hierarchy_parts().
267 toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ()))
268 if not toc_parts:
269 return ""
270
271 config = self.env.app.config
272 namespace, modname, name = toc_parts
273
274 if config.toc_object_entries_show_parents == "domain":
275 ret = name
276 if modname and modname != self.env.ref_context.get(
277 "qapi:module", ""
278 ):
279 ret = f"{modname}.{name}"
280 if namespace and namespace != self.env.ref_context.get(
281 "qapi:namespace", ""
282 ):
283 ret = f"{namespace}:{ret}"
284 return ret
285 if config.toc_object_entries_show_parents == "hide":
286 return name
287 if config.toc_object_entries_show_parents == "all":
288 return sig_node.get("fullname", name)
289 return ""
290
291
292 class QAPIObject(QAPIDescription):
293 """
294 Description of a generic QAPI object.
295
296 It's not used directly, but is instead subclassed by specific directives.
297 """
298
299 # Inherit some standard options from Sphinx's ObjectDescription
300 option_spec: OptionSpec = ( # type:ignore[misc]
301 ObjectDescription.option_spec.copy()
302 )
303 option_spec.update(
304 {
305 # Context overrides:
306 "namespace": directives.unchanged,
307 "module": directives.unchanged,
308 # These are QAPI originals:
309 "since": directives.unchanged,
310 "ifcond": directives.unchanged,
311 "deprecated": directives.flag,
312 "unstable": directives.flag,
313 }
314 )
315
316 doc_field_types = [
317 # :feat name: descr
318 CompatGroupedField(
319 "feature",
320 label=_("Features"),
321 names=("feat",),
322 can_collapse=False,
323 ),
324 ]
325
326 def get_signature_prefix(self) -> List[nodes.Node]:
327 """Return a prefix to put before the object name in the signature."""
328 assert self.objtype
329 return [
330 KeywordNode("", self.objtype.title()),
331 SpaceNode(" "),
332 ]
333
334 def get_signature_suffix(self) -> List[nodes.Node]:
335 """Return a suffix to put after the object name in the signature."""
336 ret: List[nodes.Node] = []
337
338 if "since" in self.options:
339 ret += [
340 SpaceNode(" "),
341 addnodes.desc_sig_element(
342 "", f"(Since: {self.options['since']})"
343 ),
344 ]
345
346 return ret
347
348 def handle_signature(self, sig: str, signode: desc_signature) -> Signature:
349 """
350 Transform a QAPI definition name into RST nodes.
351
352 This method was originally intended for handling function
353 signatures. In the QAPI domain, however, we only pass the
354 definition name as the directive argument and handle everything
355 else in the content body with field lists.
356
357 As such, the only argument here is "sig", which is just the QAPI
358 definition name.
359 """
360 # No module or domain info allowed in the signature!
361 assert ":" not in sig
362 assert "." not in sig
363
364 namespace, modname = self._get_context()
365 signode["fullname"] = self._get_fqn(sig)
366 signode["namespace"] = namespace
367 signode["module"] = modname
368
369 sig_prefix = self.get_signature_prefix()
370 if sig_prefix:
371 signode += addnodes.desc_annotation(
372 str(sig_prefix), "", *sig_prefix
373 )
374 signode += addnodes.desc_name(sig, sig)
375 signode += self.get_signature_suffix()
376
377 return sig
378
379 def _add_infopips(self, contentnode: addnodes.desc_content) -> None:
380 # Add various eye-catches and things that go below the signature
381 # bar, but precede the user-defined content.
382 infopips = nodes.container()
383 infopips.attributes["classes"].append("qapi-infopips")
384
385 def _add_pip(
386 source: str, content: Union[str, List[nodes.Node]], classname: str
387 ) -> None:
388 node = nodes.container(source)
389 if isinstance(content, str):
390 node.append(nodes.Text(content))
391 else:
392 node.extend(content)
393 node.attributes["classes"].extend(["qapi-infopip", classname])
394 infopips.append(node)
395
396 if "deprecated" in self.options:
397 _add_pip(
398 ":deprecated:",
399 f"This {self.objtype} is deprecated.",
400 "qapi-deprecated",
401 )
402
403 if "unstable" in self.options:
404 _add_pip(
405 ":unstable:",
406 f"This {self.objtype} is unstable/experimental.",
407 "qapi-unstable",
408 )
409
410 if self.options.get("ifcond", ""):
411 ifcond = self.options["ifcond"]
412 _add_pip(
413 f":ifcond: {ifcond}",
414 [
415 nodes.emphasis("", "Availability"),
416 nodes.Text(": "),
417 nodes.literal(ifcond, ifcond),
418 ],
419 "qapi-ifcond",
420 )
421
422 if infopips.children:
423 contentnode.insert(0, infopips)
424
425 def _validate_field(self, field: nodes.field) -> None:
426 """Validate field lists in this QAPI Object Description."""
427 name, _ = _unpack_field(field)
428 allowed_fields = set(self.env.app.config.qapi_allowed_fields)
429
430 field_label = name.astext()
431 if field_label in allowed_fields:
432 # Explicitly allowed field list name, OK.
433 return
434
435 try:
436 # split into field type and argument (if provided)
437 # e.g. `:arg type name: descr` is
438 # field_type = "arg", field_arg = "type name".
439 field_type, field_arg = field_label.split(None, 1)
440 except ValueError:
441 # No arguments provided
442 field_type = field_label
443 field_arg = ""
444
445 typemap = self.get_field_type_map()
446 if field_type in typemap:
447 # This is a special docfield, yet-to-be-processed. Catch
448 # correct names, but incorrect arguments. This mismatch WILL
449 # cause Sphinx to render this field incorrectly (without a
450 # warning), which is never what we want.
451 typedesc = typemap[field_type][0]
452 if typedesc.has_arg != bool(field_arg):
453 msg = f"docfield field list type {field_type!r} "
454 if typedesc.has_arg:
455 msg += "requires an argument."
456 else:
457 msg += "takes no arguments."
458 logger.warning(msg, location=field)
459 else:
460 # This is unrecognized entirely. It's valid rST to use
461 # arbitrary fields, but let's ensure the documentation
462 # writer has done this intentionally.
463 valid = ", ".join(sorted(set(typemap) | allowed_fields))
464 msg = (
465 f"Unrecognized field list name {field_label!r}.\n"
466 f"Valid fields for qapi:{self.objtype} are: {valid}\n"
467 "\n"
468 "If this usage is intentional, please add it to "
469 "'qapi_allowed_fields' in docs/conf.py."
470 )
471 logger.warning(msg, location=field)
472
473 def transform_content(self, content_node: addnodes.desc_content) -> None:
474 # This hook runs after before_content and the nested parse, but
475 # before the DocFieldTransformer is executed.
476 super().transform_content(content_node)
477
478 self._add_infopips(content_node)
479
480 # Validate field lists.
481 for child in content_node:
482 if isinstance(child, nodes.field_list):
483 for field in child.children:
484 assert isinstance(field, nodes.field)
485 self._validate_field(field)
486
487
488 class SpecialTypedField(CompatTypedField):
489 def make_field(self, *args: Any, **kwargs: Any) -> nodes.field:
490 ret = super().make_field(*args, **kwargs)
491
492 # Look for the characteristic " -- " text node that Sphinx
493 # inserts for each TypedField entry ...
494 for node in ret.traverse(lambda n: str(n) == " -- "):
495 par = node.parent
496 if par.children[0].astext() != "q_dummy":
497 continue
498
499 # If the first node's text is q_dummy, this is a dummy
500 # field we want to strip down to just its contents.
501 del par.children[:-1]
502
503 return ret
504
505
506 class QAPICommand(QAPIObject):
507 """Description of a QAPI Command."""
508
509 doc_field_types = QAPIObject.doc_field_types.copy()
510 doc_field_types.extend(
511 [
512 # :arg TypeName ArgName: descr
513 SpecialTypedField(
514 "argument",
515 label=_("Arguments"),
516 names=("arg",),
517 typerolename="type",
518 can_collapse=False,
519 ),
520 # :error: descr
521 CompatField(
522 "error",
523 label=_("Errors"),
524 names=("error", "errors"),
525 has_arg=False,
526 ),
527 # :return TypeName: descr
528 CompatGroupedField(
529 "returnvalue",
530 label=_("Return"),
531 rolename="type",
532 names=("return",),
533 can_collapse=True,
534 ),
535 # :return-nodesc: TypeName
536 CompatField(
537 "returnvalue",
538 label=_("Return"),
539 names=("return-nodesc",),
540 bodyrolename="type",
541 has_arg=False,
542 ),
543 ]
544 )
545
546
547 class QAPIEnum(QAPIObject):
548 """Description of a QAPI Enum."""
549
550 doc_field_types = QAPIObject.doc_field_types.copy()
551 doc_field_types.extend(
552 [
553 # :value name: descr
554 CompatGroupedField(
555 "value",
556 label=_("Values"),
557 names=("value",),
558 can_collapse=False,
559 )
560 ]
561 )
562
563
564 class QAPIAlternate(QAPIObject):
565 """Description of a QAPI Alternate."""
566
567 doc_field_types = QAPIObject.doc_field_types.copy()
568 doc_field_types.extend(
569 [
570 # :alt type name: descr
571 CompatTypedField(
572 "alternative",
573 label=_("Alternatives"),
574 names=("alt",),
575 typerolename="type",
576 can_collapse=False,
577 ),
578 ]
579 )
580
581
582 class QAPIObjectWithMembers(QAPIObject):
583 """Base class for Events/Structs/Unions"""
584
585 doc_field_types = QAPIObject.doc_field_types.copy()
586 doc_field_types.extend(
587 [
588 # :member type name: descr
589 SpecialTypedField(
590 "member",
591 label=_("Members"),
592 names=("memb",),
593 typerolename="type",
594 can_collapse=False,
595 ),
596 ]
597 )
598
599
600 class QAPIEvent(QAPIObjectWithMembers):
601 # pylint: disable=too-many-ancestors
602 """Description of a QAPI Event."""
603
604
605 class QAPIJSONObject(QAPIObjectWithMembers):
606 # pylint: disable=too-many-ancestors
607 """Description of a QAPI Object: structs and unions."""
608
609
610 class QAPIModule(QAPIDescription):
611 """
612 Directive to mark description of a new module.
613
614 This directive doesn't generate any special formatting, and is just
615 a pass-through for the content body. Named section titles are
616 allowed in the content body.
617
618 Use this directive to create entries for the QAPI module in the
619 global index and the QAPI index; as well as to associate subsequent
620 definitions with the module they are defined in for purposes of
621 search and QAPI index organization.
622
623 :arg: The name of the module.
624 :opt no-index: Don't add cross-reference targets or index entries.
625 :opt no-typesetting: Don't render the content body (but preserve any
626 cross-reference target IDs in the squelched output.)
627
628 Example::
629
630 .. qapi:module:: block-core
631 :no-index:
632 :no-typesetting:
633
634 Lorem ipsum, dolor sit amet ...
635 """
636
637 def run(self) -> List[Node]:
638 modname = self.arguments[0].strip()
639 self.env.ref_context["qapi:module"] = modname
640 ret = super().run()
641
642 # ObjectDescription always creates a visible signature bar. We
643 # want module items to be "invisible", however.
644
645 # Extract the content body of the directive:
646 assert isinstance(ret[-1], addnodes.desc)
647 desc_node = ret.pop(-1)
648 assert isinstance(desc_node.children[1], addnodes.desc_content)
649 ret.extend(desc_node.children[1].children)
650
651 # Re-home node_ids so anchor refs still work:
652 node_ids: List[str]
653 if node_ids := [
654 node_id
655 for el in desc_node.children[0].traverse(nodes.Element)
656 for node_id in cast(List[str], el.get("ids", ()))
657 ]:
658 target_node = nodes.target(ids=node_ids)
659 ret.insert(1, target_node)
660
661 return ret
662
663
664 class QAPINamespace(SphinxDirective):
665 has_content = False
666 required_arguments = 1
667
668 def run(self) -> List[Node]:
669 namespace = self.arguments[0].strip()
670 self.env.ref_context["qapi:namespace"] = namespace
671
672 return []
673
674
675 class QAPIIndex(Index):
676 """
677 Index subclass to provide the QAPI definition index.
678 """
679
680 # pylint: disable=too-few-public-methods
681
682 name = "index"
683 localname = _("QAPI Index")
684 shortname = _("QAPI Index")
685 namespace = ""
686
687 def generate(
688 self,
689 docnames: Optional[Iterable[str]] = None,
690 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
691 assert isinstance(self.domain, QAPIDomain)
692 content: Dict[str, List[IndexEntry]] = {}
693 collapse = False
694
695 for objname, obj in self.domain.objects.items():
696 if docnames and obj.docname not in docnames:
697 continue
698
699 ns, _mod, name = QAPIDescription.split_fqn(objname)
700
701 if self.namespace != ns:
702 continue
703
704 # Add an alphabetical entry:
705 entries = content.setdefault(name[0].upper(), [])
706 entries.append(
707 IndexEntry(
708 name, 0, obj.docname, obj.node_id, obj.objtype, "", ""
709 )
710 )
711
712 # Add a categorical entry:
713 category = obj.objtype.title() + "s"
714 entries = content.setdefault(category, [])
715 entries.append(
716 IndexEntry(name, 0, obj.docname, obj.node_id, "", "", "")
717 )
718
719 # Sort entries within each category alphabetically
720 for category in content:
721 content[category] = sorted(content[category])
722
723 # Sort the categories themselves; type names first, ABC entries last.
724 sorted_content = sorted(
725 content.items(),
726 key=lambda x: (len(x[0]) == 1, x[0]),
727 )
728 return sorted_content, collapse
729
730
731 class QAPIDomain(Domain):
732 """QAPI language domain."""
733
734 name = "qapi"
735 label = "QAPI"
736
737 # This table associates cross-reference object types (key) with an
738 # ObjType instance, which defines the valid cross-reference roles
739 # for each object type.
740 #
741 # e.g., the :qapi:type: cross-reference role can refer to enum,
742 # struct, union, or alternate objects; but :qapi:obj: can refer to
743 # anything. Each object also gets its own targeted cross-reference role.
744 object_types: Dict[str, ObjType] = {
745 "module": ObjType(_("module"), "mod", "any"),
746 "command": ObjType(_("command"), "cmd", "any"),
747 "event": ObjType(_("event"), "event", "any"),
748 "enum": ObjType(_("enum"), "enum", "type", "any"),
749 "object": ObjType(_("object"), "obj", "type", "any"),
750 "alternate": ObjType(_("alternate"), "alt", "type", "any"),
751 }
752
753 # Each of these provides a rST directive,
754 # e.g. .. qapi:module:: block-core
755 directives = {
756 "namespace": QAPINamespace,
757 "module": QAPIModule,
758 "command": QAPICommand,
759 "event": QAPIEvent,
760 "enum": QAPIEnum,
761 "object": QAPIJSONObject,
762 "alternate": QAPIAlternate,
763 }
764
765 # These are all cross-reference roles; e.g.
766 # :qapi:cmd:`query-block`. The keys correlate to the names used in
767 # the object_types table values above.
768 roles = {
769 "mod": QAPIXRefRole(),
770 "cmd": QAPIXRefRole(),
771 "event": QAPIXRefRole(),
772 "enum": QAPIXRefRole(),
773 "obj": QAPIXRefRole(), # specifically structs and unions.
774 "alt": QAPIXRefRole(),
775 # reference any data type (excludes modules, commands, events)
776 "type": QAPIXRefRole(),
777 "any": QAPIXRefRole(), # reference *any* type of QAPI object.
778 }
779
780 # Moved into the data property at runtime;
781 # this is the internal index of reference-able objects.
782 initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
783 "objects": {}, # fullname -> ObjectEntry
784 }
785
786 # Index pages to generate; each entry is an Index class.
787 indices = [
788 QAPIIndex,
789 ]
790
791 @property
792 def objects(self) -> Dict[str, ObjectEntry]:
793 ret = self.data.setdefault("objects", {})
794 return ret # type: ignore[no-any-return]
795
796 def setup(self) -> None:
797 namespaces = set(self.env.app.config.qapi_namespaces)
798 for namespace in namespaces:
799 new_index: Type[QAPIIndex] = types.new_class(
800 f"{namespace}Index", bases=(QAPIIndex,)
801 )
802 new_index.name = f"{namespace.lower()}-index"
803 new_index.localname = _(f"{namespace} Index")
804 new_index.shortname = _(f"{namespace} Index")
805 new_index.namespace = namespace
806
807 self.indices.append(new_index)
808
809 super().setup()
810
811 def note_object(
812 self,
813 name: str,
814 objtype: str,
815 node_id: str,
816 aliased: bool = False,
817 location: Any = None,
818 ) -> None:
819 """Note a QAPI object for cross reference."""
820 if name in self.objects:
821 other = self.objects[name]
822 if other.aliased and aliased is False:
823 # The original definition found. Override it!
824 pass
825 elif other.aliased is False and aliased:
826 # The original definition is already registered.
827 return
828 else:
829 # duplicated
830 logger.warning(
831 __(
832 "duplicate object description of %s, "
833 "other instance in %s, use :no-index: for one of them"
834 ),
835 name,
836 other.docname,
837 location=location,
838 )
839 self.objects[name] = ObjectEntry(
840 self.env.docname, node_id, objtype, aliased
841 )
842
843 def clear_doc(self, docname: str) -> None:
844 for fullname, obj in list(self.objects.items()):
845 if obj.docname == docname:
846 del self.objects[fullname]
847
848 def merge_domaindata(
849 self, docnames: AbstractSet[str], otherdata: Dict[str, Any]
850 ) -> None:
851 for fullname, obj in otherdata["objects"].items():
852 if obj.docname in docnames:
853 # Sphinx's own python domain doesn't appear to bother to
854 # check for collisions. Assert they don't happen and
855 # we'll fix it if/when the case arises.
856 assert fullname not in self.objects, (
857 "bug - collision on merge?"
858 f" {fullname=} {obj=} {self.objects[fullname]=}"
859 )
860 self.objects[fullname] = obj
861
862 def find_obj(
863 self, namespace: str, modname: str, name: str, typ: Optional[str]
864 ) -> List[Tuple[str, ObjectEntry]]:
865 """
866 Find a QAPI object for "name", maybe using contextual information.
867
868 Returns a list of (name, object entry) tuples.
869
870 :param namespace: The current namespace context (if any!) under
871 which we are searching.
872 :param modname: The current module context (if any!) under
873 which we are searching.
874 :param name: The name of the x-ref to resolve; may or may not
875 include leading context.
876 :param type: The role name of the x-ref we're resolving, if
877 provided. This is absent for "any" role lookups.
878 """
879 if not name:
880 return []
881
882 # ##
883 # what to search for
884 # ##
885
886 parts = list(QAPIDescription.split_fqn(name))
887 explicit = tuple(bool(x) for x in parts)
888
889 # Fill in the blanks where possible:
890 if namespace and not parts[0]:
891 parts[0] = namespace
892 if modname and not parts[1]:
893 parts[1] = modname
894
895 implicit_fqn = ""
896 if all(parts):
897 implicit_fqn = f"{parts[0]}:{parts[1]}.{parts[2]}"
898
899 if typ is None:
900 # :any: lookup, search everything:
901 objtypes: List[str] = list(self.object_types)
902 else:
903 # type is specified and will be a role (e.g. obj, mod, cmd)
904 # convert this to eligible object types (e.g. command, module)
905 # using the QAPIDomain.object_types table.
906 objtypes = self.objtypes_for_role(typ, [])
907
908 # ##
909 # search!
910 # ##
911
912 def _search(needle: str) -> List[str]:
913 if (
914 needle
915 and needle in self.objects
916 and self.objects[needle].objtype in objtypes
917 ):
918 return [needle]
919 return []
920
921 if found := _search(name):
922 # Exact match!
923 pass
924 elif found := _search(implicit_fqn):
925 # Exact match using contextual information to fill in the gaps.
926 pass
927 else:
928 # No exact hits, perform applicable fuzzy searches.
929 searches = []
930
931 esc = tuple(re.escape(s) for s in parts)
932
933 # Try searching for ns:*.name or ns:name
934 if explicit[0] and not explicit[1]:
935 searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
936 # Try searching for *:module.name or module.name
937 if explicit[1] and not explicit[0]:
938 searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
939 # Try searching for context-ns:*.name or context-ns:name
940 if parts[0] and not (explicit[0] or explicit[1]):
941 searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$")
942 # Try searching for *:context-mod.name or context-mod.name
943 if parts[1] and not (explicit[0] or explicit[1]):
944 searches.append(f"(^|:){esc[1]}\\.{esc[2]}$")
945 # Try searching for *:name, *.name, or name
946 if not (explicit[0] or explicit[1]):
947 searches.append(f"(^|:|\\.){esc[2]}$")
948
949 for search in searches:
950 if found := [
951 oname
952 for oname in self.objects
953 if re.search(search, oname)
954 and self.objects[oname].objtype in objtypes
955 ]:
956 break
957
958 matches = [(oname, self.objects[oname]) for oname in found]
959 if len(matches) > 1:
960 matches = [m for m in matches if not m[1].aliased]
961 return matches
962
963 def resolve_xref(
964 self,
965 env: BuildEnvironment,
966 fromdocname: str,
967 builder: Builder,
968 typ: str,
969 target: str,
970 node: pending_xref,
971 contnode: Element,
972 ) -> nodes.reference | None:
973 namespace = node.get("qapi:namespace")
974 modname = node.get("qapi:module")
975 matches = self.find_obj(namespace, modname, target, typ)
976
977 if not matches:
978 # Normally, we could pass warn_dangling=True to QAPIXRefRole(),
979 # but that will trigger on references to these built-in types,
980 # which we'd like to ignore instead.
981
982 # Take care of that warning here instead, so long as the
983 # reference isn't to one of our built-in core types.
984 if target not in (
985 "string",
986 "number",
987 "int",
988 "boolean",
989 "null",
990 "value",
991 "q_empty",
992 ):
993 logger.warning(
994 __("qapi:%s reference target not found: %r"),
995 typ,
996 target,
997 type="ref",
998 subtype="qapi",
999 location=node,
1000 )
1001 return None
1002
1003 if len(matches) > 1:
1004 logger.warning(
1005 __("more than one target found for cross-reference %r: %s"),
1006 target,
1007 ", ".join(match[0] for match in matches),
1008 type="ref",
1009 subtype="qapi",
1010 location=node,
1011 )
1012
1013 name, obj = matches[0]
1014 return make_refnode(
1015 builder, fromdocname, obj.docname, obj.node_id, contnode, name
1016 )
1017
1018 def resolve_any_xref(
1019 self,
1020 env: BuildEnvironment,
1021 fromdocname: str,
1022 builder: Builder,
1023 target: str,
1024 node: pending_xref,
1025 contnode: Element,
1026 ) -> List[Tuple[str, nodes.reference]]:
1027 results: List[Tuple[str, nodes.reference]] = []
1028 matches = self.find_obj(
1029 node.get("qapi:namespace"), node.get("qapi:module"), target, None
1030 )
1031 for name, obj in matches:
1032 rolename = self.role_for_objtype(obj.objtype)
1033 assert rolename is not None
1034 role = f"qapi:{rolename}"
1035 refnode = make_refnode(
1036 builder, fromdocname, obj.docname, obj.node_id, contnode, name
1037 )
1038 results.append((role, refnode))
1039 return results
1040
1041
1042 def setup(app: Sphinx) -> Dict[str, Any]:
1043 app.setup_extension("sphinx.directives")
1044 app.add_config_value(
1045 "qapi_allowed_fields",
1046 set(),
1047 "env", # Setting impacts parsing phase
1048 types=set,
1049 )
1050 app.add_config_value(
1051 "qapi_namespaces",
1052 set(),
1053 "env",
1054 types=set,
1055 )
1056 app.add_domain(QAPIDomain)
1057
1058 return {
1059 "version": "1.0",
1060 "env_version": 1,
1061 "parallel_read_safe": True,
1062 "parallel_write_safe": True,
1063 }