| 1 | """ |
| 2 | QAPI domain extension. |
| 3 | """ |
| 4 | |
| 5 | # The best laid plans of mice and men, ... |
| 6 | # pylint: disable=too-many-lines |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import re |
| 11 | import types |
| 12 | from typing import ( |
| 13 | TYPE_CHECKING, |
| 14 | List, |
| 15 | NamedTuple, |
| 16 | Tuple, |
| 17 | Type, |
| 18 | cast, |
| 19 | ) |
| 20 | |
| 21 | from docutils import nodes |
| 22 | from docutils.parsers.rst import directives |
| 23 | from sphinx import addnodes |
| 24 | from sphinx.directives import ObjectDescription |
| 25 | from sphinx.domains import ( |
| 26 | Domain, |
| 27 | Index, |
| 28 | IndexEntry, |
| 29 | ObjType, |
| 30 | ) |
| 31 | from sphinx.locale import _, __ |
| 32 | from sphinx.roles import XRefRole |
| 33 | from sphinx.util import logging |
| 34 | from sphinx.util.docutils import SphinxDirective |
| 35 | from sphinx.util.nodes import make_id, make_refnode |
| 36 | |
| 37 | from compat import ( |
| 38 | CompatField, |
| 39 | CompatGroupedField, |
| 40 | CompatTypedField, |
| 41 | KeywordNode, |
| 42 | ParserFix, |
| 43 | Signature, |
| 44 | SpaceNode, |
| 45 | ) |
| 46 | |
| 47 | |
| 48 | if TYPE_CHECKING: |
| 49 | from typing import ( |
| 50 | AbstractSet, |
| 51 | Any, |
| 52 | Dict, |
| 53 | Iterable, |
| 54 | Optional, |
| 55 | Union, |
| 56 | ) |
| 57 | |
| 58 | from docutils.nodes import Element, Node |
| 59 | from sphinx.addnodes import desc_signature, pending_xref |
| 60 | from sphinx.application import Sphinx |
| 61 | from sphinx.builders import Builder |
| 62 | from sphinx.environment import BuildEnvironment |
| 63 | from sphinx.util.typing import OptionSpec |
| 64 | |
| 65 | |
| 66 | logger = logging.getLogger(__name__) |
| 67 | |
| 68 | |
| 69 | def _unpack_field( |
| 70 | field: nodes.Node, |
| 71 | ) -> Tuple[nodes.field_name, nodes.field_body]: |
| 72 | """ |
| 73 | docutils helper: unpack a field node in a type-safe manner. |
| 74 | """ |
| 75 | assert isinstance(field, nodes.field) |
| 76 | assert len(field.children) == 2 |
| 77 | assert isinstance(field.children[0], nodes.field_name) |
| 78 | assert isinstance(field.children[1], nodes.field_body) |
| 79 | return (field.children[0], field.children[1]) |
| 80 | |
| 81 | |
| 82 | class ObjectEntry(NamedTuple): |
| 83 | docname: str |
| 84 | node_id: str |
| 85 | objtype: str |
| 86 | aliased: bool |
| 87 | |
| 88 | |
| 89 | class QAPIXRefRole(XRefRole): |
| 90 | |
| 91 | def process_link( |
| 92 | self, |
| 93 | env: BuildEnvironment, |
| 94 | refnode: Element, |
| 95 | has_explicit_title: bool, |
| 96 | title: str, |
| 97 | target: str, |
| 98 | ) -> tuple[str, str]: |
| 99 | refnode["qapi:namespace"] = env.ref_context.get("qapi:namespace") |
| 100 | refnode["qapi:module"] = env.ref_context.get("qapi:module") |
| 101 | |
| 102 | # Cross-references that begin with a tilde adjust the title to |
| 103 | # only show the reference without a leading module, even if one |
| 104 | # was provided. This is a Sphinx-standard syntax; give it |
| 105 | # priority over QAPI-specific type markup below. |
| 106 | hide_module = False |
| 107 | if target.startswith("~"): |
| 108 | hide_module = True |
| 109 | target = target[1:] |
| 110 | |
| 111 | # Type names that end with "?" are considered optional |
| 112 | # arguments and should be documented as such, but it's not |
| 113 | # part of the xref itself. |
| 114 | if target.endswith("?"): |
| 115 | refnode["qapi:optional"] = True |
| 116 | target = target[:-1] |
| 117 | |
| 118 | # Type names wrapped in brackets denote lists. strip the |
| 119 | # brackets and remember to add them back later. |
| 120 | if target.startswith("[") and target.endswith("]"): |
| 121 | refnode["qapi:array"] = True |
| 122 | target = target[1:-1] |
| 123 | |
| 124 | if has_explicit_title: |
| 125 | # Don't mess with the title at all if it was explicitly set. |
| 126 | # Explicit title syntax for references is e.g. |
| 127 | # :qapi:type:`target <explicit title>` |
| 128 | # and this explicit title overrides everything else here. |
| 129 | return title, target |
| 130 | |
| 131 | title = target |
| 132 | if hide_module: |
| 133 | title = target.split(".")[-1] |
| 134 | |
| 135 | return title, target |
| 136 | |
| 137 | def result_nodes( |
| 138 | self, |
| 139 | document: nodes.document, |
| 140 | env: BuildEnvironment, |
| 141 | node: Element, |
| 142 | is_ref: bool, |
| 143 | ) -> Tuple[List[nodes.Node], List[nodes.system_message]]: |
| 144 | |
| 145 | # node here is the pending_xref node (or whatever nodeclass was |
| 146 | # configured at XRefRole class instantiation time). |
| 147 | results: List[nodes.Node] = [node] |
| 148 | |
| 149 | if node.get("qapi:array"): |
| 150 | results.insert(0, nodes.literal("[", "[")) |
| 151 | results.append(nodes.literal("]", "]")) |
| 152 | |
| 153 | if node.get("qapi:optional"): |
| 154 | results.append(nodes.Text(", ")) |
| 155 | results.append(nodes.emphasis("?", "optional")) |
| 156 | |
| 157 | return results, [] |
| 158 | |
| 159 | |
| 160 | class QAPIDescription(ParserFix): |
| 161 | """ |
| 162 | Generic QAPI description. |
| 163 | |
| 164 | This is meant to be an abstract class, not instantiated |
| 165 | directly. This class handles the abstract details of indexing, the |
| 166 | TOC, and reference targets for QAPI descriptions. |
| 167 | """ |
| 168 | |
| 169 | def handle_signature(self, sig: str, signode: desc_signature) -> Signature: |
| 170 | # pylint: disable=unused-argument |
| 171 | |
| 172 | # Do nothing. The return value here is the "name" of the entity |
| 173 | # being documented; for QAPI, this is the same as the |
| 174 | # "signature", which is just a name. |
| 175 | |
| 176 | # Normally this method must also populate signode with nodes to |
| 177 | # render the signature; here we do nothing instead - the |
| 178 | # subclasses will handle this. |
| 179 | return sig |
| 180 | |
| 181 | def get_index_text(self, name: Signature) -> Tuple[str, str]: |
| 182 | """Return the text for the index entry of the object.""" |
| 183 | |
| 184 | # NB: this is used for the global index, not the QAPI index. |
| 185 | return ("single", f"{name} (QMP {self.objtype})") |
| 186 | |
| 187 | def _get_context(self) -> Tuple[str, str]: |
| 188 | namespace = self.options.get( |
| 189 | "namespace", self.env.ref_context.get("qapi:namespace", "") |
| 190 | ) |
| 191 | modname = self.options.get( |
| 192 | "module", self.env.ref_context.get("qapi:module", "") |
| 193 | ) |
| 194 | |
| 195 | return namespace, modname |
| 196 | |
| 197 | def _get_fqn(self, name: Signature) -> str: |
| 198 | namespace, modname = self._get_context() |
| 199 | |
| 200 | # If we're documenting a module, don't include the module as |
| 201 | # part of the FQN; we ARE the module! |
| 202 | if self.objtype == "module": |
| 203 | modname = "" |
| 204 | |
| 205 | if modname: |
| 206 | name = f"{modname}.{name}" |
| 207 | if namespace: |
| 208 | name = f"{namespace}:{name}" |
| 209 | return name |
| 210 | |
| 211 | def add_target_and_index( |
| 212 | self, name: Signature, sig: str, signode: desc_signature |
| 213 | ) -> None: |
| 214 | # pylint: disable=unused-argument |
| 215 | |
| 216 | # name is the return value of handle_signature. |
| 217 | # sig is the original, raw text argument to handle_signature. |
| 218 | # For QAPI, these are identical, currently. |
| 219 | |
| 220 | assert self.objtype |
| 221 | |
| 222 | if not (fullname := signode.get("fullname", "")): |
| 223 | fullname = self._get_fqn(name) |
| 224 | |
| 225 | node_id = make_id( |
| 226 | self.env, self.state.document, self.objtype, fullname |
| 227 | ) |
| 228 | signode["ids"].append(node_id) |
| 229 | |
| 230 | self.state.document.note_explicit_target(signode) |
| 231 | domain = cast(QAPIDomain, self.env.get_domain("qapi")) |
| 232 | domain.note_object(fullname, self.objtype, node_id, location=signode) |
| 233 | |
| 234 | if "no-index-entry" not in self.options: |
| 235 | arity, indextext = self.get_index_text(name) |
| 236 | assert self.indexnode is not None |
| 237 | if indextext: |
| 238 | self.indexnode["entries"].append( |
| 239 | (arity, indextext, node_id, "", None) |
| 240 | ) |
| 241 | |
| 242 | @staticmethod |
| 243 | def split_fqn(name: str) -> Tuple[str, str, str]: |
| 244 | if ":" in name: |
| 245 | ns, name = name.split(":") |
| 246 | else: |
| 247 | ns = "" |
| 248 | |
| 249 | if "." in name: |
| 250 | module, name = name.split(".") |
| 251 | else: |
| 252 | module = "" |
| 253 | |
| 254 | return (ns, module, name) |
| 255 | |
| 256 | def _object_hierarchy_parts( |
| 257 | self, sig_node: desc_signature |
| 258 | ) -> Tuple[str, ...]: |
| 259 | if "fullname" not in sig_node: |
| 260 | return () |
| 261 | return self.split_fqn(sig_node["fullname"]) |
| 262 | |
| 263 | def _toc_entry_name(self, sig_node: desc_signature) -> str: |
| 264 | # This controls the name in the TOC and on the sidebar. |
| 265 | |
| 266 | # This is the return type of _object_hierarchy_parts(). |
| 267 | toc_parts = cast(Tuple[str, ...], sig_node.get("_toc_parts", ())) |
| 268 | if not toc_parts: |
| 269 | return "" |
| 270 | |
| 271 | config = self.env.app.config |
| 272 | namespace, modname, name = toc_parts |
| 273 | |
| 274 | if config.toc_object_entries_show_parents == "domain": |
| 275 | ret = name |
| 276 | if modname and modname != self.env.ref_context.get( |
| 277 | "qapi:module", "" |
| 278 | ): |
| 279 | ret = f"{modname}.{name}" |
| 280 | if namespace and namespace != self.env.ref_context.get( |
| 281 | "qapi:namespace", "" |
| 282 | ): |
| 283 | ret = f"{namespace}:{ret}" |
| 284 | return ret |
| 285 | if config.toc_object_entries_show_parents == "hide": |
| 286 | return name |
| 287 | if config.toc_object_entries_show_parents == "all": |
| 288 | return sig_node.get("fullname", name) |
| 289 | return "" |
| 290 | |
| 291 | |
| 292 | class QAPIObject(QAPIDescription): |
| 293 | """ |
| 294 | Description of a generic QAPI object. |
| 295 | |
| 296 | It's not used directly, but is instead subclassed by specific directives. |
| 297 | """ |
| 298 | |
| 299 | # Inherit some standard options from Sphinx's ObjectDescription |
| 300 | option_spec: OptionSpec = ( # type:ignore[misc] |
| 301 | ObjectDescription.option_spec.copy() |
| 302 | ) |
| 303 | option_spec.update( |
| 304 | { |
| 305 | # Context overrides: |
| 306 | "namespace": directives.unchanged, |
| 307 | "module": directives.unchanged, |
| 308 | # These are QAPI originals: |
| 309 | "since": directives.unchanged, |
| 310 | "ifcond": directives.unchanged, |
| 311 | "deprecated": directives.flag, |
| 312 | "unstable": directives.flag, |
| 313 | } |
| 314 | ) |
| 315 | |
| 316 | doc_field_types = [ |
| 317 | # :feat name: descr |
| 318 | CompatGroupedField( |
| 319 | "feature", |
| 320 | label=_("Features"), |
| 321 | names=("feat",), |
| 322 | can_collapse=False, |
| 323 | ), |
| 324 | ] |
| 325 | |
| 326 | def get_signature_prefix(self) -> List[nodes.Node]: |
| 327 | """Return a prefix to put before the object name in the signature.""" |
| 328 | assert self.objtype |
| 329 | return [ |
| 330 | KeywordNode("", self.objtype.title()), |
| 331 | SpaceNode(" "), |
| 332 | ] |
| 333 | |
| 334 | def get_signature_suffix(self) -> List[nodes.Node]: |
| 335 | """Return a suffix to put after the object name in the signature.""" |
| 336 | ret: List[nodes.Node] = [] |
| 337 | |
| 338 | if "since" in self.options: |
| 339 | ret += [ |
| 340 | SpaceNode(" "), |
| 341 | addnodes.desc_sig_element( |
| 342 | "", f"(Since: {self.options['since']})" |
| 343 | ), |
| 344 | ] |
| 345 | |
| 346 | return ret |
| 347 | |
| 348 | def handle_signature(self, sig: str, signode: desc_signature) -> Signature: |
| 349 | """ |
| 350 | Transform a QAPI definition name into RST nodes. |
| 351 | |
| 352 | This method was originally intended for handling function |
| 353 | signatures. In the QAPI domain, however, we only pass the |
| 354 | definition name as the directive argument and handle everything |
| 355 | else in the content body with field lists. |
| 356 | |
| 357 | As such, the only argument here is "sig", which is just the QAPI |
| 358 | definition name. |
| 359 | """ |
| 360 | # No module or domain info allowed in the signature! |
| 361 | assert ":" not in sig |
| 362 | assert "." not in sig |
| 363 | |
| 364 | namespace, modname = self._get_context() |
| 365 | signode["fullname"] = self._get_fqn(sig) |
| 366 | signode["namespace"] = namespace |
| 367 | signode["module"] = modname |
| 368 | |
| 369 | sig_prefix = self.get_signature_prefix() |
| 370 | if sig_prefix: |
| 371 | signode += addnodes.desc_annotation( |
| 372 | str(sig_prefix), "", *sig_prefix |
| 373 | ) |
| 374 | signode += addnodes.desc_name(sig, sig) |
| 375 | signode += self.get_signature_suffix() |
| 376 | |
| 377 | return sig |
| 378 | |
| 379 | def _add_infopips(self, contentnode: addnodes.desc_content) -> None: |
| 380 | # Add various eye-catches and things that go below the signature |
| 381 | # bar, but precede the user-defined content. |
| 382 | infopips = nodes.container() |
| 383 | infopips.attributes["classes"].append("qapi-infopips") |
| 384 | |
| 385 | def _add_pip( |
| 386 | source: str, content: Union[str, List[nodes.Node]], classname: str |
| 387 | ) -> None: |
| 388 | node = nodes.container(source) |
| 389 | if isinstance(content, str): |
| 390 | node.append(nodes.Text(content)) |
| 391 | else: |
| 392 | node.extend(content) |
| 393 | node.attributes["classes"].extend(["qapi-infopip", classname]) |
| 394 | infopips.append(node) |
| 395 | |
| 396 | if "deprecated" in self.options: |
| 397 | _add_pip( |
| 398 | ":deprecated:", |
| 399 | f"This {self.objtype} is deprecated.", |
| 400 | "qapi-deprecated", |
| 401 | ) |
| 402 | |
| 403 | if "unstable" in self.options: |
| 404 | _add_pip( |
| 405 | ":unstable:", |
| 406 | f"This {self.objtype} is unstable/experimental.", |
| 407 | "qapi-unstable", |
| 408 | ) |
| 409 | |
| 410 | if self.options.get("ifcond", ""): |
| 411 | ifcond = self.options["ifcond"] |
| 412 | _add_pip( |
| 413 | f":ifcond: {ifcond}", |
| 414 | [ |
| 415 | nodes.emphasis("", "Availability"), |
| 416 | nodes.Text(": "), |
| 417 | nodes.literal(ifcond, ifcond), |
| 418 | ], |
| 419 | "qapi-ifcond", |
| 420 | ) |
| 421 | |
| 422 | if infopips.children: |
| 423 | contentnode.insert(0, infopips) |
| 424 | |
| 425 | def _validate_field(self, field: nodes.field) -> None: |
| 426 | """Validate field lists in this QAPI Object Description.""" |
| 427 | name, _ = _unpack_field(field) |
| 428 | allowed_fields = set(self.env.app.config.qapi_allowed_fields) |
| 429 | |
| 430 | field_label = name.astext() |
| 431 | if field_label in allowed_fields: |
| 432 | # Explicitly allowed field list name, OK. |
| 433 | return |
| 434 | |
| 435 | try: |
| 436 | # split into field type and argument (if provided) |
| 437 | # e.g. `:arg type name: descr` is |
| 438 | # field_type = "arg", field_arg = "type name". |
| 439 | field_type, field_arg = field_label.split(None, 1) |
| 440 | except ValueError: |
| 441 | # No arguments provided |
| 442 | field_type = field_label |
| 443 | field_arg = "" |
| 444 | |
| 445 | typemap = self.get_field_type_map() |
| 446 | if field_type in typemap: |
| 447 | # This is a special docfield, yet-to-be-processed. Catch |
| 448 | # correct names, but incorrect arguments. This mismatch WILL |
| 449 | # cause Sphinx to render this field incorrectly (without a |
| 450 | # warning), which is never what we want. |
| 451 | typedesc = typemap[field_type][0] |
| 452 | if typedesc.has_arg != bool(field_arg): |
| 453 | msg = f"docfield field list type {field_type!r} " |
| 454 | if typedesc.has_arg: |
| 455 | msg += "requires an argument." |
| 456 | else: |
| 457 | msg += "takes no arguments." |
| 458 | logger.warning(msg, location=field) |
| 459 | else: |
| 460 | # This is unrecognized entirely. It's valid rST to use |
| 461 | # arbitrary fields, but let's ensure the documentation |
| 462 | # writer has done this intentionally. |
| 463 | valid = ", ".join(sorted(set(typemap) | allowed_fields)) |
| 464 | msg = ( |
| 465 | f"Unrecognized field list name {field_label!r}.\n" |
| 466 | f"Valid fields for qapi:{self.objtype} are: {valid}\n" |
| 467 | "\n" |
| 468 | "If this usage is intentional, please add it to " |
| 469 | "'qapi_allowed_fields' in docs/conf.py." |
| 470 | ) |
| 471 | logger.warning(msg, location=field) |
| 472 | |
| 473 | def transform_content(self, content_node: addnodes.desc_content) -> None: |
| 474 | # This hook runs after before_content and the nested parse, but |
| 475 | # before the DocFieldTransformer is executed. |
| 476 | super().transform_content(content_node) |
| 477 | |
| 478 | self._add_infopips(content_node) |
| 479 | |
| 480 | # Validate field lists. |
| 481 | for child in content_node: |
| 482 | if isinstance(child, nodes.field_list): |
| 483 | for field in child.children: |
| 484 | assert isinstance(field, nodes.field) |
| 485 | self._validate_field(field) |
| 486 | |
| 487 | |
| 488 | class SpecialTypedField(CompatTypedField): |
| 489 | def make_field(self, *args: Any, **kwargs: Any) -> nodes.field: |
| 490 | ret = super().make_field(*args, **kwargs) |
| 491 | |
| 492 | # Look for the characteristic " -- " text node that Sphinx |
| 493 | # inserts for each TypedField entry ... |
| 494 | for node in ret.traverse(lambda n: str(n) == " -- "): |
| 495 | par = node.parent |
| 496 | if par.children[0].astext() != "q_dummy": |
| 497 | continue |
| 498 | |
| 499 | # If the first node's text is q_dummy, this is a dummy |
| 500 | # field we want to strip down to just its contents. |
| 501 | del par.children[:-1] |
| 502 | |
| 503 | return ret |
| 504 | |
| 505 | |
| 506 | class QAPICommand(QAPIObject): |
| 507 | """Description of a QAPI Command.""" |
| 508 | |
| 509 | doc_field_types = QAPIObject.doc_field_types.copy() |
| 510 | doc_field_types.extend( |
| 511 | [ |
| 512 | # :arg TypeName ArgName: descr |
| 513 | SpecialTypedField( |
| 514 | "argument", |
| 515 | label=_("Arguments"), |
| 516 | names=("arg",), |
| 517 | typerolename="type", |
| 518 | can_collapse=False, |
| 519 | ), |
| 520 | # :error: descr |
| 521 | CompatField( |
| 522 | "error", |
| 523 | label=_("Errors"), |
| 524 | names=("error", "errors"), |
| 525 | has_arg=False, |
| 526 | ), |
| 527 | # :return TypeName: descr |
| 528 | CompatGroupedField( |
| 529 | "returnvalue", |
| 530 | label=_("Return"), |
| 531 | rolename="type", |
| 532 | names=("return",), |
| 533 | can_collapse=True, |
| 534 | ), |
| 535 | # :return-nodesc: TypeName |
| 536 | CompatField( |
| 537 | "returnvalue", |
| 538 | label=_("Return"), |
| 539 | names=("return-nodesc",), |
| 540 | bodyrolename="type", |
| 541 | has_arg=False, |
| 542 | ), |
| 543 | ] |
| 544 | ) |
| 545 | |
| 546 | |
| 547 | class QAPIEnum(QAPIObject): |
| 548 | """Description of a QAPI Enum.""" |
| 549 | |
| 550 | doc_field_types = QAPIObject.doc_field_types.copy() |
| 551 | doc_field_types.extend( |
| 552 | [ |
| 553 | # :value name: descr |
| 554 | CompatGroupedField( |
| 555 | "value", |
| 556 | label=_("Values"), |
| 557 | names=("value",), |
| 558 | can_collapse=False, |
| 559 | ) |
| 560 | ] |
| 561 | ) |
| 562 | |
| 563 | |
| 564 | class QAPIAlternate(QAPIObject): |
| 565 | """Description of a QAPI Alternate.""" |
| 566 | |
| 567 | doc_field_types = QAPIObject.doc_field_types.copy() |
| 568 | doc_field_types.extend( |
| 569 | [ |
| 570 | # :alt type name: descr |
| 571 | CompatTypedField( |
| 572 | "alternative", |
| 573 | label=_("Alternatives"), |
| 574 | names=("alt",), |
| 575 | typerolename="type", |
| 576 | can_collapse=False, |
| 577 | ), |
| 578 | ] |
| 579 | ) |
| 580 | |
| 581 | |
| 582 | class QAPIObjectWithMembers(QAPIObject): |
| 583 | """Base class for Events/Structs/Unions""" |
| 584 | |
| 585 | doc_field_types = QAPIObject.doc_field_types.copy() |
| 586 | doc_field_types.extend( |
| 587 | [ |
| 588 | # :member type name: descr |
| 589 | SpecialTypedField( |
| 590 | "member", |
| 591 | label=_("Members"), |
| 592 | names=("memb",), |
| 593 | typerolename="type", |
| 594 | can_collapse=False, |
| 595 | ), |
| 596 | ] |
| 597 | ) |
| 598 | |
| 599 | |
| 600 | class QAPIEvent(QAPIObjectWithMembers): |
| 601 | # pylint: disable=too-many-ancestors |
| 602 | """Description of a QAPI Event.""" |
| 603 | |
| 604 | |
| 605 | class QAPIJSONObject(QAPIObjectWithMembers): |
| 606 | # pylint: disable=too-many-ancestors |
| 607 | """Description of a QAPI Object: structs and unions.""" |
| 608 | |
| 609 | |
| 610 | class QAPIModule(QAPIDescription): |
| 611 | """ |
| 612 | Directive to mark description of a new module. |
| 613 | |
| 614 | This directive doesn't generate any special formatting, and is just |
| 615 | a pass-through for the content body. Named section titles are |
| 616 | allowed in the content body. |
| 617 | |
| 618 | Use this directive to create entries for the QAPI module in the |
| 619 | global index and the QAPI index; as well as to associate subsequent |
| 620 | definitions with the module they are defined in for purposes of |
| 621 | search and QAPI index organization. |
| 622 | |
| 623 | :arg: The name of the module. |
| 624 | :opt no-index: Don't add cross-reference targets or index entries. |
| 625 | :opt no-typesetting: Don't render the content body (but preserve any |
| 626 | cross-reference target IDs in the squelched output.) |
| 627 | |
| 628 | Example:: |
| 629 | |
| 630 | .. qapi:module:: block-core |
| 631 | :no-index: |
| 632 | :no-typesetting: |
| 633 | |
| 634 | Lorem ipsum, dolor sit amet ... |
| 635 | """ |
| 636 | |
| 637 | def run(self) -> List[Node]: |
| 638 | modname = self.arguments[0].strip() |
| 639 | self.env.ref_context["qapi:module"] = modname |
| 640 | ret = super().run() |
| 641 | |
| 642 | # ObjectDescription always creates a visible signature bar. We |
| 643 | # want module items to be "invisible", however. |
| 644 | |
| 645 | # Extract the content body of the directive: |
| 646 | assert isinstance(ret[-1], addnodes.desc) |
| 647 | desc_node = ret.pop(-1) |
| 648 | assert isinstance(desc_node.children[1], addnodes.desc_content) |
| 649 | ret.extend(desc_node.children[1].children) |
| 650 | |
| 651 | # Re-home node_ids so anchor refs still work: |
| 652 | node_ids: List[str] |
| 653 | if node_ids := [ |
| 654 | node_id |
| 655 | for el in desc_node.children[0].traverse(nodes.Element) |
| 656 | for node_id in cast(List[str], el.get("ids", ())) |
| 657 | ]: |
| 658 | target_node = nodes.target(ids=node_ids) |
| 659 | ret.insert(1, target_node) |
| 660 | |
| 661 | return ret |
| 662 | |
| 663 | |
| 664 | class QAPINamespace(SphinxDirective): |
| 665 | has_content = False |
| 666 | required_arguments = 1 |
| 667 | |
| 668 | def run(self) -> List[Node]: |
| 669 | namespace = self.arguments[0].strip() |
| 670 | self.env.ref_context["qapi:namespace"] = namespace |
| 671 | |
| 672 | return [] |
| 673 | |
| 674 | |
| 675 | class QAPIIndex(Index): |
| 676 | """ |
| 677 | Index subclass to provide the QAPI definition index. |
| 678 | """ |
| 679 | |
| 680 | # pylint: disable=too-few-public-methods |
| 681 | |
| 682 | name = "index" |
| 683 | localname = _("QAPI Index") |
| 684 | shortname = _("QAPI Index") |
| 685 | namespace = "" |
| 686 | |
| 687 | def generate( |
| 688 | self, |
| 689 | docnames: Optional[Iterable[str]] = None, |
| 690 | ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: |
| 691 | assert isinstance(self.domain, QAPIDomain) |
| 692 | content: Dict[str, List[IndexEntry]] = {} |
| 693 | collapse = False |
| 694 | |
| 695 | for objname, obj in self.domain.objects.items(): |
| 696 | if docnames and obj.docname not in docnames: |
| 697 | continue |
| 698 | |
| 699 | ns, _mod, name = QAPIDescription.split_fqn(objname) |
| 700 | |
| 701 | if self.namespace != ns: |
| 702 | continue |
| 703 | |
| 704 | # Add an alphabetical entry: |
| 705 | entries = content.setdefault(name[0].upper(), []) |
| 706 | entries.append( |
| 707 | IndexEntry( |
| 708 | name, 0, obj.docname, obj.node_id, obj.objtype, "", "" |
| 709 | ) |
| 710 | ) |
| 711 | |
| 712 | # Add a categorical entry: |
| 713 | category = obj.objtype.title() + "s" |
| 714 | entries = content.setdefault(category, []) |
| 715 | entries.append( |
| 716 | IndexEntry(name, 0, obj.docname, obj.node_id, "", "", "") |
| 717 | ) |
| 718 | |
| 719 | # Sort entries within each category alphabetically |
| 720 | for category in content: |
| 721 | content[category] = sorted(content[category]) |
| 722 | |
| 723 | # Sort the categories themselves; type names first, ABC entries last. |
| 724 | sorted_content = sorted( |
| 725 | content.items(), |
| 726 | key=lambda x: (len(x[0]) == 1, x[0]), |
| 727 | ) |
| 728 | return sorted_content, collapse |
| 729 | |
| 730 | |
| 731 | class QAPIDomain(Domain): |
| 732 | """QAPI language domain.""" |
| 733 | |
| 734 | name = "qapi" |
| 735 | label = "QAPI" |
| 736 | |
| 737 | # This table associates cross-reference object types (key) with an |
| 738 | # ObjType instance, which defines the valid cross-reference roles |
| 739 | # for each object type. |
| 740 | # |
| 741 | # e.g., the :qapi:type: cross-reference role can refer to enum, |
| 742 | # struct, union, or alternate objects; but :qapi:obj: can refer to |
| 743 | # anything. Each object also gets its own targeted cross-reference role. |
| 744 | object_types: Dict[str, ObjType] = { |
| 745 | "module": ObjType(_("module"), "mod", "any"), |
| 746 | "command": ObjType(_("command"), "cmd", "any"), |
| 747 | "event": ObjType(_("event"), "event", "any"), |
| 748 | "enum": ObjType(_("enum"), "enum", "type", "any"), |
| 749 | "object": ObjType(_("object"), "obj", "type", "any"), |
| 750 | "alternate": ObjType(_("alternate"), "alt", "type", "any"), |
| 751 | } |
| 752 | |
| 753 | # Each of these provides a rST directive, |
| 754 | # e.g. .. qapi:module:: block-core |
| 755 | directives = { |
| 756 | "namespace": QAPINamespace, |
| 757 | "module": QAPIModule, |
| 758 | "command": QAPICommand, |
| 759 | "event": QAPIEvent, |
| 760 | "enum": QAPIEnum, |
| 761 | "object": QAPIJSONObject, |
| 762 | "alternate": QAPIAlternate, |
| 763 | } |
| 764 | |
| 765 | # These are all cross-reference roles; e.g. |
| 766 | # :qapi:cmd:`query-block`. The keys correlate to the names used in |
| 767 | # the object_types table values above. |
| 768 | roles = { |
| 769 | "mod": QAPIXRefRole(), |
| 770 | "cmd": QAPIXRefRole(), |
| 771 | "event": QAPIXRefRole(), |
| 772 | "enum": QAPIXRefRole(), |
| 773 | "obj": QAPIXRefRole(), # specifically structs and unions. |
| 774 | "alt": QAPIXRefRole(), |
| 775 | # reference any data type (excludes modules, commands, events) |
| 776 | "type": QAPIXRefRole(), |
| 777 | "any": QAPIXRefRole(), # reference *any* type of QAPI object. |
| 778 | } |
| 779 | |
| 780 | # Moved into the data property at runtime; |
| 781 | # this is the internal index of reference-able objects. |
| 782 | initial_data: Dict[str, Dict[str, Tuple[Any]]] = { |
| 783 | "objects": {}, # fullname -> ObjectEntry |
| 784 | } |
| 785 | |
| 786 | # Index pages to generate; each entry is an Index class. |
| 787 | indices = [ |
| 788 | QAPIIndex, |
| 789 | ] |
| 790 | |
| 791 | @property |
| 792 | def objects(self) -> Dict[str, ObjectEntry]: |
| 793 | ret = self.data.setdefault("objects", {}) |
| 794 | return ret # type: ignore[no-any-return] |
| 795 | |
| 796 | def setup(self) -> None: |
| 797 | namespaces = set(self.env.app.config.qapi_namespaces) |
| 798 | for namespace in namespaces: |
| 799 | new_index: Type[QAPIIndex] = types.new_class( |
| 800 | f"{namespace}Index", bases=(QAPIIndex,) |
| 801 | ) |
| 802 | new_index.name = f"{namespace.lower()}-index" |
| 803 | new_index.localname = _(f"{namespace} Index") |
| 804 | new_index.shortname = _(f"{namespace} Index") |
| 805 | new_index.namespace = namespace |
| 806 | |
| 807 | self.indices.append(new_index) |
| 808 | |
| 809 | super().setup() |
| 810 | |
| 811 | def note_object( |
| 812 | self, |
| 813 | name: str, |
| 814 | objtype: str, |
| 815 | node_id: str, |
| 816 | aliased: bool = False, |
| 817 | location: Any = None, |
| 818 | ) -> None: |
| 819 | """Note a QAPI object for cross reference.""" |
| 820 | if name in self.objects: |
| 821 | other = self.objects[name] |
| 822 | if other.aliased and aliased is False: |
| 823 | # The original definition found. Override it! |
| 824 | pass |
| 825 | elif other.aliased is False and aliased: |
| 826 | # The original definition is already registered. |
| 827 | return |
| 828 | else: |
| 829 | # duplicated |
| 830 | logger.warning( |
| 831 | __( |
| 832 | "duplicate object description of %s, " |
| 833 | "other instance in %s, use :no-index: for one of them" |
| 834 | ), |
| 835 | name, |
| 836 | other.docname, |
| 837 | location=location, |
| 838 | ) |
| 839 | self.objects[name] = ObjectEntry( |
| 840 | self.env.docname, node_id, objtype, aliased |
| 841 | ) |
| 842 | |
| 843 | def clear_doc(self, docname: str) -> None: |
| 844 | for fullname, obj in list(self.objects.items()): |
| 845 | if obj.docname == docname: |
| 846 | del self.objects[fullname] |
| 847 | |
| 848 | def merge_domaindata( |
| 849 | self, docnames: AbstractSet[str], otherdata: Dict[str, Any] |
| 850 | ) -> None: |
| 851 | for fullname, obj in otherdata["objects"].items(): |
| 852 | if obj.docname in docnames: |
| 853 | # Sphinx's own python domain doesn't appear to bother to |
| 854 | # check for collisions. Assert they don't happen and |
| 855 | # we'll fix it if/when the case arises. |
| 856 | assert fullname not in self.objects, ( |
| 857 | "bug - collision on merge?" |
| 858 | f" {fullname=} {obj=} {self.objects[fullname]=}" |
| 859 | ) |
| 860 | self.objects[fullname] = obj |
| 861 | |
| 862 | def find_obj( |
| 863 | self, namespace: str, modname: str, name: str, typ: Optional[str] |
| 864 | ) -> List[Tuple[str, ObjectEntry]]: |
| 865 | """ |
| 866 | Find a QAPI object for "name", maybe using contextual information. |
| 867 | |
| 868 | Returns a list of (name, object entry) tuples. |
| 869 | |
| 870 | :param namespace: The current namespace context (if any!) under |
| 871 | which we are searching. |
| 872 | :param modname: The current module context (if any!) under |
| 873 | which we are searching. |
| 874 | :param name: The name of the x-ref to resolve; may or may not |
| 875 | include leading context. |
| 876 | :param type: The role name of the x-ref we're resolving, if |
| 877 | provided. This is absent for "any" role lookups. |
| 878 | """ |
| 879 | if not name: |
| 880 | return [] |
| 881 | |
| 882 | # ## |
| 883 | # what to search for |
| 884 | # ## |
| 885 | |
| 886 | parts = list(QAPIDescription.split_fqn(name)) |
| 887 | explicit = tuple(bool(x) for x in parts) |
| 888 | |
| 889 | # Fill in the blanks where possible: |
| 890 | if namespace and not parts[0]: |
| 891 | parts[0] = namespace |
| 892 | if modname and not parts[1]: |
| 893 | parts[1] = modname |
| 894 | |
| 895 | implicit_fqn = "" |
| 896 | if all(parts): |
| 897 | implicit_fqn = f"{parts[0]}:{parts[1]}.{parts[2]}" |
| 898 | |
| 899 | if typ is None: |
| 900 | # :any: lookup, search everything: |
| 901 | objtypes: List[str] = list(self.object_types) |
| 902 | else: |
| 903 | # type is specified and will be a role (e.g. obj, mod, cmd) |
| 904 | # convert this to eligible object types (e.g. command, module) |
| 905 | # using the QAPIDomain.object_types table. |
| 906 | objtypes = self.objtypes_for_role(typ, []) |
| 907 | |
| 908 | # ## |
| 909 | # search! |
| 910 | # ## |
| 911 | |
| 912 | def _search(needle: str) -> List[str]: |
| 913 | if ( |
| 914 | needle |
| 915 | and needle in self.objects |
| 916 | and self.objects[needle].objtype in objtypes |
| 917 | ): |
| 918 | return [needle] |
| 919 | return [] |
| 920 | |
| 921 | if found := _search(name): |
| 922 | # Exact match! |
| 923 | pass |
| 924 | elif found := _search(implicit_fqn): |
| 925 | # Exact match using contextual information to fill in the gaps. |
| 926 | pass |
| 927 | else: |
| 928 | # No exact hits, perform applicable fuzzy searches. |
| 929 | searches = [] |
| 930 | |
| 931 | esc = tuple(re.escape(s) for s in parts) |
| 932 | |
| 933 | # Try searching for ns:*.name or ns:name |
| 934 | if explicit[0] and not explicit[1]: |
| 935 | searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$") |
| 936 | # Try searching for *:module.name or module.name |
| 937 | if explicit[1] and not explicit[0]: |
| 938 | searches.append(f"(^|:){esc[1]}\\.{esc[2]}$") |
| 939 | # Try searching for context-ns:*.name or context-ns:name |
| 940 | if parts[0] and not (explicit[0] or explicit[1]): |
| 941 | searches.append(f"^{esc[0]}:([^\\.]+\\.)?{esc[2]}$") |
| 942 | # Try searching for *:context-mod.name or context-mod.name |
| 943 | if parts[1] and not (explicit[0] or explicit[1]): |
| 944 | searches.append(f"(^|:){esc[1]}\\.{esc[2]}$") |
| 945 | # Try searching for *:name, *.name, or name |
| 946 | if not (explicit[0] or explicit[1]): |
| 947 | searches.append(f"(^|:|\\.){esc[2]}$") |
| 948 | |
| 949 | for search in searches: |
| 950 | if found := [ |
| 951 | oname |
| 952 | for oname in self.objects |
| 953 | if re.search(search, oname) |
| 954 | and self.objects[oname].objtype in objtypes |
| 955 | ]: |
| 956 | break |
| 957 | |
| 958 | matches = [(oname, self.objects[oname]) for oname in found] |
| 959 | if len(matches) > 1: |
| 960 | matches = [m for m in matches if not m[1].aliased] |
| 961 | return matches |
| 962 | |
| 963 | def resolve_xref( |
| 964 | self, |
| 965 | env: BuildEnvironment, |
| 966 | fromdocname: str, |
| 967 | builder: Builder, |
| 968 | typ: str, |
| 969 | target: str, |
| 970 | node: pending_xref, |
| 971 | contnode: Element, |
| 972 | ) -> nodes.reference | None: |
| 973 | namespace = node.get("qapi:namespace") |
| 974 | modname = node.get("qapi:module") |
| 975 | matches = self.find_obj(namespace, modname, target, typ) |
| 976 | |
| 977 | if not matches: |
| 978 | # Normally, we could pass warn_dangling=True to QAPIXRefRole(), |
| 979 | # but that will trigger on references to these built-in types, |
| 980 | # which we'd like to ignore instead. |
| 981 | |
| 982 | # Take care of that warning here instead, so long as the |
| 983 | # reference isn't to one of our built-in core types. |
| 984 | if target not in ( |
| 985 | "string", |
| 986 | "number", |
| 987 | "int", |
| 988 | "boolean", |
| 989 | "null", |
| 990 | "value", |
| 991 | "q_empty", |
| 992 | ): |
| 993 | logger.warning( |
| 994 | __("qapi:%s reference target not found: %r"), |
| 995 | typ, |
| 996 | target, |
| 997 | type="ref", |
| 998 | subtype="qapi", |
| 999 | location=node, |
| 1000 | ) |
| 1001 | return None |
| 1002 | |
| 1003 | if len(matches) > 1: |
| 1004 | logger.warning( |
| 1005 | __("more than one target found for cross-reference %r: %s"), |
| 1006 | target, |
| 1007 | ", ".join(match[0] for match in matches), |
| 1008 | type="ref", |
| 1009 | subtype="qapi", |
| 1010 | location=node, |
| 1011 | ) |
| 1012 | |
| 1013 | name, obj = matches[0] |
| 1014 | return make_refnode( |
| 1015 | builder, fromdocname, obj.docname, obj.node_id, contnode, name |
| 1016 | ) |
| 1017 | |
| 1018 | def resolve_any_xref( |
| 1019 | self, |
| 1020 | env: BuildEnvironment, |
| 1021 | fromdocname: str, |
| 1022 | builder: Builder, |
| 1023 | target: str, |
| 1024 | node: pending_xref, |
| 1025 | contnode: Element, |
| 1026 | ) -> List[Tuple[str, nodes.reference]]: |
| 1027 | results: List[Tuple[str, nodes.reference]] = [] |
| 1028 | matches = self.find_obj( |
| 1029 | node.get("qapi:namespace"), node.get("qapi:module"), target, None |
| 1030 | ) |
| 1031 | for name, obj in matches: |
| 1032 | rolename = self.role_for_objtype(obj.objtype) |
| 1033 | assert rolename is not None |
| 1034 | role = f"qapi:{rolename}" |
| 1035 | refnode = make_refnode( |
| 1036 | builder, fromdocname, obj.docname, obj.node_id, contnode, name |
| 1037 | ) |
| 1038 | results.append((role, refnode)) |
| 1039 | return results |
| 1040 | |
| 1041 | |
| 1042 | def setup(app: Sphinx) -> Dict[str, Any]: |
| 1043 | app.setup_extension("sphinx.directives") |
| 1044 | app.add_config_value( |
| 1045 | "qapi_allowed_fields", |
| 1046 | set(), |
| 1047 | "env", # Setting impacts parsing phase |
| 1048 | types=set, |
| 1049 | ) |
| 1050 | app.add_config_value( |
| 1051 | "qapi_namespaces", |
| 1052 | set(), |
| 1053 | "env", |
| 1054 | types=set, |
| 1055 | ) |
| 1056 | app.add_domain(QAPIDomain) |
| 1057 | |
| 1058 | return { |
| 1059 | "version": "1.0", |
| 1060 | "env_version": 1, |
| 1061 | "parallel_read_safe": True, |
| 1062 | "parallel_write_safe": True, |
| 1063 | } |