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 QAPIModule(QAPIDescription): 333 """ 334 Directive to mark description of a new module. 335 336 This directive doesn't generate any special formatting, and is just 337 a pass-through for the content body. Named section titles are 338 allowed in the content body. 339 340 Use this directive to create entries for the QAPI module in the 341 global index and the QAPI index; as well as to associate subsequent 342 definitions with the module they are defined in for purposes of 343 search and QAPI index organization. 344 345 :arg: The name of the module. 346 :opt no-index: Don't add cross-reference targets or index entries. 347 :opt no-typesetting: Don't render the content body (but preserve any 348 cross-reference target IDs in the squelched output.) 349 350 Example:: 351 352 .. qapi:module:: block-core 353 :no-index: 354 :no-typesetting: 355 356 Lorem ipsum, dolor sit amet ... 357 """ 358 359 def run(self) -> List[Node]: 360 modname = self.arguments[0].strip() 361 self.env.ref_context["qapi:module"] = modname 362 ret = super().run() 363 364 # ObjectDescription always creates a visible signature bar. We 365 # want module items to be "invisible", however. 366 367 # Extract the content body of the directive: 368 assert isinstance(ret[-1], addnodes.desc) 369 desc_node = ret.pop(-1) 370 assert isinstance(desc_node.children[1], addnodes.desc_content) 371 ret.extend(desc_node.children[1].children) 372 373 # Re-home node_ids so anchor refs still work: 374 node_ids: List[str] 375 if node_ids := [ 376 node_id 377 for el in desc_node.children[0].traverse(nodes.Element) 378 for node_id in cast(List[str], el.get("ids", ())) 379 ]: 380 target_node = nodes.target(ids=node_ids) 381 ret.insert(1, target_node) 382 383 return ret 384 385 386class QAPIIndex(Index): 387 """ 388 Index subclass to provide the QAPI definition index. 389 """ 390 391 # pylint: disable=too-few-public-methods 392 393 name = "index" 394 localname = _("QAPI Index") 395 shortname = _("QAPI Index") 396 397 def generate( 398 self, 399 docnames: Optional[Iterable[str]] = None, 400 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 401 assert isinstance(self.domain, QAPIDomain) 402 content: Dict[str, List[IndexEntry]] = {} 403 collapse = False 404 405 # list of all object (name, ObjectEntry) pairs, sorted by name 406 # (ignoring the module) 407 objects = sorted( 408 self.domain.objects.items(), 409 key=lambda x: x[0].split(".")[-1].lower(), 410 ) 411 412 for objname, obj in objects: 413 if docnames and obj.docname not in docnames: 414 continue 415 416 # Strip the module name out: 417 objname = objname.split(".")[-1] 418 419 # Add an alphabetical entry: 420 entries = content.setdefault(objname[0].upper(), []) 421 entries.append( 422 IndexEntry( 423 objname, 0, obj.docname, obj.node_id, obj.objtype, "", "" 424 ) 425 ) 426 427 # Add a categorical entry: 428 category = obj.objtype.title() + "s" 429 entries = content.setdefault(category, []) 430 entries.append( 431 IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "") 432 ) 433 434 # alphabetically sort categories; type names first, ABC entries last. 435 sorted_content = sorted( 436 content.items(), 437 key=lambda x: (len(x[0]) == 1, x[0]), 438 ) 439 return sorted_content, collapse 440 441 442class QAPIDomain(Domain): 443 """QAPI language domain.""" 444 445 name = "qapi" 446 label = "QAPI" 447 448 # This table associates cross-reference object types (key) with an 449 # ObjType instance, which defines the valid cross-reference roles 450 # for each object type. 451 # 452 # e.g., the :qapi:type: cross-reference role can refer to enum, 453 # struct, union, or alternate objects; but :qapi:obj: can refer to 454 # anything. Each object also gets its own targeted cross-reference role. 455 object_types: Dict[str, ObjType] = { 456 "module": ObjType(_("module"), "mod", "any"), 457 "command": ObjType(_("command"), "cmd", "any"), 458 "enum": ObjType(_("enum"), "enum", "type", "any"), 459 } 460 461 # Each of these provides a rST directive, 462 # e.g. .. qapi:module:: block-core 463 directives = { 464 "module": QAPIModule, 465 "command": QAPICommand, 466 "enum": QAPIEnum, 467 } 468 469 # These are all cross-reference roles; e.g. 470 # :qapi:cmd:`query-block`. The keys correlate to the names used in 471 # the object_types table values above. 472 roles = { 473 "mod": QAPIXRefRole(), 474 "cmd": QAPIXRefRole(), 475 "enum": QAPIXRefRole(), 476 # reference any data type (excludes modules, commands, events) 477 "type": QAPIXRefRole(), 478 "any": QAPIXRefRole(), # reference *any* type of QAPI object. 479 } 480 481 # Moved into the data property at runtime; 482 # this is the internal index of reference-able objects. 483 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 484 "objects": {}, # fullname -> ObjectEntry 485 } 486 487 # Index pages to generate; each entry is an Index class. 488 indices = [ 489 QAPIIndex, 490 ] 491 492 @property 493 def objects(self) -> Dict[str, ObjectEntry]: 494 ret = self.data.setdefault("objects", {}) 495 return ret # type: ignore[no-any-return] 496 497 def note_object( 498 self, 499 name: str, 500 objtype: str, 501 node_id: str, 502 aliased: bool = False, 503 location: Any = None, 504 ) -> None: 505 """Note a QAPI object for cross reference.""" 506 if name in self.objects: 507 other = self.objects[name] 508 if other.aliased and aliased is False: 509 # The original definition found. Override it! 510 pass 511 elif other.aliased is False and aliased: 512 # The original definition is already registered. 513 return 514 else: 515 # duplicated 516 logger.warning( 517 __( 518 "duplicate object description of %s, " 519 "other instance in %s, use :no-index: for one of them" 520 ), 521 name, 522 other.docname, 523 location=location, 524 ) 525 self.objects[name] = ObjectEntry( 526 self.env.docname, node_id, objtype, aliased 527 ) 528 529 def clear_doc(self, docname: str) -> None: 530 for fullname, obj in list(self.objects.items()): 531 if obj.docname == docname: 532 del self.objects[fullname] 533 534 def merge_domaindata( 535 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 536 ) -> None: 537 for fullname, obj in otherdata["objects"].items(): 538 if obj.docname in docnames: 539 # Sphinx's own python domain doesn't appear to bother to 540 # check for collisions. Assert they don't happen and 541 # we'll fix it if/when the case arises. 542 assert fullname not in self.objects, ( 543 "bug - collision on merge?" 544 f" {fullname=} {obj=} {self.objects[fullname]=}" 545 ) 546 self.objects[fullname] = obj 547 548 def find_obj( 549 self, modname: str, name: str, typ: Optional[str] 550 ) -> list[tuple[str, ObjectEntry]]: 551 """ 552 Find a QAPI object for "name", perhaps using the given module. 553 554 Returns a list of (name, object entry) tuples. 555 556 :param modname: The current module context (if any!) 557 under which we are searching. 558 :param name: The name of the x-ref to resolve; 559 may or may not include a leading module. 560 :param type: The role name of the x-ref we're resolving, if provided. 561 (This is absent for "any" lookups.) 562 """ 563 if not name: 564 return [] 565 566 names: list[str] = [] 567 matches: list[tuple[str, ObjectEntry]] = [] 568 569 fullname = name 570 if "." in fullname: 571 # We're searching for a fully qualified reference; 572 # ignore the contextual module. 573 pass 574 elif modname: 575 # We're searching for something from somewhere; 576 # try searching the current module first. 577 # e.g. :qapi:cmd:`query-block` or `query-block` is being searched. 578 fullname = f"{modname}.{name}" 579 580 if typ is None: 581 # type isn't specified, this is a generic xref. 582 # search *all* qapi-specific object types. 583 objtypes: List[str] = list(self.object_types) 584 else: 585 # type is specified and will be a role (e.g. obj, mod, cmd) 586 # convert this to eligible object types (e.g. command, module) 587 # using the QAPIDomain.object_types table. 588 objtypes = self.objtypes_for_role(typ, []) 589 590 if name in self.objects and self.objects[name].objtype in objtypes: 591 names = [name] 592 elif ( 593 fullname in self.objects 594 and self.objects[fullname].objtype in objtypes 595 ): 596 names = [fullname] 597 else: 598 # exact match wasn't found; e.g. we are searching for 599 # `query-block` from a different (or no) module. 600 searchname = "." + name 601 names = [ 602 oname 603 for oname in self.objects 604 if oname.endswith(searchname) 605 and self.objects[oname].objtype in objtypes 606 ] 607 608 matches = [(oname, self.objects[oname]) for oname in names] 609 if len(matches) > 1: 610 matches = [m for m in matches if not m[1].aliased] 611 return matches 612 613 def resolve_xref( 614 self, 615 env: BuildEnvironment, 616 fromdocname: str, 617 builder: Builder, 618 typ: str, 619 target: str, 620 node: pending_xref, 621 contnode: Element, 622 ) -> nodes.reference | None: 623 modname = node.get("qapi:module") 624 matches = self.find_obj(modname, target, typ) 625 626 if not matches: 627 return None 628 629 if len(matches) > 1: 630 logger.warning( 631 __("more than one target found for cross-reference %r: %s"), 632 target, 633 ", ".join(match[0] for match in matches), 634 type="ref", 635 subtype="qapi", 636 location=node, 637 ) 638 639 name, obj = matches[0] 640 return make_refnode( 641 builder, fromdocname, obj.docname, obj.node_id, contnode, name 642 ) 643 644 def resolve_any_xref( 645 self, 646 env: BuildEnvironment, 647 fromdocname: str, 648 builder: Builder, 649 target: str, 650 node: pending_xref, 651 contnode: Element, 652 ) -> List[Tuple[str, nodes.reference]]: 653 results: List[Tuple[str, nodes.reference]] = [] 654 matches = self.find_obj(node.get("qapi:module"), target, None) 655 for name, obj in matches: 656 rolename = self.role_for_objtype(obj.objtype) 657 assert rolename is not None 658 role = f"qapi:{rolename}" 659 refnode = make_refnode( 660 builder, fromdocname, obj.docname, obj.node_id, contnode, name 661 ) 662 results.append((role, refnode)) 663 return results 664 665 666def setup(app: Sphinx) -> Dict[str, Any]: 667 app.setup_extension("sphinx.directives") 668 app.add_domain(QAPIDomain) 669 670 return { 671 "version": "1.0", 672 "env_version": 1, 673 "parallel_read_safe": True, 674 "parallel_write_safe": True, 675 } 676