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