1""" 2QAPI domain extension. 3""" 4 5from __future__ import annotations 6 7from typing import ( 8 TYPE_CHECKING, 9 AbstractSet, 10 Any, 11 Dict, 12 Iterable, 13 List, 14 NamedTuple, 15 Optional, 16 Tuple, 17 Union, 18 cast, 19) 20 21from docutils import nodes 22from docutils.parsers.rst import directives 23 24from compat import KeywordNode, SpaceNode 25from sphinx import addnodes 26from sphinx.addnodes import desc_signature, pending_xref 27from sphinx.directives import ObjectDescription 28from sphinx.domains import ( 29 Domain, 30 Index, 31 IndexEntry, 32 ObjType, 33) 34from sphinx.locale import _, __ 35from sphinx.roles import XRefRole 36from sphinx.util import logging 37from sphinx.util.docfields import Field, GroupedField, TypedField 38from sphinx.util.nodes import make_id, make_refnode 39 40 41if TYPE_CHECKING: 42 from docutils.nodes import Element, Node 43 44 from sphinx.application import Sphinx 45 from sphinx.builders import Builder 46 from sphinx.environment import BuildEnvironment 47 from sphinx.util.typing import OptionSpec 48 49logger = logging.getLogger(__name__) 50 51 52class ObjectEntry(NamedTuple): 53 docname: str 54 node_id: str 55 objtype: str 56 aliased: bool 57 58 59class QAPIXRefRole(XRefRole): 60 61 def process_link( 62 self, 63 env: BuildEnvironment, 64 refnode: Element, 65 has_explicit_title: bool, 66 title: str, 67 target: str, 68 ) -> tuple[str, str]: 69 refnode["qapi:module"] = env.ref_context.get("qapi:module") 70 71 # Cross-references that begin with a tilde adjust the title to 72 # only show the reference without a leading module, even if one 73 # was provided. This is a Sphinx-standard syntax; give it 74 # priority over QAPI-specific type markup below. 75 hide_module = False 76 if target.startswith("~"): 77 hide_module = True 78 target = target[1:] 79 80 # Type names that end with "?" are considered optional 81 # arguments and should be documented as such, but it's not 82 # part of the xref itself. 83 if target.endswith("?"): 84 refnode["qapi:optional"] = True 85 target = target[:-1] 86 87 # Type names wrapped in brackets denote lists. strip the 88 # brackets and remember to add them back later. 89 if target.startswith("[") and target.endswith("]"): 90 refnode["qapi:array"] = True 91 target = target[1:-1] 92 93 if has_explicit_title: 94 # Don't mess with the title at all if it was explicitly set. 95 # Explicit title syntax for references is e.g. 96 # :qapi:type:`target <explicit title>` 97 # and this explicit title overrides everything else here. 98 return title, target 99 100 title = target 101 if hide_module: 102 title = target.split(".")[-1] 103 104 return title, target 105 106 107# Alias for the return of handle_signature(), which is used in several places. 108# (In the Python domain, this is Tuple[str, str] instead.) 109Signature = str 110 111 112class QAPIDescription(ObjectDescription[Signature]): 113 """ 114 Generic QAPI description. 115 116 This is meant to be an abstract class, not instantiated 117 directly. This class handles the abstract details of indexing, the 118 TOC, and reference targets for QAPI descriptions. 119 """ 120 121 def handle_signature(self, sig: str, signode: desc_signature) -> Signature: 122 # Do nothing. The return value here is the "name" of the entity 123 # being documented; for QAPI, this is the same as the 124 # "signature", which is just a name. 125 126 # Normally this method must also populate signode with nodes to 127 # render the signature; here we do nothing instead - the 128 # subclasses will handle this. 129 return sig 130 131 def get_index_text(self, name: Signature) -> Tuple[str, str]: 132 """Return the text for the index entry of the object.""" 133 134 # NB: this is used for the global index, not the QAPI index. 135 return ("single", f"{name} (QMP {self.objtype})") 136 137 def add_target_and_index( 138 self, name: Signature, sig: str, signode: desc_signature 139 ) -> None: 140 # name is the return value of handle_signature. 141 # sig is the original, raw text argument to handle_signature. 142 # For QAPI, these are identical, currently. 143 144 assert self.objtype 145 146 # If we're documenting a module, don't include the module as 147 # part of the FQN. 148 modname = "" 149 if self.objtype != "module": 150 modname = self.options.get( 151 "module", self.env.ref_context.get("qapi:module") 152 ) 153 fullname = (modname + "." if modname else "") + name 154 155 node_id = make_id( 156 self.env, self.state.document, self.objtype, fullname 157 ) 158 signode["ids"].append(node_id) 159 160 self.state.document.note_explicit_target(signode) 161 domain = cast(QAPIDomain, self.env.get_domain("qapi")) 162 domain.note_object(fullname, self.objtype, node_id, location=signode) 163 164 if "no-index-entry" not in self.options: 165 arity, indextext = self.get_index_text(name) 166 assert self.indexnode is not None 167 if indextext: 168 self.indexnode["entries"].append( 169 (arity, indextext, node_id, "", None) 170 ) 171 172 def _object_hierarchy_parts( 173 self, sig_node: desc_signature 174 ) -> Tuple[str, ...]: 175 if "fullname" not in sig_node: 176 return () 177 modname = sig_node.get("module") 178 fullname = sig_node["fullname"] 179 180 if modname: 181 return (modname, *fullname.split(".")) 182 183 return tuple(fullname.split(".")) 184 185 def _toc_entry_name(self, sig_node: desc_signature) -> str: 186 # This controls the name in the TOC and on the sidebar. 187 188 # This is the return type of _object_hierarchy_parts(). 189 toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ())) 190 if not toc_parts: 191 return "" 192 193 config = self.env.app.config 194 *parents, name = toc_parts 195 if config.toc_object_entries_show_parents == "domain": 196 return sig_node.get("fullname", name) 197 if config.toc_object_entries_show_parents == "hide": 198 return name 199 if config.toc_object_entries_show_parents == "all": 200 return ".".join(parents + [name]) 201 return "" 202 203 204class QAPIObject(QAPIDescription): 205 """ 206 Description of a generic QAPI object. 207 208 It's not used directly, but is instead subclassed by specific directives. 209 """ 210 211 # Inherit some standard options from Sphinx's ObjectDescription 212 option_spec: OptionSpec = ( # type:ignore[misc] 213 ObjectDescription.option_spec.copy() 214 ) 215 option_spec.update( 216 { 217 # Borrowed from the Python domain: 218 "module": directives.unchanged, # Override contextual module name 219 # These are QAPI originals: 220 "since": directives.unchanged, 221 "ifcond": directives.unchanged, 222 "deprecated": directives.flag, 223 "unstable": directives.flag, 224 } 225 ) 226 227 doc_field_types = [ 228 # :feat name: descr 229 GroupedField( 230 "feature", 231 label=_("Features"), 232 names=("feat",), 233 can_collapse=False, 234 ), 235 ] 236 237 def get_signature_prefix(self) -> List[nodes.Node]: 238 """Return a prefix to put before the object name in the signature.""" 239 assert self.objtype 240 return [ 241 KeywordNode("", self.objtype.title()), 242 SpaceNode(" "), 243 ] 244 245 def get_signature_suffix(self) -> List[nodes.Node]: 246 """Return a suffix to put after the object name in the signature.""" 247 ret: List[nodes.Node] = [] 248 249 if "since" in self.options: 250 ret += [ 251 SpaceNode(" "), 252 addnodes.desc_sig_element( 253 "", f"(Since: {self.options['since']})" 254 ), 255 ] 256 257 return ret 258 259 def handle_signature(self, sig: str, signode: desc_signature) -> Signature: 260 """ 261 Transform a QAPI definition name into RST nodes. 262 263 This method was originally intended for handling function 264 signatures. In the QAPI domain, however, we only pass the 265 definition name as the directive argument and handle everything 266 else in the content body with field lists. 267 268 As such, the only argument here is "sig", which is just the QAPI 269 definition name. 270 """ 271 modname = self.options.get( 272 "module", self.env.ref_context.get("qapi:module") 273 ) 274 275 signode["fullname"] = sig 276 signode["module"] = modname 277 sig_prefix = self.get_signature_prefix() 278 if sig_prefix: 279 signode += addnodes.desc_annotation( 280 str(sig_prefix), "", *sig_prefix 281 ) 282 signode += addnodes.desc_name(sig, sig) 283 signode += self.get_signature_suffix() 284 285 return sig 286 287 def _add_infopips(self, contentnode: addnodes.desc_content) -> None: 288 # Add various eye-catches and things that go below the signature 289 # bar, but precede the user-defined content. 290 infopips = nodes.container() 291 infopips.attributes["classes"].append("qapi-infopips") 292 293 def _add_pip( 294 source: str, content: Union[str, List[nodes.Node]], classname: str 295 ) -> None: 296 node = nodes.container(source) 297 if isinstance(content, str): 298 node.append(nodes.Text(content)) 299 else: 300 node.extend(content) 301 node.attributes["classes"].extend(["qapi-infopip", classname]) 302 infopips.append(node) 303 304 if "deprecated" in self.options: 305 _add_pip( 306 ":deprecated:", 307 f"This {self.objtype} is deprecated.", 308 "qapi-deprecated", 309 ) 310 311 if "unstable" in self.options: 312 _add_pip( 313 ":unstable:", 314 f"This {self.objtype} is unstable/experimental.", 315 "qapi-unstable", 316 ) 317 318 if self.options.get("ifcond", ""): 319 ifcond = self.options["ifcond"] 320 _add_pip( 321 f":ifcond: {ifcond}", 322 [ 323 nodes.emphasis("", "Availability"), 324 nodes.Text(": "), 325 nodes.literal(ifcond, ifcond), 326 ], 327 "qapi-ifcond", 328 ) 329 330 if infopips.children: 331 contentnode.insert(0, infopips) 332 333 def transform_content(self, content_node: addnodes.desc_content) -> None: 334 self._add_infopips(content_node) 335 336 337class QAPICommand(QAPIObject): 338 """Description of a QAPI Command.""" 339 340 doc_field_types = QAPIObject.doc_field_types.copy() 341 doc_field_types.extend( 342 [ 343 # :arg TypeName ArgName: descr 344 TypedField( 345 "argument", 346 label=_("Arguments"), 347 names=("arg",), 348 can_collapse=False, 349 ), 350 # :error: descr 351 Field( 352 "error", 353 label=_("Errors"), 354 names=("error", "errors"), 355 has_arg=False, 356 ), 357 # :return TypeName: descr 358 GroupedField( 359 "returnvalue", 360 label=_("Return"), 361 names=("return",), 362 can_collapse=True, 363 ), 364 ] 365 ) 366 367 368class QAPIEnum(QAPIObject): 369 """Description of a QAPI Enum.""" 370 371 doc_field_types = QAPIObject.doc_field_types.copy() 372 doc_field_types.extend( 373 [ 374 # :value name: descr 375 GroupedField( 376 "value", 377 label=_("Values"), 378 names=("value",), 379 can_collapse=False, 380 ) 381 ] 382 ) 383 384 385class QAPIAlternate(QAPIObject): 386 """Description of a QAPI Alternate.""" 387 388 doc_field_types = QAPIObject.doc_field_types.copy() 389 doc_field_types.extend( 390 [ 391 # :alt type name: descr 392 TypedField( 393 "alternative", 394 label=_("Alternatives"), 395 names=("alt",), 396 can_collapse=False, 397 ), 398 ] 399 ) 400 401 402class QAPIObjectWithMembers(QAPIObject): 403 """Base class for Events/Structs/Unions""" 404 405 doc_field_types = QAPIObject.doc_field_types.copy() 406 doc_field_types.extend( 407 [ 408 # :member type name: descr 409 TypedField( 410 "member", 411 label=_("Members"), 412 names=("memb",), 413 can_collapse=False, 414 ), 415 ] 416 ) 417 418 419class QAPIEvent(QAPIObjectWithMembers): 420 """Description of a QAPI Event.""" 421 422 423class QAPIJSONObject(QAPIObjectWithMembers): 424 """Description of a QAPI Object: structs and unions.""" 425 426 427class QAPIModule(QAPIDescription): 428 """ 429 Directive to mark description of a new module. 430 431 This directive doesn't generate any special formatting, and is just 432 a pass-through for the content body. Named section titles are 433 allowed in the content body. 434 435 Use this directive to create entries for the QAPI module in the 436 global index and the QAPI index; as well as to associate subsequent 437 definitions with the module they are defined in for purposes of 438 search and QAPI index organization. 439 440 :arg: The name of the module. 441 :opt no-index: Don't add cross-reference targets or index entries. 442 :opt no-typesetting: Don't render the content body (but preserve any 443 cross-reference target IDs in the squelched output.) 444 445 Example:: 446 447 .. qapi:module:: block-core 448 :no-index: 449 :no-typesetting: 450 451 Lorem ipsum, dolor sit amet ... 452 """ 453 454 def run(self) -> List[Node]: 455 modname = self.arguments[0].strip() 456 self.env.ref_context["qapi:module"] = modname 457 ret = super().run() 458 459 # ObjectDescription always creates a visible signature bar. We 460 # want module items to be "invisible", however. 461 462 # Extract the content body of the directive: 463 assert isinstance(ret[-1], addnodes.desc) 464 desc_node = ret.pop(-1) 465 assert isinstance(desc_node.children[1], addnodes.desc_content) 466 ret.extend(desc_node.children[1].children) 467 468 # Re-home node_ids so anchor refs still work: 469 node_ids: List[str] 470 if node_ids := [ 471 node_id 472 for el in desc_node.children[0].traverse(nodes.Element) 473 for node_id in cast(List[str], el.get("ids", ())) 474 ]: 475 target_node = nodes.target(ids=node_ids) 476 ret.insert(1, target_node) 477 478 return ret 479 480 481class QAPIIndex(Index): 482 """ 483 Index subclass to provide the QAPI definition index. 484 """ 485 486 # pylint: disable=too-few-public-methods 487 488 name = "index" 489 localname = _("QAPI Index") 490 shortname = _("QAPI Index") 491 492 def generate( 493 self, 494 docnames: Optional[Iterable[str]] = None, 495 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 496 assert isinstance(self.domain, QAPIDomain) 497 content: Dict[str, List[IndexEntry]] = {} 498 collapse = False 499 500 # list of all object (name, ObjectEntry) pairs, sorted by name 501 # (ignoring the module) 502 objects = sorted( 503 self.domain.objects.items(), 504 key=lambda x: x[0].split(".")[-1].lower(), 505 ) 506 507 for objname, obj in objects: 508 if docnames and obj.docname not in docnames: 509 continue 510 511 # Strip the module name out: 512 objname = objname.split(".")[-1] 513 514 # Add an alphabetical entry: 515 entries = content.setdefault(objname[0].upper(), []) 516 entries.append( 517 IndexEntry( 518 objname, 0, obj.docname, obj.node_id, obj.objtype, "", "" 519 ) 520 ) 521 522 # Add a categorical entry: 523 category = obj.objtype.title() + "s" 524 entries = content.setdefault(category, []) 525 entries.append( 526 IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "") 527 ) 528 529 # alphabetically sort categories; type names first, ABC entries last. 530 sorted_content = sorted( 531 content.items(), 532 key=lambda x: (len(x[0]) == 1, x[0]), 533 ) 534 return sorted_content, collapse 535 536 537class QAPIDomain(Domain): 538 """QAPI language domain.""" 539 540 name = "qapi" 541 label = "QAPI" 542 543 # This table associates cross-reference object types (key) with an 544 # ObjType instance, which defines the valid cross-reference roles 545 # for each object type. 546 # 547 # e.g., the :qapi:type: cross-reference role can refer to enum, 548 # struct, union, or alternate objects; but :qapi:obj: can refer to 549 # anything. Each object also gets its own targeted cross-reference role. 550 object_types: Dict[str, ObjType] = { 551 "module": ObjType(_("module"), "mod", "any"), 552 "command": ObjType(_("command"), "cmd", "any"), 553 "event": ObjType(_("event"), "event", "any"), 554 "enum": ObjType(_("enum"), "enum", "type", "any"), 555 "object": ObjType(_("object"), "obj", "type", "any"), 556 "alternate": ObjType(_("alternate"), "alt", "type", "any"), 557 } 558 559 # Each of these provides a rST directive, 560 # e.g. .. qapi:module:: block-core 561 directives = { 562 "module": QAPIModule, 563 "command": QAPICommand, 564 "event": QAPIEvent, 565 "enum": QAPIEnum, 566 "object": QAPIJSONObject, 567 "alternate": QAPIAlternate, 568 } 569 570 # These are all cross-reference roles; e.g. 571 # :qapi:cmd:`query-block`. The keys correlate to the names used in 572 # the object_types table values above. 573 roles = { 574 "mod": QAPIXRefRole(), 575 "cmd": QAPIXRefRole(), 576 "event": QAPIXRefRole(), 577 "enum": QAPIXRefRole(), 578 "obj": QAPIXRefRole(), # specifically structs and unions. 579 "alt": QAPIXRefRole(), 580 # reference any data type (excludes modules, commands, events) 581 "type": QAPIXRefRole(), 582 "any": QAPIXRefRole(), # reference *any* type of QAPI object. 583 } 584 585 # Moved into the data property at runtime; 586 # this is the internal index of reference-able objects. 587 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 588 "objects": {}, # fullname -> ObjectEntry 589 } 590 591 # Index pages to generate; each entry is an Index class. 592 indices = [ 593 QAPIIndex, 594 ] 595 596 @property 597 def objects(self) -> Dict[str, ObjectEntry]: 598 ret = self.data.setdefault("objects", {}) 599 return ret # type: ignore[no-any-return] 600 601 def note_object( 602 self, 603 name: str, 604 objtype: str, 605 node_id: str, 606 aliased: bool = False, 607 location: Any = None, 608 ) -> None: 609 """Note a QAPI object for cross reference.""" 610 if name in self.objects: 611 other = self.objects[name] 612 if other.aliased and aliased is False: 613 # The original definition found. Override it! 614 pass 615 elif other.aliased is False and aliased: 616 # The original definition is already registered. 617 return 618 else: 619 # duplicated 620 logger.warning( 621 __( 622 "duplicate object description of %s, " 623 "other instance in %s, use :no-index: for one of them" 624 ), 625 name, 626 other.docname, 627 location=location, 628 ) 629 self.objects[name] = ObjectEntry( 630 self.env.docname, node_id, objtype, aliased 631 ) 632 633 def clear_doc(self, docname: str) -> None: 634 for fullname, obj in list(self.objects.items()): 635 if obj.docname == docname: 636 del self.objects[fullname] 637 638 def merge_domaindata( 639 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 640 ) -> None: 641 for fullname, obj in otherdata["objects"].items(): 642 if obj.docname in docnames: 643 # Sphinx's own python domain doesn't appear to bother to 644 # check for collisions. Assert they don't happen and 645 # we'll fix it if/when the case arises. 646 assert fullname not in self.objects, ( 647 "bug - collision on merge?" 648 f" {fullname=} {obj=} {self.objects[fullname]=}" 649 ) 650 self.objects[fullname] = obj 651 652 def find_obj( 653 self, modname: str, name: str, typ: Optional[str] 654 ) -> list[tuple[str, ObjectEntry]]: 655 """ 656 Find a QAPI object for "name", perhaps using the given module. 657 658 Returns a list of (name, object entry) tuples. 659 660 :param modname: The current module context (if any!) 661 under which we are searching. 662 :param name: The name of the x-ref to resolve; 663 may or may not include a leading module. 664 :param type: The role name of the x-ref we're resolving, if provided. 665 (This is absent for "any" lookups.) 666 """ 667 if not name: 668 return [] 669 670 names: list[str] = [] 671 matches: list[tuple[str, ObjectEntry]] = [] 672 673 fullname = name 674 if "." in fullname: 675 # We're searching for a fully qualified reference; 676 # ignore the contextual module. 677 pass 678 elif modname: 679 # We're searching for something from somewhere; 680 # try searching the current module first. 681 # e.g. :qapi:cmd:`query-block` or `query-block` is being searched. 682 fullname = f"{modname}.{name}" 683 684 if typ is None: 685 # type isn't specified, this is a generic xref. 686 # search *all* qapi-specific object types. 687 objtypes: List[str] = list(self.object_types) 688 else: 689 # type is specified and will be a role (e.g. obj, mod, cmd) 690 # convert this to eligible object types (e.g. command, module) 691 # using the QAPIDomain.object_types table. 692 objtypes = self.objtypes_for_role(typ, []) 693 694 if name in self.objects and self.objects[name].objtype in objtypes: 695 names = [name] 696 elif ( 697 fullname in self.objects 698 and self.objects[fullname].objtype in objtypes 699 ): 700 names = [fullname] 701 else: 702 # exact match wasn't found; e.g. we are searching for 703 # `query-block` from a different (or no) module. 704 searchname = "." + name 705 names = [ 706 oname 707 for oname in self.objects 708 if oname.endswith(searchname) 709 and self.objects[oname].objtype in objtypes 710 ] 711 712 matches = [(oname, self.objects[oname]) for oname in names] 713 if len(matches) > 1: 714 matches = [m for m in matches if not m[1].aliased] 715 return matches 716 717 def resolve_xref( 718 self, 719 env: BuildEnvironment, 720 fromdocname: str, 721 builder: Builder, 722 typ: str, 723 target: str, 724 node: pending_xref, 725 contnode: Element, 726 ) -> nodes.reference | None: 727 modname = node.get("qapi:module") 728 matches = self.find_obj(modname, target, typ) 729 730 if not matches: 731 return None 732 733 if len(matches) > 1: 734 logger.warning( 735 __("more than one target found for cross-reference %r: %s"), 736 target, 737 ", ".join(match[0] for match in matches), 738 type="ref", 739 subtype="qapi", 740 location=node, 741 ) 742 743 name, obj = matches[0] 744 return make_refnode( 745 builder, fromdocname, obj.docname, obj.node_id, contnode, name 746 ) 747 748 def resolve_any_xref( 749 self, 750 env: BuildEnvironment, 751 fromdocname: str, 752 builder: Builder, 753 target: str, 754 node: pending_xref, 755 contnode: Element, 756 ) -> List[Tuple[str, nodes.reference]]: 757 results: List[Tuple[str, nodes.reference]] = [] 758 matches = self.find_obj(node.get("qapi:module"), target, None) 759 for name, obj in matches: 760 rolename = self.role_for_objtype(obj.objtype) 761 assert rolename is not None 762 role = f"qapi:{rolename}" 763 refnode = make_refnode( 764 builder, fromdocname, obj.docname, obj.node_id, contnode, name 765 ) 766 results.append((role, refnode)) 767 return results 768 769 770def setup(app: Sphinx) -> Dict[str, Any]: 771 app.setup_extension("sphinx.directives") 772 app.add_domain(QAPIDomain) 773 774 return { 775 "version": "1.0", 776 "env_version": 1, 777 "parallel_read_safe": True, 778 "parallel_write_safe": True, 779 } 780