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