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