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