123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733 |
- # coding=utf-8
- #
- # QEMU qapidoc QAPI file parsing extension
- #
- # Copyright (c) 2024-2025 Red Hat
- # Copyright (c) 2020 Linaro
- #
- # This work is licensed under the terms of the GNU GPLv2 or later.
- # See the COPYING file in the top-level directory.
- """
- qapidoc is a Sphinx extension that implements the qapi-doc directive
- The purpose of this extension is to read the documentation comments
- in QAPI schema files, and insert them all into the current document.
- It implements one new rST directive, "qapi-doc::".
- Each qapi-doc:: directive takes one argument, which is the
- pathname of the schema file to process, relative to the source tree.
- The docs/conf.py file must set the qapidoc_srctree config value to
- the root of the QEMU source tree.
- The Sphinx documentation on writing extensions is at:
- https://www.sphinx-doc.org/en/master/development/index.html
- """
- from __future__ import annotations
- __version__ = "2.0"
- from contextlib import contextmanager
- import os
- from pathlib import Path
- import re
- import sys
- from typing import TYPE_CHECKING
- from docutils import nodes
- from docutils.parsers.rst import directives
- from docutils.statemachine import StringList
- from qapi.error import QAPIError
- from qapi.parser import QAPIDoc
- from qapi.schema import (
- QAPISchema,
- QAPISchemaArrayType,
- QAPISchemaCommand,
- QAPISchemaDefinition,
- QAPISchemaEnumMember,
- QAPISchemaEvent,
- QAPISchemaFeature,
- QAPISchemaMember,
- QAPISchemaObjectType,
- QAPISchemaObjectTypeMember,
- QAPISchemaType,
- QAPISchemaVisitor,
- )
- from qapi.source import QAPISourceInfo
- from qapidoc_legacy import QAPISchemaGenRSTVisitor # type: ignore
- from sphinx import addnodes
- from sphinx.directives.code import CodeBlock
- from sphinx.errors import ExtensionError
- from sphinx.util import logging
- from sphinx.util.docutils import SphinxDirective, switch_source_input
- from sphinx.util.nodes import nested_parse_with_titles
- if TYPE_CHECKING:
- from typing import (
- Any,
- Generator,
- List,
- Optional,
- Sequence,
- Union,
- )
- from sphinx.application import Sphinx
- from sphinx.util.typing import ExtensionMetadata
- logger = logging.getLogger(__name__)
- class Transmogrifier:
- # pylint: disable=too-many-public-methods
- # Field names used for different entity types:
- field_types = {
- "enum": "value",
- "struct": "memb",
- "union": "memb",
- "event": "memb",
- "command": "arg",
- "alternate": "alt",
- }
- def __init__(self) -> None:
- self._curr_ent: Optional[QAPISchemaDefinition] = None
- self._result = StringList()
- self.indent = 0
- @property
- def result(self) -> StringList:
- return self._result
- @property
- def entity(self) -> QAPISchemaDefinition:
- assert self._curr_ent is not None
- return self._curr_ent
- @property
- def member_field_type(self) -> str:
- return self.field_types[self.entity.meta]
- # General-purpose rST generation functions
- def get_indent(self) -> str:
- return " " * self.indent
- @contextmanager
- def indented(self) -> Generator[None]:
- self.indent += 1
- try:
- yield
- finally:
- self.indent -= 1
- def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
- """Append one line of generated reST to the output."""
- # NB: Sphinx uses zero-indexed lines; subtract one.
- lineno = tuple((n - 1 for n in lineno))
- if line.strip():
- # not a blank line
- self._result.append(
- self.get_indent() + line.rstrip("\n"), source, *lineno
- )
- else:
- self._result.append("", source, *lineno)
- def add_line(self, content: str, info: QAPISourceInfo) -> None:
- # NB: We *require* an info object; this works out OK because we
- # don't document built-in objects that don't have
- # one. Everything else should.
- self.add_line_raw(content, info.fname, info.line)
- def add_lines(
- self,
- content: str,
- info: QAPISourceInfo,
- ) -> None:
- lines = content.splitlines(True)
- for i, line in enumerate(lines):
- self.add_line_raw(line, info.fname, info.line + i)
- def ensure_blank_line(self) -> None:
- # Empty document -- no blank line required.
- if not self._result:
- return
- # Last line isn't blank, add one.
- if self._result[-1].strip(): # pylint: disable=no-member
- fname, line = self._result.info(-1)
- assert isinstance(line, int)
- # New blank line is credited to one-after the current last line.
- # +2: correct for zero/one index, then increment by one.
- self.add_line_raw("", fname, line + 2)
- def add_field(
- self,
- kind: str,
- name: str,
- body: str,
- info: QAPISourceInfo,
- typ: Optional[str] = None,
- ) -> None:
- if typ:
- text = f":{kind} {typ} {name}: {body}"
- else:
- text = f":{kind} {name}: {body}"
- self.add_lines(text, info)
- def format_type(
- self, ent: Union[QAPISchemaDefinition | QAPISchemaMember]
- ) -> Optional[str]:
- if isinstance(ent, (QAPISchemaEnumMember, QAPISchemaFeature)):
- return None
- qapi_type = ent
- optional = False
- if isinstance(ent, QAPISchemaObjectTypeMember):
- qapi_type = ent.type
- optional = ent.optional
- if isinstance(qapi_type, QAPISchemaArrayType):
- ret = f"[{qapi_type.element_type.doc_type()}]"
- else:
- assert isinstance(qapi_type, QAPISchemaType)
- tmp = qapi_type.doc_type()
- assert tmp
- ret = tmp
- if optional:
- ret += "?"
- return ret
- def generate_field(
- self,
- kind: str,
- member: QAPISchemaMember,
- body: str,
- info: QAPISourceInfo,
- ) -> None:
- typ = self.format_type(member)
- self.add_field(kind, member.name, body, info, typ)
- # Transmogrification helpers
- def visit_paragraph(self, section: QAPIDoc.Section) -> None:
- # Squelch empty paragraphs.
- if not section.text:
- return
- self.ensure_blank_line()
- self.add_lines(section.text, section.info)
- self.ensure_blank_line()
- def visit_member(self, section: QAPIDoc.ArgSection) -> None:
- # FIXME: ifcond for members
- # TODO: features for members (documented at entity-level,
- # but sometimes defined per-member. Should we add such
- # information to member descriptions when we can?)
- assert section.member
- self.generate_field(
- self.member_field_type,
- section.member,
- # TODO drop fallbacks when undocumented members are outlawed
- section.text if section.text else "Not documented",
- section.info,
- )
- def visit_feature(self, section: QAPIDoc.ArgSection) -> None:
- # FIXME - ifcond for features is not handled at all yet!
- # Proposal: decorate the right-hand column with some graphical
- # element to indicate conditional availability?
- assert section.text # Guaranteed by parser.py
- assert section.member
- self.generate_field("feat", section.member, section.text, section.info)
- def visit_returns(self, section: QAPIDoc.Section) -> None:
- assert isinstance(self.entity, QAPISchemaCommand)
- rtype = self.entity.ret_type
- # q_empty can produce None, but we won't be documenting anything
- # without an explicit return statement in the doc block, and we
- # should not have any such explicit statements when there is no
- # return value.
- assert rtype
- typ = self.format_type(rtype)
- assert typ
- assert section.text
- self.add_field("return", typ, section.text, section.info)
- def visit_errors(self, section: QAPIDoc.Section) -> None:
- # FIXME: the formatting for errors may be inconsistent and may
- # or may not require different newline placement to ensure
- # proper rendering as a nested list.
- self.add_lines(f":error:\n{section.text}", section.info)
- def preamble(self, ent: QAPISchemaDefinition) -> None:
- """
- Generate option lines for QAPI entity directives.
- """
- if ent.doc and ent.doc.since:
- assert ent.doc.since.kind == QAPIDoc.Kind.SINCE
- # Generated from the entity's docblock; info location is exact.
- self.add_line(f":since: {ent.doc.since.text}", ent.doc.since.info)
- if ent.ifcond.is_present():
- doc = ent.ifcond.docgen()
- assert ent.info
- # Generated from entity definition; info location is approximate.
- self.add_line(f":ifcond: {doc}", ent.info)
- # Hoist special features such as :deprecated: and :unstable:
- # into the options block for the entity. If, in the future, new
- # special features are added, qapi-domain will chirp about
- # unrecognized options and fail until they are handled in
- # qapi-domain.
- for feat in ent.features:
- if feat.is_special():
- # FIXME: handle ifcond if present. How to display that
- # information is TBD.
- # Generated from entity def; info location is approximate.
- assert feat.info
- self.add_line(f":{feat.name}:", feat.info)
- self.ensure_blank_line()
- def _insert_member_pointer(self, ent: QAPISchemaDefinition) -> None:
- def _get_target(
- ent: QAPISchemaDefinition,
- ) -> Optional[QAPISchemaDefinition]:
- if isinstance(ent, (QAPISchemaCommand, QAPISchemaEvent)):
- return ent.arg_type
- if isinstance(ent, QAPISchemaObjectType):
- return ent.base
- return None
- target = _get_target(ent)
- if target is not None and not target.is_implicit():
- assert ent.info
- self.add_field(
- self.member_field_type,
- "q_dummy",
- f"The members of :qapi:type:`{target.name}`.",
- ent.info,
- "q_dummy",
- )
- if isinstance(ent, QAPISchemaObjectType) and ent.branches is not None:
- for variant in ent.branches.variants:
- if variant.type.name == "q_empty":
- continue
- assert ent.info
- self.add_field(
- self.member_field_type,
- "q_dummy",
- f" When ``{ent.branches.tag_member.name}`` is "
- f"``{variant.name}``: "
- f"The members of :qapi:type:`{variant.type.name}`.",
- ent.info,
- "q_dummy",
- )
- def visit_sections(self, ent: QAPISchemaDefinition) -> None:
- sections = ent.doc.all_sections if ent.doc else []
- # Determine the index location at which we should generate
- # documentation for "The members of ..." pointers. This should
- # go at the end of the members section(s) if any. Note that
- # index 0 is assumed to be a plain intro section, even if it is
- # empty; and that a members section if present will always
- # immediately follow the opening PLAIN section.
- gen_index = 1
- if len(sections) > 1:
- while sections[gen_index].kind == QAPIDoc.Kind.MEMBER:
- gen_index += 1
- if gen_index >= len(sections):
- break
- # Add sections in source order:
- for i, section in enumerate(sections):
- # @var is translated to ``var``:
- section.text = re.sub(r"@([\w-]+)", r"``\1``", section.text)
- if section.kind == QAPIDoc.Kind.PLAIN:
- self.visit_paragraph(section)
- elif section.kind == QAPIDoc.Kind.MEMBER:
- assert isinstance(section, QAPIDoc.ArgSection)
- self.visit_member(section)
- elif section.kind == QAPIDoc.Kind.FEATURE:
- assert isinstance(section, QAPIDoc.ArgSection)
- self.visit_feature(section)
- elif section.kind in (QAPIDoc.Kind.SINCE, QAPIDoc.Kind.TODO):
- # Since is handled in preamble, TODO is skipped intentionally.
- pass
- elif section.kind == QAPIDoc.Kind.RETURNS:
- self.visit_returns(section)
- elif section.kind == QAPIDoc.Kind.ERRORS:
- self.visit_errors(section)
- else:
- assert False
- # Generate "The members of ..." entries if necessary:
- if i == gen_index - 1:
- self._insert_member_pointer(ent)
- self.ensure_blank_line()
- # Transmogrification core methods
- def visit_module(self, path: str) -> None:
- name = Path(path).stem
- # module directives are credited to the first line of a module file.
- self.add_line_raw(f".. qapi:module:: {name}", path, 1)
- self.ensure_blank_line()
- def visit_freeform(self, doc: QAPIDoc) -> None:
- # TODO: Once the old qapidoc transformer is deprecated, freeform
- # sections can be updated to pure rST, and this transformed removed.
- #
- # For now, translate our micro-format into rST. Code adapted
- # from Peter Maydell's freeform().
- assert len(doc.all_sections) == 1, doc.all_sections
- body = doc.all_sections[0]
- text = body.text
- info = doc.info
- if re.match(r"=+ ", text):
- # Section/subsection heading (if present, will always be the
- # first line of the block)
- (heading, _, text) = text.partition("\n")
- (leader, _, heading) = heading.partition(" ")
- # Implicit +1 for heading in the containing .rst doc
- level = len(leader) + 1
- # https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#sections
- markers = ' #*=_^"'
- overline = level <= 2
- marker = markers[level]
- self.ensure_blank_line()
- # This credits all 2 or 3 lines to the single source line.
- if overline:
- self.add_line(marker * len(heading), info)
- self.add_line(heading, info)
- self.add_line(marker * len(heading), info)
- self.ensure_blank_line()
- # Eat blank line(s) and advance info
- trimmed = text.lstrip("\n")
- text = trimmed
- info = info.next_line(len(text) - len(trimmed) + 1)
- self.add_lines(text, info)
- self.ensure_blank_line()
- def visit_entity(self, ent: QAPISchemaDefinition) -> None:
- assert ent.info
- try:
- self._curr_ent = ent
- # Squish structs and unions together into an "object" directive.
- meta = ent.meta
- if meta in ("struct", "union"):
- meta = "object"
- # This line gets credited to the start of the /definition/.
- self.add_line(f".. qapi:{meta}:: {ent.name}", ent.info)
- with self.indented():
- self.preamble(ent)
- self.visit_sections(ent)
- finally:
- self._curr_ent = None
- def set_namespace(self, namespace: str, source: str, lineno: int) -> None:
- self.add_line_raw(
- f".. qapi:namespace:: {namespace}", source, lineno + 1
- )
- self.ensure_blank_line()
- class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
- """A QAPI schema visitor which adds Sphinx dependencies each module
- This class calls the Sphinx note_dependency() function to tell Sphinx
- that the generated documentation output depends on the input
- schema file associated with each module in the QAPI input.
- """
- def __init__(self, env: Any, qapidir: str) -> None:
- self._env = env
- self._qapidir = qapidir
- def visit_module(self, name: str) -> None:
- if name != "./builtin":
- qapifile = self._qapidir + "/" + name
- self._env.note_dependency(os.path.abspath(qapifile))
- super().visit_module(name)
- class NestedDirective(SphinxDirective):
- def run(self) -> Sequence[nodes.Node]:
- raise NotImplementedError
- def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
- """
- Parse rST source lines and add them to the specified node
- Take the list of rST source lines rstlist, parse them as
- rST, and add the resulting docutils nodes as children of node.
- The nodes are parsed in a way that allows them to include
- subheadings (titles) without confusing the rendering of
- anything else.
- """
- with switch_source_input(self.state, rstlist):
- nested_parse_with_titles(self.state, rstlist, node)
- class QAPIDocDirective(NestedDirective):
- """Extract documentation from the specified QAPI .json file"""
- required_argument = 1
- optional_arguments = 1
- option_spec = {
- "qapifile": directives.unchanged_required,
- "namespace": directives.unchanged,
- "transmogrify": directives.flag,
- }
- has_content = False
- def new_serialno(self) -> str:
- """Return a unique new ID string suitable for use as a node's ID"""
- env = self.state.document.settings.env
- return "qapidoc-%d" % env.new_serialno("qapidoc")
- def transmogrify(self, schema: QAPISchema) -> nodes.Element:
- logger.info("Transmogrifying QAPI to rST ...")
- vis = Transmogrifier()
- modules = set()
- if "namespace" in self.options:
- vis.set_namespace(
- self.options["namespace"], *self.get_source_info()
- )
- for doc in schema.docs:
- module_source = doc.info.fname
- if module_source not in modules:
- vis.visit_module(module_source)
- modules.add(module_source)
- if doc.symbol:
- ent = schema.lookup_entity(doc.symbol)
- assert isinstance(ent, QAPISchemaDefinition)
- vis.visit_entity(ent)
- else:
- vis.visit_freeform(doc)
- logger.info("Transmogrification complete.")
- contentnode = nodes.section()
- content = vis.result
- titles_allowed = True
- logger.info("Transmogrifier running nested parse ...")
- with switch_source_input(self.state, content):
- if titles_allowed:
- node: nodes.Element = nodes.section()
- node.document = self.state.document
- nested_parse_with_titles(self.state, content, contentnode)
- else:
- node = nodes.paragraph()
- node.document = self.state.document
- self.state.nested_parse(content, 0, contentnode)
- logger.info("Transmogrifier's nested parse completed.")
- if self.env.app.verbosity >= 2 or os.environ.get("DEBUG"):
- argname = "_".join(Path(self.arguments[0]).parts)
- name = Path(argname).stem + ".ir"
- self.write_intermediate(content, name)
- sys.stdout.flush()
- return contentnode
- def write_intermediate(self, content: StringList, filename: str) -> None:
- logger.info(
- "writing intermediate rST for '%s' to '%s'",
- self.arguments[0],
- filename,
- )
- srctree = Path(self.env.app.config.qapidoc_srctree).resolve()
- outlines = []
- lcol_width = 0
- for i, line in enumerate(content):
- src, lineno = content.info(i)
- srcpath = Path(src).resolve()
- srcpath = srcpath.relative_to(srctree)
- lcol = f"{srcpath}:{lineno:04d}"
- lcol_width = max(lcol_width, len(lcol))
- outlines.append((lcol, line))
- with open(filename, "w", encoding="UTF-8") as outfile:
- for lcol, rcol in outlines:
- outfile.write(lcol.rjust(lcol_width))
- outfile.write(" |")
- if rcol:
- outfile.write(f" {rcol}")
- outfile.write("\n")
- def legacy(self, schema: QAPISchema) -> nodes.Element:
- vis = QAPISchemaGenRSTVisitor(self)
- vis.visit_begin(schema)
- for doc in schema.docs:
- if doc.symbol:
- vis.symbol(doc, schema.lookup_entity(doc.symbol))
- else:
- vis.freeform(doc)
- return vis.get_document_node() # type: ignore
- def run(self) -> Sequence[nodes.Node]:
- env = self.state.document.settings.env
- qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
- qapidir = os.path.dirname(qapifile)
- transmogrify = "transmogrify" in self.options
- try:
- schema = QAPISchema(qapifile)
- # First tell Sphinx about all the schema files that the
- # output documentation depends on (including 'qapifile' itself)
- schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
- except QAPIError as err:
- # Launder QAPI parse errors into Sphinx extension errors
- # so they are displayed nicely to the user
- raise ExtensionError(str(err)) from err
- if transmogrify:
- contentnode = self.transmogrify(schema)
- else:
- contentnode = self.legacy(schema)
- return contentnode.children
- class QMPExample(CodeBlock, NestedDirective):
- """
- Custom admonition for QMP code examples.
- When the :annotated: option is present, the body of this directive
- is parsed as normal rST, but with any '::' code blocks set to use
- the QMP lexer. Code blocks must be explicitly written by the user,
- but this allows for intermingling explanatory paragraphs with
- arbitrary rST syntax and code blocks for more involved examples.
- When :annotated: is absent, the directive body is treated as a
- simple standalone QMP code block literal.
- """
- required_argument = 0
- optional_arguments = 0
- has_content = True
- option_spec = {
- "annotated": directives.flag,
- "title": directives.unchanged,
- }
- def _highlightlang(self) -> addnodes.highlightlang:
- """Return the current highlightlang setting for the document"""
- node = None
- doc = self.state.document
- if hasattr(doc, "findall"):
- # docutils >= 0.18.1
- for node in doc.findall(addnodes.highlightlang):
- pass
- else:
- for elem in doc.traverse():
- if isinstance(elem, addnodes.highlightlang):
- node = elem
- if node:
- return node
- # No explicit directive found, use defaults
- node = addnodes.highlightlang(
- lang=self.env.config.highlight_language,
- force=False,
- # Yes, Sphinx uses this value to effectively disable line
- # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
- linenothreshold=sys.maxsize,
- )
- return node
- def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
- title = "Example:"
- if "title" in self.options:
- title = f"{title} {self.options['title']}"
- admon = nodes.admonition(
- "",
- nodes.title("", title),
- *content,
- classes=["admonition", "admonition-example"],
- )
- return [admon]
- def run_annotated(self) -> List[nodes.Node]:
- lang_node = self._highlightlang()
- content_node: nodes.Element = nodes.section()
- # Configure QMP highlighting for "::" blocks, if needed
- if lang_node["lang"] != "QMP":
- content_node += addnodes.highlightlang(
- lang="QMP",
- force=False, # "True" ignores lexing errors
- linenothreshold=lang_node["linenothreshold"],
- )
- self.do_parse(self.content, content_node)
- # Restore prior language highlighting, if needed
- if lang_node["lang"] != "QMP":
- content_node += addnodes.highlightlang(**lang_node.attributes)
- return content_node.children
- def run(self) -> List[nodes.Node]:
- annotated = "annotated" in self.options
- if annotated:
- content_nodes = self.run_annotated()
- else:
- self.arguments = ["QMP"]
- content_nodes = super().run()
- return self.admonition_wrap(*content_nodes)
- def setup(app: Sphinx) -> ExtensionMetadata:
- """Register qapi-doc directive with Sphinx"""
- app.setup_extension("qapi_domain")
- app.add_config_value("qapidoc_srctree", None, "env")
- app.add_directive("qapi-doc", QAPIDocDirective)
- app.add_directive("qmp-example", QMPExample)
- return {
- "version": __version__,
- "parallel_read_safe": True,
- "parallel_write_safe": True,
- }
|