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.util import logging 30from sphinx.util.nodes import make_refnode 31 32 33if TYPE_CHECKING: 34 from docutils.nodes import Element 35 36 from sphinx.application import Sphinx 37 from sphinx.builders import Builder 38 from sphinx.environment import BuildEnvironment 39 40logger = logging.getLogger(__name__) 41 42 43class ObjectEntry(NamedTuple): 44 docname: str 45 node_id: str 46 objtype: str 47 aliased: bool 48 49 50class QAPIIndex(Index): 51 """ 52 Index subclass to provide the QAPI definition index. 53 """ 54 55 # pylint: disable=too-few-public-methods 56 57 name = "index" 58 localname = _("QAPI Index") 59 shortname = _("QAPI Index") 60 61 def generate( 62 self, 63 docnames: Optional[Iterable[str]] = None, 64 ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: 65 assert isinstance(self.domain, QAPIDomain) 66 content: Dict[str, List[IndexEntry]] = {} 67 collapse = False 68 69 # list of all object (name, ObjectEntry) pairs, sorted by name 70 # (ignoring the module) 71 objects = sorted( 72 self.domain.objects.items(), 73 key=lambda x: x[0].split(".")[-1].lower(), 74 ) 75 76 for objname, obj in objects: 77 if docnames and obj.docname not in docnames: 78 continue 79 80 # Strip the module name out: 81 objname = objname.split(".")[-1] 82 83 # Add an alphabetical entry: 84 entries = content.setdefault(objname[0].upper(), []) 85 entries.append( 86 IndexEntry( 87 objname, 0, obj.docname, obj.node_id, obj.objtype, "", "" 88 ) 89 ) 90 91 # Add a categorical entry: 92 category = obj.objtype.title() + "s" 93 entries = content.setdefault(category, []) 94 entries.append( 95 IndexEntry(objname, 0, obj.docname, obj.node_id, "", "", "") 96 ) 97 98 # alphabetically sort categories; type names first, ABC entries last. 99 sorted_content = sorted( 100 content.items(), 101 key=lambda x: (len(x[0]) == 1, x[0]), 102 ) 103 return sorted_content, collapse 104 105 106class QAPIDomain(Domain): 107 """QAPI language domain.""" 108 109 name = "qapi" 110 label = "QAPI" 111 112 # This table associates cross-reference object types (key) with an 113 # ObjType instance, which defines the valid cross-reference roles 114 # for each object type. 115 116 # Actual table entries for module, command, event, etc will come in 117 # forthcoming commits. 118 object_types: Dict[str, ObjType] = {} 119 120 directives = {} 121 roles = {} 122 123 # Moved into the data property at runtime; 124 # this is the internal index of reference-able objects. 125 initial_data: Dict[str, Dict[str, Tuple[Any]]] = { 126 "objects": {}, # fullname -> ObjectEntry 127 } 128 129 # Index pages to generate; each entry is an Index class. 130 indices = [ 131 QAPIIndex, 132 ] 133 134 @property 135 def objects(self) -> Dict[str, ObjectEntry]: 136 ret = self.data.setdefault("objects", {}) 137 return ret # type: ignore[no-any-return] 138 139 def note_object( 140 self, 141 name: str, 142 objtype: str, 143 node_id: str, 144 aliased: bool = False, 145 location: Any = None, 146 ) -> None: 147 """Note a QAPI object for cross reference.""" 148 if name in self.objects: 149 other = self.objects[name] 150 if other.aliased and aliased is False: 151 # The original definition found. Override it! 152 pass 153 elif other.aliased is False and aliased: 154 # The original definition is already registered. 155 return 156 else: 157 # duplicated 158 logger.warning( 159 __( 160 "duplicate object description of %s, " 161 "other instance in %s, use :no-index: for one of them" 162 ), 163 name, 164 other.docname, 165 location=location, 166 ) 167 self.objects[name] = ObjectEntry( 168 self.env.docname, node_id, objtype, aliased 169 ) 170 171 def clear_doc(self, docname: str) -> None: 172 for fullname, obj in list(self.objects.items()): 173 if obj.docname == docname: 174 del self.objects[fullname] 175 176 def merge_domaindata( 177 self, docnames: AbstractSet[str], otherdata: Dict[str, Any] 178 ) -> None: 179 for fullname, obj in otherdata["objects"].items(): 180 if obj.docname in docnames: 181 # Sphinx's own python domain doesn't appear to bother to 182 # check for collisions. Assert they don't happen and 183 # we'll fix it if/when the case arises. 184 assert fullname not in self.objects, ( 185 "bug - collision on merge?" 186 f" {fullname=} {obj=} {self.objects[fullname]=}" 187 ) 188 self.objects[fullname] = obj 189 190 def find_obj( 191 self, modname: str, name: str, typ: Optional[str] 192 ) -> list[tuple[str, ObjectEntry]]: 193 """ 194 Find a QAPI object for "name", perhaps using the given module. 195 196 Returns a list of (name, object entry) tuples. 197 198 :param modname: The current module context (if any!) 199 under which we are searching. 200 :param name: The name of the x-ref to resolve; 201 may or may not include a leading module. 202 :param type: The role name of the x-ref we're resolving, if provided. 203 (This is absent for "any" lookups.) 204 """ 205 if not name: 206 return [] 207 208 names: list[str] = [] 209 matches: list[tuple[str, ObjectEntry]] = [] 210 211 fullname = name 212 if "." in fullname: 213 # We're searching for a fully qualified reference; 214 # ignore the contextual module. 215 pass 216 elif modname: 217 # We're searching for something from somewhere; 218 # try searching the current module first. 219 # e.g. :qapi:cmd:`query-block` or `query-block` is being searched. 220 fullname = f"{modname}.{name}" 221 222 if typ is None: 223 # type isn't specified, this is a generic xref. 224 # search *all* qapi-specific object types. 225 objtypes: List[str] = list(self.object_types) 226 else: 227 # type is specified and will be a role (e.g. obj, mod, cmd) 228 # convert this to eligible object types (e.g. command, module) 229 # using the QAPIDomain.object_types table. 230 objtypes = self.objtypes_for_role(typ, []) 231 232 if name in self.objects and self.objects[name].objtype in objtypes: 233 names = [name] 234 elif ( 235 fullname in self.objects 236 and self.objects[fullname].objtype in objtypes 237 ): 238 names = [fullname] 239 else: 240 # exact match wasn't found; e.g. we are searching for 241 # `query-block` from a different (or no) module. 242 searchname = "." + name 243 names = [ 244 oname 245 for oname in self.objects 246 if oname.endswith(searchname) 247 and self.objects[oname].objtype in objtypes 248 ] 249 250 matches = [(oname, self.objects[oname]) for oname in names] 251 if len(matches) > 1: 252 matches = [m for m in matches if not m[1].aliased] 253 return matches 254 255 def resolve_any_xref( 256 self, 257 env: BuildEnvironment, 258 fromdocname: str, 259 builder: Builder, 260 target: str, 261 node: pending_xref, 262 contnode: Element, 263 ) -> List[Tuple[str, nodes.reference]]: 264 results: List[Tuple[str, nodes.reference]] = [] 265 matches = self.find_obj(node.get("qapi:module"), target, None) 266 for name, obj in matches: 267 rolename = self.role_for_objtype(obj.objtype) 268 assert rolename is not None 269 role = f"qapi:{rolename}" 270 refnode = make_refnode( 271 builder, fromdocname, obj.docname, obj.node_id, contnode, name 272 ) 273 results.append((role, refnode)) 274 return results 275 276 277def setup(app: Sphinx) -> Dict[str, Any]: 278 app.setup_extension("sphinx.directives") 279 app.add_domain(QAPIDomain) 280 281 return { 282 "version": "1.0", 283 "env_version": 1, 284 "parallel_read_safe": True, 285 "parallel_write_safe": True, 286 } 287