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