xref: /openbmc/qemu/docs/sphinx/qapidoc.py (revision 6c10778826a873b9012d95e63298a6f879debcaa)
1# coding=utf-8
2#
3# QEMU qapidoc QAPI file parsing extension
4#
5# Copyright (c) 2024-2025 Red Hat
6# Copyright (c) 2020 Linaro
7#
8# This work is licensed under the terms of the GNU GPLv2 or later.
9# See the COPYING file in the top-level directory.
10
11"""
12qapidoc is a Sphinx extension that implements the qapi-doc directive
13
14The purpose of this extension is to read the documentation comments
15in QAPI schema files, and insert them all into the current document.
16
17It implements one new rST directive, "qapi-doc::".
18Each qapi-doc:: directive takes one argument, which is the
19pathname of the schema file to process, relative to the source tree.
20
21The docs/conf.py file must set the qapidoc_srctree config value to
22the root of the QEMU source tree.
23
24The Sphinx documentation on writing extensions is at:
25https://www.sphinx-doc.org/en/master/development/index.html
26"""
27
28from __future__ import annotations
29
30
31__version__ = "2.0"
32
33from contextlib import contextmanager
34import os
35from pathlib import Path
36import re
37import sys
38from typing import TYPE_CHECKING
39
40from docutils import nodes
41from docutils.parsers.rst import directives
42from docutils.statemachine import StringList
43from qapi.error import QAPIError
44from qapi.parser import QAPIDoc
45from qapi.schema import (
46    QAPISchema,
47    QAPISchemaArrayType,
48    QAPISchemaCommand,
49    QAPISchemaDefinition,
50    QAPISchemaEnumMember,
51    QAPISchemaEvent,
52    QAPISchemaFeature,
53    QAPISchemaMember,
54    QAPISchemaObjectType,
55    QAPISchemaObjectTypeMember,
56    QAPISchemaType,
57    QAPISchemaVisitor,
58)
59from qapi.source import QAPISourceInfo
60from sphinx import addnodes
61from sphinx.directives.code import CodeBlock
62from sphinx.errors import ExtensionError
63from sphinx.util import logging
64from sphinx.util.docutils import SphinxDirective, switch_source_input
65from sphinx.util.nodes import nested_parse_with_titles
66
67
68if TYPE_CHECKING:
69    from typing import (
70        Any,
71        Generator,
72        List,
73        Optional,
74        Sequence,
75        Union,
76    )
77
78    from sphinx.application import Sphinx
79    from sphinx.util.typing import ExtensionMetadata
80
81
82logger = logging.getLogger(__name__)
83
84
85class Transmogrifier:
86    # pylint: disable=too-many-public-methods
87
88    # Field names used for different entity types:
89    field_types = {
90        "enum": "value",
91        "struct": "memb",
92        "union": "memb",
93        "event": "memb",
94        "command": "arg",
95        "alternate": "alt",
96    }
97
98    def __init__(self) -> None:
99        self._curr_ent: Optional[QAPISchemaDefinition] = None
100        self._result = StringList()
101        self.indent = 0
102
103    @property
104    def result(self) -> StringList:
105        return self._result
106
107    @property
108    def entity(self) -> QAPISchemaDefinition:
109        assert self._curr_ent is not None
110        return self._curr_ent
111
112    @property
113    def member_field_type(self) -> str:
114        return self.field_types[self.entity.meta]
115
116    # General-purpose rST generation functions
117
118    def get_indent(self) -> str:
119        return "   " * self.indent
120
121    @contextmanager
122    def indented(self) -> Generator[None]:
123        self.indent += 1
124        try:
125            yield
126        finally:
127            self.indent -= 1
128
129    def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
130        """Append one line of generated reST to the output."""
131
132        # NB: Sphinx uses zero-indexed lines; subtract one.
133        lineno = tuple((n - 1 for n in lineno))
134
135        if line.strip():
136            # not a blank line
137            self._result.append(
138                self.get_indent() + line.rstrip("\n"), source, *lineno
139            )
140        else:
141            self._result.append("", source, *lineno)
142
143    def add_line(self, content: str, info: QAPISourceInfo) -> None:
144        # NB: We *require* an info object; this works out OK because we
145        # don't document built-in objects that don't have
146        # one. Everything else should.
147        self.add_line_raw(content, info.fname, info.line)
148
149    def add_lines(
150        self,
151        content: str,
152        info: QAPISourceInfo,
153    ) -> None:
154        lines = content.splitlines(True)
155        for i, line in enumerate(lines):
156            self.add_line_raw(line, info.fname, info.line + i)
157
158    def ensure_blank_line(self) -> None:
159        # Empty document -- no blank line required.
160        if not self._result:
161            return
162
163        # Last line isn't blank, add one.
164        if self._result[-1].strip():  # pylint: disable=no-member
165            fname, line = self._result.info(-1)
166            assert isinstance(line, int)
167            # New blank line is credited to one-after the current last line.
168            # +2: correct for zero/one index, then increment by one.
169            self.add_line_raw("", fname, line + 2)
170
171    def add_field(
172        self,
173        kind: str,
174        name: str,
175        body: str,
176        info: QAPISourceInfo,
177        typ: Optional[str] = None,
178    ) -> None:
179        if typ:
180            text = f":{kind} {typ} {name}: {body}"
181        else:
182            text = f":{kind} {name}: {body}"
183        self.add_lines(text, info)
184
185    def format_type(
186        self, ent: Union[QAPISchemaDefinition | QAPISchemaMember]
187    ) -> Optional[str]:
188        if isinstance(ent, (QAPISchemaEnumMember, QAPISchemaFeature)):
189            return None
190
191        qapi_type = ent
192        optional = False
193        if isinstance(ent, QAPISchemaObjectTypeMember):
194            qapi_type = ent.type
195            optional = ent.optional
196
197        if isinstance(qapi_type, QAPISchemaArrayType):
198            ret = f"[{qapi_type.element_type.doc_type()}]"
199        else:
200            assert isinstance(qapi_type, QAPISchemaType)
201            tmp = qapi_type.doc_type()
202            assert tmp
203            ret = tmp
204        if optional:
205            ret += "?"
206
207        return ret
208
209    def generate_field(
210        self,
211        kind: str,
212        member: QAPISchemaMember,
213        body: str,
214        info: QAPISourceInfo,
215    ) -> None:
216        typ = self.format_type(member)
217        self.add_field(kind, member.name, body, info, typ)
218
219    @staticmethod
220    def reformat_arobase(text: str) -> str:
221        """ reformats @var to ``var`` """
222        return re.sub(r"@([\w-]+)", r"``\1``", text)
223
224    # Transmogrification helpers
225
226    def visit_paragraph(self, section: QAPIDoc.Section) -> None:
227        # Squelch empty paragraphs.
228        if not section.text:
229            return
230
231        self.ensure_blank_line()
232        self.add_lines(section.text, section.info)
233        self.ensure_blank_line()
234
235    def visit_member(self, section: QAPIDoc.ArgSection) -> None:
236        # FIXME: ifcond for members
237        # TODO: features for members (documented at entity-level,
238        # but sometimes defined per-member. Should we add such
239        # information to member descriptions when we can?)
240        assert section.member
241        self.generate_field(
242            self.member_field_type,
243            section.member,
244            # TODO drop fallbacks when undocumented members are outlawed
245            section.text if section.text else "Not documented",
246            section.info,
247        )
248
249    def visit_feature(self, section: QAPIDoc.ArgSection) -> None:
250        # FIXME - ifcond for features is not handled at all yet!
251        # Proposal: decorate the right-hand column with some graphical
252        # element to indicate conditional availability?
253        assert section.text  # Guaranteed by parser.py
254        assert section.member
255
256        self.generate_field("feat", section.member, section.text, section.info)
257
258    def visit_returns(self, section: QAPIDoc.Section) -> None:
259        assert isinstance(self.entity, QAPISchemaCommand)
260        rtype = self.entity.ret_type
261        # q_empty can produce None, but we won't be documenting anything
262        # without an explicit return statement in the doc block, and we
263        # should not have any such explicit statements when there is no
264        # return value.
265        assert rtype
266
267        typ = self.format_type(rtype)
268        assert typ
269        assert section.text
270        self.add_field("return", typ, section.text, section.info)
271
272    def visit_errors(self, section: QAPIDoc.Section) -> None:
273        # If the section text does not start with a space, it means text
274        # began on the same line as the "Error:" string and we should
275        # not insert a newline in this case.
276        if section.text[0].isspace():
277            text = f":error:\n{section.text}"
278        else:
279            text = f":error: {section.text}"
280        self.add_lines(text, section.info)
281
282    def preamble(self, ent: QAPISchemaDefinition) -> None:
283        """
284        Generate option lines for QAPI entity directives.
285        """
286        if ent.doc and ent.doc.since:
287            assert ent.doc.since.kind == QAPIDoc.Kind.SINCE
288            # Generated from the entity's docblock; info location is exact.
289            self.add_line(f":since: {ent.doc.since.text}", ent.doc.since.info)
290
291        if ent.ifcond.is_present():
292            doc = ent.ifcond.docgen()
293            assert ent.info
294            # Generated from entity definition; info location is approximate.
295            self.add_line(f":ifcond: {doc}", ent.info)
296
297        # Hoist special features such as :deprecated: and :unstable:
298        # into the options block for the entity. If, in the future, new
299        # special features are added, qapi-domain will chirp about
300        # unrecognized options and fail until they are handled in
301        # qapi-domain.
302        for feat in ent.features:
303            if feat.is_special():
304                # FIXME: handle ifcond if present. How to display that
305                # information is TBD.
306                # Generated from entity def; info location is approximate.
307                assert feat.info
308                self.add_line(f":{feat.name}:", feat.info)
309
310        self.ensure_blank_line()
311
312    def _insert_member_pointer(self, ent: QAPISchemaDefinition) -> None:
313
314        def _get_target(
315            ent: QAPISchemaDefinition,
316        ) -> Optional[QAPISchemaDefinition]:
317            if isinstance(ent, (QAPISchemaCommand, QAPISchemaEvent)):
318                return ent.arg_type
319            if isinstance(ent, QAPISchemaObjectType):
320                return ent.base
321            return None
322
323        target = _get_target(ent)
324        if target is not None and not target.is_implicit():
325            assert ent.info
326            self.add_field(
327                self.member_field_type,
328                "q_dummy",
329                f"The members of :qapi:type:`{target.name}`.",
330                ent.info,
331                "q_dummy",
332            )
333
334        if isinstance(ent, QAPISchemaObjectType) and ent.branches is not None:
335            for variant in ent.branches.variants:
336                if variant.type.name == "q_empty":
337                    continue
338                assert ent.info
339                self.add_field(
340                    self.member_field_type,
341                    "q_dummy",
342                    f" When ``{ent.branches.tag_member.name}`` is "
343                    f"``{variant.name}``: "
344                    f"The members of :qapi:type:`{variant.type.name}`.",
345                    ent.info,
346                    "q_dummy",
347                )
348
349    def visit_sections(self, ent: QAPISchemaDefinition) -> None:
350        sections = ent.doc.all_sections if ent.doc else []
351
352        # Determine the index location at which we should generate
353        # documentation for "The members of ..." pointers. This should
354        # go at the end of the members section(s) if any. Note that
355        # index 0 is assumed to be a plain intro section, even if it is
356        # empty; and that a members section if present will always
357        # immediately follow the opening PLAIN section.
358        gen_index = 1
359        if len(sections) > 1:
360            while sections[gen_index].kind == QAPIDoc.Kind.MEMBER:
361                gen_index += 1
362                if gen_index >= len(sections):
363                    break
364
365        # Add sections in source order:
366        for i, section in enumerate(sections):
367            section.text = self.reformat_arobase(section.text)
368
369            if section.kind == QAPIDoc.Kind.PLAIN:
370                self.visit_paragraph(section)
371            elif section.kind == QAPIDoc.Kind.MEMBER:
372                assert isinstance(section, QAPIDoc.ArgSection)
373                self.visit_member(section)
374            elif section.kind == QAPIDoc.Kind.FEATURE:
375                assert isinstance(section, QAPIDoc.ArgSection)
376                self.visit_feature(section)
377            elif section.kind in (QAPIDoc.Kind.SINCE, QAPIDoc.Kind.TODO):
378                # Since is handled in preamble, TODO is skipped intentionally.
379                pass
380            elif section.kind == QAPIDoc.Kind.RETURNS:
381                self.visit_returns(section)
382            elif section.kind == QAPIDoc.Kind.ERRORS:
383                self.visit_errors(section)
384            else:
385                assert False
386
387            # Generate "The members of ..." entries if necessary:
388            if i == gen_index - 1:
389                self._insert_member_pointer(ent)
390
391        self.ensure_blank_line()
392
393    # Transmogrification core methods
394
395    def visit_module(self, path: str) -> None:
396        name = Path(path).stem
397        # module directives are credited to the first line of a module file.
398        self.add_line_raw(f".. qapi:module:: {name}", path, 1)
399        self.ensure_blank_line()
400
401    def visit_freeform(self, doc: QAPIDoc) -> None:
402        assert len(doc.all_sections) == 1, doc.all_sections
403        body = doc.all_sections[0]
404        self.add_lines(self.reformat_arobase(body.text), doc.info)
405        self.ensure_blank_line()
406
407    def visit_entity(self, ent: QAPISchemaDefinition) -> None:
408        assert ent.info
409
410        try:
411            self._curr_ent = ent
412
413            # Squish structs and unions together into an "object" directive.
414            meta = ent.meta
415            if meta in ("struct", "union"):
416                meta = "object"
417
418            # This line gets credited to the start of the /definition/.
419            self.add_line(f".. qapi:{meta}:: {ent.name}", ent.info)
420            with self.indented():
421                self.preamble(ent)
422                self.visit_sections(ent)
423        finally:
424            self._curr_ent = None
425
426    def set_namespace(self, namespace: str, source: str, lineno: int) -> None:
427        self.add_line_raw(
428            f".. qapi:namespace:: {namespace}", source, lineno + 1
429        )
430        self.ensure_blank_line()
431
432
433class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
434    """A QAPI schema visitor which adds Sphinx dependencies each module
435
436    This class calls the Sphinx note_dependency() function to tell Sphinx
437    that the generated documentation output depends on the input
438    schema file associated with each module in the QAPI input.
439    """
440
441    def __init__(self, env: Any, qapidir: str) -> None:
442        self._env = env
443        self._qapidir = qapidir
444
445    def visit_module(self, name: str) -> None:
446        if name != "./builtin":
447            qapifile = self._qapidir + "/" + name
448            self._env.note_dependency(os.path.abspath(qapifile))
449        super().visit_module(name)
450
451
452class NestedDirective(SphinxDirective):
453    def run(self) -> Sequence[nodes.Node]:
454        raise NotImplementedError
455
456    def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
457        """
458        Parse rST source lines and add them to the specified node
459
460        Take the list of rST source lines rstlist, parse them as
461        rST, and add the resulting docutils nodes as children of node.
462        The nodes are parsed in a way that allows them to include
463        subheadings (titles) without confusing the rendering of
464        anything else.
465        """
466        with switch_source_input(self.state, rstlist):
467            nested_parse_with_titles(self.state, rstlist, node)
468
469
470class QAPIDocDirective(NestedDirective):
471    """Extract documentation from the specified QAPI .json file"""
472
473    required_argument = 1
474    optional_arguments = 1
475    option_spec = {
476        "qapifile": directives.unchanged_required,
477        "namespace": directives.unchanged,
478    }
479    has_content = False
480
481    def transmogrify(self, schema: QAPISchema) -> nodes.Element:
482        logger.info("Transmogrifying QAPI to rST ...")
483        vis = Transmogrifier()
484        modules = set()
485
486        if "namespace" in self.options:
487            vis.set_namespace(
488                self.options["namespace"], *self.get_source_info()
489            )
490
491        for doc in schema.docs:
492            module_source = doc.info.fname
493            if module_source not in modules:
494                vis.visit_module(module_source)
495                modules.add(module_source)
496
497            if doc.symbol:
498                ent = schema.lookup_entity(doc.symbol)
499                assert isinstance(ent, QAPISchemaDefinition)
500                vis.visit_entity(ent)
501            else:
502                vis.visit_freeform(doc)
503
504        logger.info("Transmogrification complete.")
505
506        contentnode = nodes.section()
507        content = vis.result
508        titles_allowed = True
509
510        logger.info("Transmogrifier running nested parse ...")
511        with switch_source_input(self.state, content):
512            if titles_allowed:
513                node: nodes.Element = nodes.section()
514                node.document = self.state.document
515                nested_parse_with_titles(self.state, content, contentnode)
516            else:
517                node = nodes.paragraph()
518                node.document = self.state.document
519                self.state.nested_parse(content, 0, contentnode)
520        logger.info("Transmogrifier's nested parse completed.")
521
522        if self.env.app.verbosity >= 2 or os.environ.get("DEBUG"):
523            argname = "_".join(Path(self.arguments[0]).parts)
524            name = Path(argname).stem + ".ir"
525            self.write_intermediate(content, name)
526
527        sys.stdout.flush()
528        return contentnode
529
530    def write_intermediate(self, content: StringList, filename: str) -> None:
531        logger.info(
532            "writing intermediate rST for '%s' to '%s'",
533            self.arguments[0],
534            filename,
535        )
536
537        srctree = Path(self.env.app.config.qapidoc_srctree).resolve()
538        outlines = []
539        lcol_width = 0
540
541        for i, line in enumerate(content):
542            src, lineno = content.info(i)
543            srcpath = Path(src).resolve()
544            srcpath = srcpath.relative_to(srctree)
545
546            lcol = f"{srcpath}:{lineno:04d}"
547            lcol_width = max(lcol_width, len(lcol))
548            outlines.append((lcol, line))
549
550        with open(filename, "w", encoding="UTF-8") as outfile:
551            for lcol, rcol in outlines:
552                outfile.write(lcol.rjust(lcol_width))
553                outfile.write(" |")
554                if rcol:
555                    outfile.write(f" {rcol}")
556                outfile.write("\n")
557
558    def run(self) -> Sequence[nodes.Node]:
559        env = self.state.document.settings.env
560        qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
561        qapidir = os.path.dirname(qapifile)
562
563        try:
564            schema = QAPISchema(qapifile)
565
566            # First tell Sphinx about all the schema files that the
567            # output documentation depends on (including 'qapifile' itself)
568            schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
569        except QAPIError as err:
570            # Launder QAPI parse errors into Sphinx extension errors
571            # so they are displayed nicely to the user
572            raise ExtensionError(str(err)) from err
573
574        contentnode = self.transmogrify(schema)
575        return contentnode.children
576
577
578class QMPExample(CodeBlock, NestedDirective):
579    """
580    Custom admonition for QMP code examples.
581
582    When the :annotated: option is present, the body of this directive
583    is parsed as normal rST, but with any '::' code blocks set to use
584    the QMP lexer. Code blocks must be explicitly written by the user,
585    but this allows for intermingling explanatory paragraphs with
586    arbitrary rST syntax and code blocks for more involved examples.
587
588    When :annotated: is absent, the directive body is treated as a
589    simple standalone QMP code block literal.
590    """
591
592    required_argument = 0
593    optional_arguments = 0
594    has_content = True
595    option_spec = {
596        "annotated": directives.flag,
597        "title": directives.unchanged,
598    }
599
600    def _highlightlang(self) -> addnodes.highlightlang:
601        """Return the current highlightlang setting for the document"""
602        node = None
603        doc = self.state.document
604
605        if hasattr(doc, "findall"):
606            # docutils >= 0.18.1
607            for node in doc.findall(addnodes.highlightlang):
608                pass
609        else:
610            for elem in doc.traverse():
611                if isinstance(elem, addnodes.highlightlang):
612                    node = elem
613
614        if node:
615            return node
616
617        # No explicit directive found, use defaults
618        node = addnodes.highlightlang(
619            lang=self.env.config.highlight_language,
620            force=False,
621            # Yes, Sphinx uses this value to effectively disable line
622            # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
623            linenothreshold=sys.maxsize,
624        )
625        return node
626
627    def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
628        title = "Example:"
629        if "title" in self.options:
630            title = f"{title} {self.options['title']}"
631
632        admon = nodes.admonition(
633            "",
634            nodes.title("", title),
635            *content,
636            classes=["admonition", "admonition-example"],
637        )
638        return [admon]
639
640    def run_annotated(self) -> List[nodes.Node]:
641        lang_node = self._highlightlang()
642
643        content_node: nodes.Element = nodes.section()
644
645        # Configure QMP highlighting for "::" blocks, if needed
646        if lang_node["lang"] != "QMP":
647            content_node += addnodes.highlightlang(
648                lang="QMP",
649                force=False,  # "True" ignores lexing errors
650                linenothreshold=lang_node["linenothreshold"],
651            )
652
653        self.do_parse(self.content, content_node)
654
655        # Restore prior language highlighting, if needed
656        if lang_node["lang"] != "QMP":
657            content_node += addnodes.highlightlang(**lang_node.attributes)
658
659        return content_node.children
660
661    def run(self) -> List[nodes.Node]:
662        annotated = "annotated" in self.options
663
664        if annotated:
665            content_nodes = self.run_annotated()
666        else:
667            self.arguments = ["QMP"]
668            content_nodes = super().run()
669
670        return self.admonition_wrap(*content_nodes)
671
672
673def setup(app: Sphinx) -> ExtensionMetadata:
674    """Register qapi-doc directive with Sphinx"""
675    app.setup_extension("qapi_domain")
676    app.add_config_value("qapidoc_srctree", None, "env")
677    app.add_directive("qapi-doc", QAPIDocDirective)
678    app.add_directive("qmp-example", QMPExample)
679
680    return {
681        "version": __version__,
682        "parallel_read_safe": True,
683        "parallel_write_safe": True,
684    }
685