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