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 ] 536 ) 537 538 539class QAPIEnum(QAPIObject): 540 """Description of a QAPI Enum.""" 541 542 doc_field_types = QAPIObject.doc_field_types.copy() 543 doc_field_types.extend( 544 [ 545 # :value name: descr 546 CompatGroupedField( 547 "value", 548 label=_("Values"), 549 names=("value",), 550 can_collapse=False, 551 ) 552 ] 553 ) 554 555 556class QAPIAlternate(QAPIObject): 557 """Description of a QAPI Alternate.""" 558 559 doc_field_types = QAPIObject.doc_field_types.copy() 560 doc_field_types.extend( 561 [ 562 # :alt type name: descr 563 CompatTypedField( 564 "alternative", 565 label=_("Alternatives"), 566 names=("alt",), 567 typerolename="type", 568 can_collapse=False, 569 ), 570 ] 571 ) 572 573 574class QAPIObjectWithMembers(QAPIObject): 575 """Base class for Events/Structs/Unions""" 576 577 doc_field_types = QAPIObject.doc_field_types.copy() 578 doc_field_types.extend( 579 [ 580 # :member type name: descr 581 SpecialTypedField( 582 "member", 583 label=_("Members"), 584 names=("memb",), 585 typerolename="type", 586 can_collapse=False, 587 ), 588 ] 589 ) 590 591 592class QAPIEvent(QAPIObjectWithMembers): 593 # pylint: disable=too-many-ancestors 594 """Description of a QAPI Event.""" 595 596 597class QAPIJSONObject(QAPIObjectWithMembers): 598 # pylint: disable=too-many-ancestors 599 """Description of a QAPI Object: structs and unions.""" 600 601 602class QAPIModule(QAPIDescription): 603 """ 604 Directive to mark description of a new module. 605 606 This directive doesn't generate any special formatting, and is just 607 a pass-through for the content body. Named section titles are 608 allowed in the content body. 609 610 Use this directive to create entries for the QAPI module in the 611 global index and the QAPI index; as well as to associate subsequent 612 definitions with the module they are defined in for purposes of 613 search and QAPI index organization. 614 615 :arg: The name of the module. 616 :opt no-index: Don't add cross-reference targets or index entries. 617 :opt no-typesetting: Don't render the content body (but preserve any 618 cross-reference target IDs in the squelched output.) 619 620 Example:: 621 622 .. qapi:module:: block-core 623 :no-index: 624 :no-typesetting: 625 626 Lorem ipsum, dolor sit amet ... 627 """ 628 629 def run(self) -> List[Node]: 630 modname = self.arguments[0].strip() 631 self.env.ref_context["qapi:module"] = modname 632 ret = super().run() 633 634 # ObjectDescription always creates a visible signature bar. We 635 # want module items to be "invisible", however. 636 637 # Extract the content body of the directive: 638 assert isinstance(ret[-1], addnodes.desc) 639 desc_node = ret.pop(-1) 640 assert isinstance(desc_node.children[1], addnodes.desc_content) 641 ret.extend(desc_node.children[1].children) 642 643 # Re-home node_ids so anchor refs still work: 644 node_ids: List[str] 645 if node_ids := [ 646 node_id 647 for el in desc_node.children[0].traverse(nodes.Element) 648 for node_id in cast(List[str], el.get("ids", ())) 649 ]: 650 target_node = nodes.target(ids=node_ids) 651 ret.insert(1, target_node) 652 653 return ret 654 655 656class QAPINamespace(SphinxDirective): 657 has_content = False 658 required_arguments = 1 659 660 def run(self) -> List[Node]: 661 namespace = self.arguments[0].strip() 662 self.env.ref_context["qapi:namespace"] = namespace 663 664 return [] 665 666 667class QAPIIndex(Index): 668 """ 669 Index subclass to provide the QAPI definition index. 670 """ 671 672 # pylint: disable=too-few-public-methods 673 674 name = "index" 675 localname = _("QAPI Index") 676 shortname = _("QAPI Index") 677 namespace = "" 678 679 def generate( 680 self, 681 docnames: Optional[Iterable[str]] = None, 682 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 683 assert isinstance(self.domain, QAPIDomain) 684 content: Dict[str, List[IndexEntry]] = {} 685 collapse = False 686 687 for objname, obj in self.domain.objects.items(): 688 if docnames and obj.docname not in docnames: 689 continue 690 691 ns, _mod, name = QAPIDescription.split_fqn(objname) 692 693 if self.namespace != ns: 694 continue 695 696 # Add an alphabetical entry: 697 entries = content.setdefault(name[0].upper(), []) 698 entries.append( 699 IndexEntry( 700 name, 0, obj.docname, obj.node_id, obj.objtype, "", "" 701 ) 702 ) 703 704 # Add a categorical entry: 705 category = obj.objtype.title() + "s" 706 entries = content.setdefault(category, []) 707 entries.append( 708 IndexEntry(name, 0, obj.docname, obj.node_id, "", "", "") 709 ) 710 711 # Sort entries within each category alphabetically 712 for category in content: 713 content[category] = sorted(content[category]) 714 715 # Sort the categories themselves; type names first, ABC entries last. 716 sorted_content = sorted( 717 content.items(), 718 key=lambda x: (len(x[0]) == 1, x[0]), 719 ) 720 return sorted_content, collapse 721 722 723class QAPIDomain(Domain): 724 """QAPI language domain.""" 725 726 name = "qapi" 727 label = "QAPI" 728 729 # This table associates cross-reference object types (key) with an 730 # ObjType instance, which defines the valid cross-reference roles 731 # for each object type. 732 # 733 # e.g., the :qapi:type: cross-reference role can refer to enum, 734 # struct, union, or alternate objects; but :qapi:obj: can refer to 735 # anything. Each object also gets its own targeted cross-reference role. 736 object_types: Dict[str, ObjType] = { 737 "module": ObjType(_("module"), "mod", "any"), 738 "command": ObjType(_("command"), "cmd", "any"), 739 "event": ObjType(_("event"), "event", "any"), 740 "enum": ObjType(_("enum"), "enum", "type", "any"), 741 "object": ObjType(_("object"), "obj", "type", "any"), 742 "alternate": ObjType(_("alternate"), "alt", "type", "any"), 743 } 744 745 # Each of these provides a rST directive, 746 # e.g. .. qapi:module:: block-core 747 directives = { 748 "namespace": QAPINamespace, 749 "module": QAPIModule, 750 "command": QAPICommand, 751 "event": QAPIEvent, 752 "enum": QAPIEnum, 753 "object": QAPIJSONObject, 754 "alternate": QAPIAlternate, 755 } 756 757 # These are all cross-reference roles; e.g. 758 # :qapi:cmd:`query-block`. The keys correlate to the names used in 759 # the object_types table values above. 760 roles = { 761 "mod": QAPIXRefRole(), 762 "cmd": QAPIXRefRole(), 763 "event": QAPIXRefRole(), 764 "enum": QAPIXRefRole(), 765 "obj": QAPIXRefRole(), # specifically structs and unions. 766 "alt": QAPIXRefRole(), 767 # reference any data type (excludes modules, commands, events) 768 "type": QAPIXRefRole(), 769 "any": QAPIXRefRole(), # reference *any* type of QAPI object. 770 } 771 772 # Moved into the data property at runtime; 773 # this is the internal index of reference-able objects. 774 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 775 "objects": {}, # fullname -> ObjectEntry 776 } 777 778 # Index pages to generate; each entry is an Index class. 779 indices = [ 780 QAPIIndex, 781 ] 782 783 @property 784 def objects(self) -> Dict[str, ObjectEntry]: 785 ret = self.data.setdefault("objects", {}) 786 return ret # type: ignore[no-any-return] 787 788 def setup(self) -> None: 789 namespaces = set(self.env.app.config.qapi_namespaces) 790 for namespace in namespaces: 791 new_index: Type[QAPIIndex] = types.new_class( 792 f"{namespace}Index", bases=(QAPIIndex,) 793 ) 794 new_index.name = f"{namespace.lower()}-index" 795 new_index.localname = _(f"{namespace} Index") 796 new_index.shortname = _(f"{namespace} Index") 797 new_index.namespace = namespace 798 799 self.indices.append(new_index) 800 801 super().setup() 802 803 def note_object( 804 self, 805 name: str, 806 objtype: str, 807 node_id: str, 808 aliased: bool = False, 809 location: Any = None, 810 ) -> None: 811 """Note a QAPI object for cross reference.""" 812 if name in self.objects: 813 other = self.objects[name] 814 if other.aliased and aliased is False: 815 # The original definition found. Override it! 816 pass 817 elif other.aliased is False and aliased: 818 # The original definition is already registered. 819 return 820 else: 821 # duplicated 822 logger.warning( 823 __( 824 "duplicate object description of %s, " 825 "other instance in %s, use :no-index: for one of them" 826 ), 827 name, 828 other.docname, 829 location=location, 830 ) 831 self.objects[name] = ObjectEntry( 832 self.env.docname, node_id, objtype, aliased 833 ) 834 835 def clear_doc(self, docname: str) -> None: 836 for fullname, obj in list(self.objects.items()): 837 if obj.docname == docname: 838 del self.objects[fullname] 839 840 def merge_domaindata( 841 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 842 ) -> None: 843 for fullname, obj in otherdata["objects"].items(): 844 if obj.docname in docnames: 845 # Sphinx's own python domain doesn't appear to bother to 846 # check for collisions. Assert they don't happen and 847 # we'll fix it if/when the case arises. 848 assert fullname not in self.objects, ( 849 "bug - collision on merge?" 850 f" {fullname=} {obj=} {self.objects[fullname]=}" 851 ) 852 self.objects[fullname] = obj 853 854 def find_obj( 855 self, namespace: str, modname: str, name: str, typ: Optional[str] 856 ) -> List[Tuple[str, ObjectEntry]]: 857 """ 858 Find a QAPI object for "name", maybe using contextual information. 859 860 Returns a list of (name, object entry) tuples. 861 862 :param namespace: The current namespace context (if any!) under 863 which we are searching. 864 :param modname: The current module context (if any!) under 865 which we are searching. 866 :param name: The name of the x-ref to resolve; may or may not 867 include leading context. 868 :param type: The role name of the x-ref we're resolving, if 869 provided. This is absent for "any" role lookups. 870 """ 871 if not name: 872 return [] 873 874 # ## 875 # what to search for 876 # ## 877 878 parts = list(QAPIDescription.split_fqn(name)) 879 explicit = tuple(bool(x) for x in parts) 880 881 # Fill in the blanks where possible: 882 if namespace and not parts[0]: 883 parts[0] = namespace 884 if modname and not parts[1]: 885 parts[1] = modname 886 887 implicit_fqn = "" 888 if all(parts): 889 implicit_fqn = f"{parts[0]}:{parts[1]}.{parts[2]}" 890 891 if typ is None: 892 # :any: lookup, search everything: 893 objtypes: List[str] = list(self.object_types) 894 else: 895 # type is specified and will be a role (e.g. obj, mod, cmd) 896 # convert this to eligible object types (e.g. command, module) 897 # using the QAPIDomain.object_types table. 898 objtypes = self.objtypes_for_role(typ, []) 899 900 # ## 901 # search! 902 # ## 903 904 def _search(needle: str) -> List[str]: 905 if ( 906 needle 907 and needle in self.objects 908 and self.objects[needle].objtype in objtypes 909 ): 910 return [needle] 911 return [] 912 913 if found := _search(name): 914 # Exact match! 915 pass 916 elif found := _search(implicit_fqn): 917 # Exact match using contextual information to fill in the gaps. 918 pass 919 else: 920 # No exact hits, perform applicable fuzzy searches. 921 searches = [] 922 923 esc = tuple(re.escape(s) for s in parts) 924 925 # Try searching for ns:*.name or ns:name 926 if explicit[0] and not explicit[1]: 927 searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$") 928 # Try searching for *:module.name or module.name 929 if explicit[1] and not explicit[0]: 930 searches.append(f"(^|:){esc[1]}\\.{esc[2]}$") 931 # Try searching for context-ns:*.name or context-ns:name 932 if parts[0] and not (explicit[0] or explicit[1]): 933 searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$") 934 # Try searching for *:context-mod.name or context-mod.name 935 if parts[1] and not (explicit[0] or explicit[1]): 936 searches.append(f"(^|:){esc[1]}\\.{esc[2]}$") 937 # Try searching for *:name, *.name, or name 938 if not (explicit[0] or explicit[1]): 939 searches.append(f"(^|:|\\.){esc[2]}$") 940 941 for search in searches: 942 if found := [ 943 oname 944 for oname in self.objects 945 if re.search(search, oname) 946 and self.objects[oname].objtype in objtypes 947 ]: 948 break 949 950 matches = [(oname, self.objects[oname]) for oname in found] 951 if len(matches) > 1: 952 matches = [m for m in matches if not m[1].aliased] 953 return matches 954 955 def resolve_xref( 956 self, 957 env: BuildEnvironment, 958 fromdocname: str, 959 builder: Builder, 960 typ: str, 961 target: str, 962 node: pending_xref, 963 contnode: Element, 964 ) -> nodes.reference | None: 965 namespace = node.get("qapi:namespace") 966 modname = node.get("qapi:module") 967 matches = self.find_obj(namespace, modname, target, typ) 968 969 if not matches: 970 # Normally, we could pass warn_dangling=True to QAPIXRefRole(), 971 # but that will trigger on references to these built-in types, 972 # which we'd like to ignore instead. 973 974 # Take care of that warning here instead, so long as the 975 # reference isn't to one of our built-in core types. 976 if target not in ( 977 "string", 978 "number", 979 "int", 980 "boolean", 981 "null", 982 "value", 983 "q_empty", 984 ): 985 logger.warning( 986 __("qapi:%s reference target not found: %r"), 987 typ, 988 target, 989 type="ref", 990 subtype="qapi", 991 location=node, 992 ) 993 return None 994 995 if len(matches) > 1: 996 logger.warning( 997 __("more than one target found for cross-reference %r: %s"), 998 target, 999 ", ".join(match[0] for match in matches), 1000 type="ref", 1001 subtype="qapi", 1002 location=node, 1003 ) 1004 1005 name, obj = matches[0] 1006 return make_refnode( 1007 builder, fromdocname, obj.docname, obj.node_id, contnode, name 1008 ) 1009 1010 def resolve_any_xref( 1011 self, 1012 env: BuildEnvironment, 1013 fromdocname: str, 1014 builder: Builder, 1015 target: str, 1016 node: pending_xref, 1017 contnode: Element, 1018 ) -> List[Tuple[str, nodes.reference]]: 1019 results: List[Tuple[str, nodes.reference]] = [] 1020 matches = self.find_obj( 1021 node.get("qapi:namespace"), node.get("qapi:module"), target, None 1022 ) 1023 for name, obj in matches: 1024 rolename = self.role_for_objtype(obj.objtype) 1025 assert rolename is not None 1026 role = f"qapi:{rolename}" 1027 refnode = make_refnode( 1028 builder, fromdocname, obj.docname, obj.node_id, contnode, name 1029 ) 1030 results.append((role, refnode)) 1031 return results 1032 1033 1034def setup(app: Sphinx) -> Dict[str, Any]: 1035 app.setup_extension("sphinx.directives") 1036 app.add_config_value( 1037 "qapi_allowed_fields", 1038 set(), 1039 "env", # Setting impacts parsing phase 1040 types=set, 1041 ) 1042 app.add_config_value( 1043 "qapi_namespaces", 1044 set(), 1045 "env", 1046 types=set, 1047 ) 1048 app.add_domain(QAPIDomain) 1049 1050 return { 1051 "version": "1.0", 1052 "env_version": 1, 1053 "parallel_read_safe": True, 1054 "parallel_write_safe": True, 1055 } 1056