1""" 2QAPI domain extension. 3""" 4 5# The best laid plans of mice and men, ... 6# pylint: disable=too-many-lines 7 8from __future__ import annotations 9 10import re 11import types 12from typing import ( 13 TYPE_CHECKING, 14 List, 15 NamedTuple, 16 Tuple, 17 Type, 18 cast, 19) 20 21from docutils import nodes 22from docutils.parsers.rst import directives 23from sphinx import addnodes 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.docutils import SphinxDirective 35from sphinx.util.nodes import make_id, make_refnode 36 37from compat import ( 38 CompatField, 39 CompatGroupedField, 40 CompatTypedField, 41 KeywordNode, 42 ParserFix, 43 Signature, 44 SpaceNode, 45) 46 47 48if 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 66logger = logging.getLogger(__name__) 67 68 69def _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 82class ObjectEntry(NamedTuple): 83 docname: str 84 node_id: str 85 objtype: str 86 aliased: bool 87 88 89class 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 160class 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 292class 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 488class 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 506class 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 547class 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 564class 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 582class 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 600class QAPIEvent(QAPIObjectWithMembers): 601 # pylint: disable=too-many-ancestors 602 """Description of a QAPI Event.""" 603 604 605class QAPIJSONObject(QAPIObjectWithMembers): 606 # pylint: disable=too-many-ancestors 607 """Description of a QAPI Object: structs and unions.""" 608 609 610class 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 664class 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 675class 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 731class 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 1042def 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 } 1064