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