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