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