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