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.nodes import make_id, make_refnode 37 38 39if TYPE_CHECKING: 40 from docutils.nodes import Element, Node 41 42 from sphinx.application import Sphinx 43 from sphinx.builders import Builder 44 from sphinx.environment import BuildEnvironment 45 from sphinx.util.typing import OptionSpec 46 47logger = logging.getLogger(__name__) 48 49 50class ObjectEntry(NamedTuple): 51 docname: str 52 node_id: str 53 objtype: str 54 aliased: bool 55 56 57class QAPIXRefRole(XRefRole): 58 59 def process_link( 60 self, 61 env: BuildEnvironment, 62 refnode: Element, 63 has_explicit_title: bool, 64 title: str, 65 target: str, 66 ) -> tuple[str, str]: 67 refnode["qapi:module"] = env.ref_context.get("qapi:module") 68 69 # Cross-references that begin with a tilde adjust the title to 70 # only show the reference without a leading module, even if one 71 # was provided. This is a Sphinx-standard syntax; give it 72 # priority over QAPI-specific type markup below. 73 hide_module = False 74 if target.startswith("~"): 75 hide_module = True 76 target = target[1:] 77 78 # Type names that end with "?" are considered optional 79 # arguments and should be documented as such, but it's not 80 # part of the xref itself. 81 if target.endswith("?"): 82 refnode["qapi:optional"] = True 83 target = target[:-1] 84 85 # Type names wrapped in brackets denote lists. strip the 86 # brackets and remember to add them back later. 87 if target.startswith("[") and target.endswith("]"): 88 refnode["qapi:array"] = True 89 target = target[1:-1] 90 91 if has_explicit_title: 92 # Don't mess with the title at all if it was explicitly set. 93 # Explicit title syntax for references is e.g. 94 # :qapi:type:`target <explicit title>` 95 # and this explicit title overrides everything else here. 96 return title, target 97 98 title = target 99 if hide_module: 100 title = target.split(".")[-1] 101 102 return title, target 103 104 105# Alias for the return of handle_signature(), which is used in several places. 106# (In the Python domain, this is Tuple[str, str] instead.) 107Signature = str 108 109 110class QAPIDescription(ObjectDescription[Signature]): 111 """ 112 Generic QAPI description. 113 114 This is meant to be an abstract class, not instantiated 115 directly. This class handles the abstract details of indexing, the 116 TOC, and reference targets for QAPI descriptions. 117 """ 118 119 def handle_signature(self, sig: str, signode: desc_signature) -> Signature: 120 # Do nothing. The return value here is the "name" of the entity 121 # being documented; for QAPI, this is the same as the 122 # "signature", which is just a name. 123 124 # Normally this method must also populate signode with nodes to 125 # render the signature; here we do nothing instead - the 126 # subclasses will handle this. 127 return sig 128 129 def get_index_text(self, name: Signature) -> Tuple[str, str]: 130 """Return the text for the index entry of the object.""" 131 132 # NB: this is used for the global index, not the QAPI index. 133 return ("single", f"{name} (QMP {self.objtype})") 134 135 def add_target_and_index( 136 self, name: Signature, sig: str, signode: desc_signature 137 ) -> None: 138 # name is the return value of handle_signature. 139 # sig is the original, raw text argument to handle_signature. 140 # For QAPI, these are identical, currently. 141 142 assert self.objtype 143 144 # If we're documenting a module, don't include the module as 145 # part of the FQN. 146 modname = "" 147 if self.objtype != "module": 148 modname = self.options.get( 149 "module", self.env.ref_context.get("qapi:module") 150 ) 151 fullname = (modname + "." if modname else "") + name 152 153 node_id = make_id( 154 self.env, self.state.document, self.objtype, fullname 155 ) 156 signode["ids"].append(node_id) 157 158 self.state.document.note_explicit_target(signode) 159 domain = cast(QAPIDomain, self.env.get_domain("qapi")) 160 domain.note_object(fullname, self.objtype, node_id, location=signode) 161 162 if "no-index-entry" not in self.options: 163 arity, indextext = self.get_index_text(name) 164 assert self.indexnode is not None 165 if indextext: 166 self.indexnode["entries"].append( 167 (arity, indextext, node_id, "", None) 168 ) 169 170 def _object_hierarchy_parts( 171 self, sig_node: desc_signature 172 ) -> Tuple[str, ...]: 173 if "fullname" not in sig_node: 174 return () 175 modname = sig_node.get("module") 176 fullname = sig_node["fullname"] 177 178 if modname: 179 return (modname, *fullname.split(".")) 180 181 return tuple(fullname.split(".")) 182 183 def _toc_entry_name(self, sig_node: desc_signature) -> str: 184 # This controls the name in the TOC and on the sidebar. 185 186 # This is the return type of _object_hierarchy_parts(). 187 toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ())) 188 if not toc_parts: 189 return "" 190 191 config = self.env.app.config 192 *parents, name = toc_parts 193 if config.toc_object_entries_show_parents == "domain": 194 return sig_node.get("fullname", name) 195 if config.toc_object_entries_show_parents == "hide": 196 return name 197 if config.toc_object_entries_show_parents == "all": 198 return ".".join(parents + [name]) 199 return "" 200 201 202class QAPIObject(QAPIDescription): 203 """ 204 Description of a generic QAPI object. 205 206 It's not used directly, but is instead subclassed by specific directives. 207 """ 208 209 # Inherit some standard options from Sphinx's ObjectDescription 210 option_spec: OptionSpec = ( # type:ignore[misc] 211 ObjectDescription.option_spec.copy() 212 ) 213 option_spec.update( 214 { 215 # Borrowed from the Python domain: 216 "module": directives.unchanged, # Override contextual module name 217 # These are QAPI originals: 218 "since": directives.unchanged, 219 } 220 ) 221 222 def get_signature_prefix(self) -> List[nodes.Node]: 223 """Return a prefix to put before the object name in the signature.""" 224 assert self.objtype 225 return [ 226 KeywordNode("", self.objtype.title()), 227 SpaceNode(" "), 228 ] 229 230 def get_signature_suffix(self) -> List[nodes.Node]: 231 """Return a suffix to put after the object name in the signature.""" 232 ret: List[nodes.Node] = [] 233 234 if "since" in self.options: 235 ret += [ 236 SpaceNode(" "), 237 addnodes.desc_sig_element( 238 "", f"(Since: {self.options['since']})" 239 ), 240 ] 241 242 return ret 243 244 def handle_signature(self, sig: str, signode: desc_signature) -> Signature: 245 """ 246 Transform a QAPI definition name into RST nodes. 247 248 This method was originally intended for handling function 249 signatures. In the QAPI domain, however, we only pass the 250 definition name as the directive argument and handle everything 251 else in the content body with field lists. 252 253 As such, the only argument here is "sig", which is just the QAPI 254 definition name. 255 """ 256 modname = self.options.get( 257 "module", self.env.ref_context.get("qapi:module") 258 ) 259 260 signode["fullname"] = sig 261 signode["module"] = modname 262 sig_prefix = self.get_signature_prefix() 263 if sig_prefix: 264 signode += addnodes.desc_annotation( 265 str(sig_prefix), "", *sig_prefix 266 ) 267 signode += addnodes.desc_name(sig, sig) 268 signode += self.get_signature_suffix() 269 270 return sig 271 272 273class QAPICommand(QAPIObject): 274 """Description of a QAPI Command.""" 275 276 # Nothing unique for now! Changed in later commits O:-) 277 278 279class QAPIModule(QAPIDescription): 280 """ 281 Directive to mark description of a new module. 282 283 This directive doesn't generate any special formatting, and is just 284 a pass-through for the content body. Named section titles are 285 allowed in the content body. 286 287 Use this directive to create entries for the QAPI module in the 288 global index and the QAPI index; as well as to associate subsequent 289 definitions with the module they are defined in for purposes of 290 search and QAPI index organization. 291 292 :arg: The name of the module. 293 :opt no-index: Don't add cross-reference targets or index entries. 294 :opt no-typesetting: Don't render the content body (but preserve any 295 cross-reference target IDs in the squelched output.) 296 297 Example:: 298 299 .. qapi:module:: block-core 300 :no-index: 301 :no-typesetting: 302 303 Lorem ipsum, dolor sit amet ... 304 """ 305 306 def run(self) -> List[Node]: 307 modname = self.arguments[0].strip() 308 self.env.ref_context["qapi:module"] = modname 309 ret = super().run() 310 311 # ObjectDescription always creates a visible signature bar. We 312 # want module items to be "invisible", however. 313 314 # Extract the content body of the directive: 315 assert isinstance(ret[-1], addnodes.desc) 316 desc_node = ret.pop(-1) 317 assert isinstance(desc_node.children[1], addnodes.desc_content) 318 ret.extend(desc_node.children[1].children) 319 320 # Re-home node_ids so anchor refs still work: 321 node_ids: List[str] 322 if node_ids := [ 323 node_id 324 for el in desc_node.children[0].traverse(nodes.Element) 325 for node_id in cast(List[str], el.get("ids", ())) 326 ]: 327 target_node = nodes.target(ids=node_ids) 328 ret.insert(1, target_node) 329 330 return ret 331 332 333class QAPIIndex(Index): 334 """ 335 Index subclass to provide the QAPI definition index. 336 """ 337 338 # pylint: disable=too-few-public-methods 339 340 name = "index" 341 localname = _("QAPI Index") 342 shortname = _("QAPI Index") 343 344 def generate( 345 self, 346 docnames: Optional[Iterable[str]] = None, 347 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 348 assert isinstance(self.domain, QAPIDomain) 349 content: Dict[str, List[IndexEntry]] = {} 350 collapse = False 351 352 # list of all object (name, ObjectEntry) pairs, sorted by name 353 # (ignoring the module) 354 objects = sorted( 355 self.domain.objects.items(), 356 key=lambda x: x[0].split(".")[-1].lower(), 357 ) 358 359 for objname, obj in objects: 360 if docnames and obj.docname not in docnames: 361 continue 362 363 # Strip the module name out: 364 objname = objname.split(".")[-1] 365 366 # Add an alphabetical entry: 367 entries = content.setdefault(objname[0].upper(), []) 368 entries.append( 369 IndexEntry( 370 objname, 0, obj.docname, obj.node_id, obj.objtype, "", "" 371 ) 372 ) 373 374 # Add a categorical entry: 375 category = obj.objtype.title() + "s" 376 entries = content.setdefault(category, []) 377 entries.append( 378 IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "") 379 ) 380 381 # alphabetically sort categories; type names first, ABC entries last. 382 sorted_content = sorted( 383 content.items(), 384 key=lambda x: (len(x[0]) == 1, x[0]), 385 ) 386 return sorted_content, collapse 387 388 389class QAPIDomain(Domain): 390 """QAPI language domain.""" 391 392 name = "qapi" 393 label = "QAPI" 394 395 # This table associates cross-reference object types (key) with an 396 # ObjType instance, which defines the valid cross-reference roles 397 # for each object type. 398 object_types: Dict[str, ObjType] = { 399 "module": ObjType(_("module"), "mod", "any"), 400 "command": ObjType(_("command"), "cmd", "any"), 401 } 402 403 # Each of these provides a rST directive, 404 # e.g. .. qapi:module:: block-core 405 directives = { 406 "module": QAPIModule, 407 "command": QAPICommand, 408 } 409 410 # These are all cross-reference roles; e.g. 411 # :qapi:cmd:`query-block`. The keys correlate to the names used in 412 # the object_types table values above. 413 roles = { 414 "mod": QAPIXRefRole(), 415 "cmd": QAPIXRefRole(), 416 "any": QAPIXRefRole(), # reference *any* type of QAPI object. 417 } 418 419 # Moved into the data property at runtime; 420 # this is the internal index of reference-able objects. 421 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 422 "objects": {}, # fullname -> ObjectEntry 423 } 424 425 # Index pages to generate; each entry is an Index class. 426 indices = [ 427 QAPIIndex, 428 ] 429 430 @property 431 def objects(self) -> Dict[str, ObjectEntry]: 432 ret = self.data.setdefault("objects", {}) 433 return ret # type: ignore[no-any-return] 434 435 def note_object( 436 self, 437 name: str, 438 objtype: str, 439 node_id: str, 440 aliased: bool = False, 441 location: Any = None, 442 ) -> None: 443 """Note a QAPI object for cross reference.""" 444 if name in self.objects: 445 other = self.objects[name] 446 if other.aliased and aliased is False: 447 # The original definition found. Override it! 448 pass 449 elif other.aliased is False and aliased: 450 # The original definition is already registered. 451 return 452 else: 453 # duplicated 454 logger.warning( 455 __( 456 "duplicate object description of %s, " 457 "other instance in %s, use :no-index: for one of them" 458 ), 459 name, 460 other.docname, 461 location=location, 462 ) 463 self.objects[name] = ObjectEntry( 464 self.env.docname, node_id, objtype, aliased 465 ) 466 467 def clear_doc(self, docname: str) -> None: 468 for fullname, obj in list(self.objects.items()): 469 if obj.docname == docname: 470 del self.objects[fullname] 471 472 def merge_domaindata( 473 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 474 ) -> None: 475 for fullname, obj in otherdata["objects"].items(): 476 if obj.docname in docnames: 477 # Sphinx's own python domain doesn't appear to bother to 478 # check for collisions. Assert they don't happen and 479 # we'll fix it if/when the case arises. 480 assert fullname not in self.objects, ( 481 "bug - collision on merge?" 482 f" {fullname=} {obj=} {self.objects[fullname]=}" 483 ) 484 self.objects[fullname] = obj 485 486 def find_obj( 487 self, modname: str, name: str, typ: Optional[str] 488 ) -> list[tuple[str, ObjectEntry]]: 489 """ 490 Find a QAPI object for "name", perhaps using the given module. 491 492 Returns a list of (name, object entry) tuples. 493 494 :param modname: The current module context (if any!) 495 under which we are searching. 496 :param name: The name of the x-ref to resolve; 497 may or may not include a leading module. 498 :param type: The role name of the x-ref we're resolving, if provided. 499 (This is absent for "any" lookups.) 500 """ 501 if not name: 502 return [] 503 504 names: list[str] = [] 505 matches: list[tuple[str, ObjectEntry]] = [] 506 507 fullname = name 508 if "." in fullname: 509 # We're searching for a fully qualified reference; 510 # ignore the contextual module. 511 pass 512 elif modname: 513 # We're searching for something from somewhere; 514 # try searching the current module first. 515 # e.g. :qapi:cmd:`query-block` or `query-block` is being searched. 516 fullname = f"{modname}.{name}" 517 518 if typ is None: 519 # type isn't specified, this is a generic xref. 520 # search *all* qapi-specific object types. 521 objtypes: List[str] = list(self.object_types) 522 else: 523 # type is specified and will be a role (e.g. obj, mod, cmd) 524 # convert this to eligible object types (e.g. command, module) 525 # using the QAPIDomain.object_types table. 526 objtypes = self.objtypes_for_role(typ, []) 527 528 if name in self.objects and self.objects[name].objtype in objtypes: 529 names = [name] 530 elif ( 531 fullname in self.objects 532 and self.objects[fullname].objtype in objtypes 533 ): 534 names = [fullname] 535 else: 536 # exact match wasn't found; e.g. we are searching for 537 # `query-block` from a different (or no) module. 538 searchname = "." + name 539 names = [ 540 oname 541 for oname in self.objects 542 if oname.endswith(searchname) 543 and self.objects[oname].objtype in objtypes 544 ] 545 546 matches = [(oname, self.objects[oname]) for oname in names] 547 if len(matches) > 1: 548 matches = [m for m in matches if not m[1].aliased] 549 return matches 550 551 def resolve_xref( 552 self, 553 env: BuildEnvironment, 554 fromdocname: str, 555 builder: Builder, 556 typ: str, 557 target: str, 558 node: pending_xref, 559 contnode: Element, 560 ) -> nodes.reference | None: 561 modname = node.get("qapi:module") 562 matches = self.find_obj(modname, target, typ) 563 564 if not matches: 565 return None 566 567 if len(matches) > 1: 568 logger.warning( 569 __("more than one target found for cross-reference %r: %s"), 570 target, 571 ", ".join(match[0] for match in matches), 572 type="ref", 573 subtype="qapi", 574 location=node, 575 ) 576 577 name, obj = matches[0] 578 return make_refnode( 579 builder, fromdocname, obj.docname, obj.node_id, contnode, name 580 ) 581 582 def resolve_any_xref( 583 self, 584 env: BuildEnvironment, 585 fromdocname: str, 586 builder: Builder, 587 target: str, 588 node: pending_xref, 589 contnode: Element, 590 ) -> List[Tuple[str, nodes.reference]]: 591 results: List[Tuple[str, nodes.reference]] = [] 592 matches = self.find_obj(node.get("qapi:module"), target, None) 593 for name, obj in matches: 594 rolename = self.role_for_objtype(obj.objtype) 595 assert rolename is not None 596 role = f"qapi:{rolename}" 597 refnode = make_refnode( 598 builder, fromdocname, obj.docname, obj.node_id, contnode, name 599 ) 600 results.append((role, refnode)) 601 return results 602 603 604def setup(app: Sphinx) -> Dict[str, Any]: 605 app.setup_extension("sphinx.directives") 606 app.add_domain(QAPIDomain) 607 608 return { 609 "version": "1.0", 610 "env_version": 1, 611 "parallel_read_safe": True, 612 "parallel_write_safe": True, 613 } 614