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 } 221 ) 222 223 doc_field_types = [ 224 # :feat name: descr 225 GroupedField( 226 "feature", 227 label=_("Features"), 228 names=("feat",), 229 can_collapse=False, 230 ), 231 ] 232 233 def get_signature_prefix(self) -> List[nodes.Node]: 234 """Return a prefix to put before the object name in the signature.""" 235 assert self.objtype 236 return [ 237 KeywordNode("", self.objtype.title()), 238 SpaceNode(" "), 239 ] 240 241 def get_signature_suffix(self) -> List[nodes.Node]: 242 """Return a suffix to put after the object name in the signature.""" 243 ret: List[nodes.Node] = [] 244 245 if "since" in self.options: 246 ret += [ 247 SpaceNode(" "), 248 addnodes.desc_sig_element( 249 "", f"(Since: {self.options['since']})" 250 ), 251 ] 252 253 return ret 254 255 def handle_signature(self, sig: str, signode: desc_signature) -> Signature: 256 """ 257 Transform a QAPI definition name into RST nodes. 258 259 This method was originally intended for handling function 260 signatures. In the QAPI domain, however, we only pass the 261 definition name as the directive argument and handle everything 262 else in the content body with field lists. 263 264 As such, the only argument here is "sig", which is just the QAPI 265 definition name. 266 """ 267 modname = self.options.get( 268 "module", self.env.ref_context.get("qapi:module") 269 ) 270 271 signode["fullname"] = sig 272 signode["module"] = modname 273 sig_prefix = self.get_signature_prefix() 274 if sig_prefix: 275 signode += addnodes.desc_annotation( 276 str(sig_prefix), "", *sig_prefix 277 ) 278 signode += addnodes.desc_name(sig, sig) 279 signode += self.get_signature_suffix() 280 281 return sig 282 283 284class QAPICommand(QAPIObject): 285 """Description of a QAPI Command.""" 286 287 doc_field_types = QAPIObject.doc_field_types.copy() 288 doc_field_types.extend( 289 [ 290 # :arg TypeName ArgName: descr 291 TypedField( 292 "argument", 293 label=_("Arguments"), 294 names=("arg",), 295 can_collapse=False, 296 ), 297 # :error: descr 298 Field( 299 "error", 300 label=_("Errors"), 301 names=("error", "errors"), 302 has_arg=False, 303 ), 304 # :return TypeName: descr 305 GroupedField( 306 "returnvalue", 307 label=_("Return"), 308 names=("return",), 309 can_collapse=True, 310 ), 311 ] 312 ) 313 314 315class QAPIEnum(QAPIObject): 316 """Description of a QAPI Enum.""" 317 318 doc_field_types = QAPIObject.doc_field_types.copy() 319 doc_field_types.extend( 320 [ 321 # :value name: descr 322 GroupedField( 323 "value", 324 label=_("Values"), 325 names=("value",), 326 can_collapse=False, 327 ) 328 ] 329 ) 330 331 332class QAPIAlternate(QAPIObject): 333 """Description of a QAPI Alternate.""" 334 335 doc_field_types = QAPIObject.doc_field_types.copy() 336 doc_field_types.extend( 337 [ 338 # :alt type name: descr 339 TypedField( 340 "alternative", 341 label=_("Alternatives"), 342 names=("alt",), 343 can_collapse=False, 344 ), 345 ] 346 ) 347 348 349class QAPIModule(QAPIDescription): 350 """ 351 Directive to mark description of a new module. 352 353 This directive doesn't generate any special formatting, and is just 354 a pass-through for the content body. Named section titles are 355 allowed in the content body. 356 357 Use this directive to create entries for the QAPI module in the 358 global index and the QAPI index; as well as to associate subsequent 359 definitions with the module they are defined in for purposes of 360 search and QAPI index organization. 361 362 :arg: The name of the module. 363 :opt no-index: Don't add cross-reference targets or index entries. 364 :opt no-typesetting: Don't render the content body (but preserve any 365 cross-reference target IDs in the squelched output.) 366 367 Example:: 368 369 .. qapi:module:: block-core 370 :no-index: 371 :no-typesetting: 372 373 Lorem ipsum, dolor sit amet ... 374 """ 375 376 def run(self) -> List[Node]: 377 modname = self.arguments[0].strip() 378 self.env.ref_context["qapi:module"] = modname 379 ret = super().run() 380 381 # ObjectDescription always creates a visible signature bar. We 382 # want module items to be "invisible", however. 383 384 # Extract the content body of the directive: 385 assert isinstance(ret[-1], addnodes.desc) 386 desc_node = ret.pop(-1) 387 assert isinstance(desc_node.children[1], addnodes.desc_content) 388 ret.extend(desc_node.children[1].children) 389 390 # Re-home node_ids so anchor refs still work: 391 node_ids: List[str] 392 if node_ids := [ 393 node_id 394 for el in desc_node.children[0].traverse(nodes.Element) 395 for node_id in cast(List[str], el.get("ids", ())) 396 ]: 397 target_node = nodes.target(ids=node_ids) 398 ret.insert(1, target_node) 399 400 return ret 401 402 403class QAPIIndex(Index): 404 """ 405 Index subclass to provide the QAPI definition index. 406 """ 407 408 # pylint: disable=too-few-public-methods 409 410 name = "index" 411 localname = _("QAPI Index") 412 shortname = _("QAPI Index") 413 414 def generate( 415 self, 416 docnames: Optional[Iterable[str]] = None, 417 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 418 assert isinstance(self.domain, QAPIDomain) 419 content: Dict[str, List[IndexEntry]] = {} 420 collapse = False 421 422 # list of all object (name, ObjectEntry) pairs, sorted by name 423 # (ignoring the module) 424 objects = sorted( 425 self.domain.objects.items(), 426 key=lambda x: x[0].split(".")[-1].lower(), 427 ) 428 429 for objname, obj in objects: 430 if docnames and obj.docname not in docnames: 431 continue 432 433 # Strip the module name out: 434 objname = objname.split(".")[-1] 435 436 # Add an alphabetical entry: 437 entries = content.setdefault(objname[0].upper(), []) 438 entries.append( 439 IndexEntry( 440 objname, 0, obj.docname, obj.node_id, obj.objtype, "", "" 441 ) 442 ) 443 444 # Add a categorical entry: 445 category = obj.objtype.title() + "s" 446 entries = content.setdefault(category, []) 447 entries.append( 448 IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "") 449 ) 450 451 # alphabetically sort categories; type names first, ABC entries last. 452 sorted_content = sorted( 453 content.items(), 454 key=lambda x: (len(x[0]) == 1, x[0]), 455 ) 456 return sorted_content, collapse 457 458 459class QAPIDomain(Domain): 460 """QAPI language domain.""" 461 462 name = "qapi" 463 label = "QAPI" 464 465 # This table associates cross-reference object types (key) with an 466 # ObjType instance, which defines the valid cross-reference roles 467 # for each object type. 468 # 469 # e.g., the :qapi:type: cross-reference role can refer to enum, 470 # struct, union, or alternate objects; but :qapi:obj: can refer to 471 # anything. Each object also gets its own targeted cross-reference role. 472 object_types: Dict[str, ObjType] = { 473 "module": ObjType(_("module"), "mod", "any"), 474 "command": ObjType(_("command"), "cmd", "any"), 475 "enum": ObjType(_("enum"), "enum", "type", "any"), 476 "alternate": ObjType(_("alternate"), "alt", "type", "any"), 477 } 478 479 # Each of these provides a rST directive, 480 # e.g. .. qapi:module:: block-core 481 directives = { 482 "module": QAPIModule, 483 "command": QAPICommand, 484 "enum": QAPIEnum, 485 "alternate": QAPIAlternate, 486 } 487 488 # These are all cross-reference roles; e.g. 489 # :qapi:cmd:`query-block`. The keys correlate to the names used in 490 # the object_types table values above. 491 roles = { 492 "mod": QAPIXRefRole(), 493 "cmd": QAPIXRefRole(), 494 "enum": QAPIXRefRole(), 495 "alt": QAPIXRefRole(), 496 # reference any data type (excludes modules, commands, events) 497 "type": QAPIXRefRole(), 498 "any": QAPIXRefRole(), # reference *any* type of QAPI object. 499 } 500 501 # Moved into the data property at runtime; 502 # this is the internal index of reference-able objects. 503 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 504 "objects": {}, # fullname -> ObjectEntry 505 } 506 507 # Index pages to generate; each entry is an Index class. 508 indices = [ 509 QAPIIndex, 510 ] 511 512 @property 513 def objects(self) -> Dict[str, ObjectEntry]: 514 ret = self.data.setdefault("objects", {}) 515 return ret # type: ignore[no-any-return] 516 517 def note_object( 518 self, 519 name: str, 520 objtype: str, 521 node_id: str, 522 aliased: bool = False, 523 location: Any = None, 524 ) -> None: 525 """Note a QAPI object for cross reference.""" 526 if name in self.objects: 527 other = self.objects[name] 528 if other.aliased and aliased is False: 529 # The original definition found. Override it! 530 pass 531 elif other.aliased is False and aliased: 532 # The original definition is already registered. 533 return 534 else: 535 # duplicated 536 logger.warning( 537 __( 538 "duplicate object description of %s, " 539 "other instance in %s, use :no-index: for one of them" 540 ), 541 name, 542 other.docname, 543 location=location, 544 ) 545 self.objects[name] = ObjectEntry( 546 self.env.docname, node_id, objtype, aliased 547 ) 548 549 def clear_doc(self, docname: str) -> None: 550 for fullname, obj in list(self.objects.items()): 551 if obj.docname == docname: 552 del self.objects[fullname] 553 554 def merge_domaindata( 555 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 556 ) -> None: 557 for fullname, obj in otherdata["objects"].items(): 558 if obj.docname in docnames: 559 # Sphinx's own python domain doesn't appear to bother to 560 # check for collisions. Assert they don't happen and 561 # we'll fix it if/when the case arises. 562 assert fullname not in self.objects, ( 563 "bug - collision on merge?" 564 f" {fullname=} {obj=} {self.objects[fullname]=}" 565 ) 566 self.objects[fullname] = obj 567 568 def find_obj( 569 self, modname: str, name: str, typ: Optional[str] 570 ) -> list[tuple[str, ObjectEntry]]: 571 """ 572 Find a QAPI object for "name", perhaps using the given module. 573 574 Returns a list of (name, object entry) tuples. 575 576 :param modname: The current module context (if any!) 577 under which we are searching. 578 :param name: The name of the x-ref to resolve; 579 may or may not include a leading module. 580 :param type: The role name of the x-ref we're resolving, if provided. 581 (This is absent for "any" lookups.) 582 """ 583 if not name: 584 return [] 585 586 names: list[str] = [] 587 matches: list[tuple[str, ObjectEntry]] = [] 588 589 fullname = name 590 if "." in fullname: 591 # We're searching for a fully qualified reference; 592 # ignore the contextual module. 593 pass 594 elif modname: 595 # We're searching for something from somewhere; 596 # try searching the current module first. 597 # e.g. :qapi:cmd:`query-block` or `query-block` is being searched. 598 fullname = f"{modname}.{name}" 599 600 if typ is None: 601 # type isn't specified, this is a generic xref. 602 # search *all* qapi-specific object types. 603 objtypes: List[str] = list(self.object_types) 604 else: 605 # type is specified and will be a role (e.g. obj, mod, cmd) 606 # convert this to eligible object types (e.g. command, module) 607 # using the QAPIDomain.object_types table. 608 objtypes = self.objtypes_for_role(typ, []) 609 610 if name in self.objects and self.objects[name].objtype in objtypes: 611 names = [name] 612 elif ( 613 fullname in self.objects 614 and self.objects[fullname].objtype in objtypes 615 ): 616 names = [fullname] 617 else: 618 # exact match wasn't found; e.g. we are searching for 619 # `query-block` from a different (or no) module. 620 searchname = "." + name 621 names = [ 622 oname 623 for oname in self.objects 624 if oname.endswith(searchname) 625 and self.objects[oname].objtype in objtypes 626 ] 627 628 matches = [(oname, self.objects[oname]) for oname in names] 629 if len(matches) > 1: 630 matches = [m for m in matches if not m[1].aliased] 631 return matches 632 633 def resolve_xref( 634 self, 635 env: BuildEnvironment, 636 fromdocname: str, 637 builder: Builder, 638 typ: str, 639 target: str, 640 node: pending_xref, 641 contnode: Element, 642 ) -> nodes.reference | None: 643 modname = node.get("qapi:module") 644 matches = self.find_obj(modname, target, typ) 645 646 if not matches: 647 return None 648 649 if len(matches) > 1: 650 logger.warning( 651 __("more than one target found for cross-reference %r: %s"), 652 target, 653 ", ".join(match[0] for match in matches), 654 type="ref", 655 subtype="qapi", 656 location=node, 657 ) 658 659 name, obj = matches[0] 660 return make_refnode( 661 builder, fromdocname, obj.docname, obj.node_id, contnode, name 662 ) 663 664 def resolve_any_xref( 665 self, 666 env: BuildEnvironment, 667 fromdocname: str, 668 builder: Builder, 669 target: str, 670 node: pending_xref, 671 contnode: Element, 672 ) -> List[Tuple[str, nodes.reference]]: 673 results: List[Tuple[str, nodes.reference]] = [] 674 matches = self.find_obj(node.get("qapi:module"), target, None) 675 for name, obj in matches: 676 rolename = self.role_for_objtype(obj.objtype) 677 assert rolename is not None 678 role = f"qapi:{rolename}" 679 refnode = make_refnode( 680 builder, fromdocname, obj.docname, obj.node_id, contnode, name 681 ) 682 results.append((role, refnode)) 683 return results 684 685 686def setup(app: Sphinx) -> Dict[str, Any]: 687 app.setup_extension("sphinx.directives") 688 app.add_domain(QAPIDomain) 689 690 return { 691 "version": "1.0", 692 "env_version": 1, 693 "parallel_read_safe": True, 694 "parallel_write_safe": True, 695 } 696