xref: /openbmc/qemu/docs/sphinx/qapidoc.py (revision 36e4182f4086edf3e7bbc5202bd692678d454793)
1# coding=utf-8
2#
3# QEMU qapidoc QAPI file parsing extension
4#
5# Copyright (c) 2020 Linaro
6#
7# This work is licensed under the terms of the GNU GPLv2 or later.
8# See the COPYING file in the top-level directory.
9
10"""
11qapidoc is a Sphinx extension that implements the qapi-doc directive
12
13The purpose of this extension is to read the documentation comments
14in QAPI schema files, and insert them all into the current document.
15
16It implements one new rST directive, "qapi-doc::".
17Each qapi-doc:: directive takes one argument, which is the
18pathname of the schema file to process, relative to the source tree.
19
20The docs/conf.py file must set the qapidoc_srctree config value to
21the root of the QEMU source tree.
22
23The Sphinx documentation on writing extensions is at:
24https://www.sphinx-doc.org/en/master/development/index.html
25"""
26
27from __future__ import annotations
28
29from contextlib import contextmanager
30import os
31from pathlib import Path
32import sys
33from typing import TYPE_CHECKING
34
35from docutils import nodes
36from docutils.parsers.rst import Directive, directives
37from docutils.statemachine import StringList
38from qapi.error import QAPIError
39from qapi.schema import QAPISchema, QAPISchemaVisitor
40from qapi.source import QAPISourceInfo
41
42from qapidoc_legacy import QAPISchemaGenRSTVisitor  # type: ignore
43from sphinx import addnodes
44from sphinx.directives.code import CodeBlock
45from sphinx.errors import ExtensionError
46from sphinx.util.docutils import switch_source_input
47from sphinx.util.nodes import nested_parse_with_titles
48
49
50if TYPE_CHECKING:
51    from typing import (
52        Any,
53        Generator,
54        List,
55        Sequence,
56    )
57
58    from sphinx.application import Sphinx
59    from sphinx.util.typing import ExtensionMetadata
60
61
62__version__ = "1.0"
63
64
65class Transmogrifier:
66    def __init__(self) -> None:
67        self._result = StringList()
68        self.indent = 0
69
70    # General-purpose rST generation functions
71
72    def get_indent(self) -> str:
73        return "   " * self.indent
74
75    @contextmanager
76    def indented(self) -> Generator[None]:
77        self.indent += 1
78        try:
79            yield
80        finally:
81            self.indent -= 1
82
83    def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
84        """Append one line of generated reST to the output."""
85
86        # NB: Sphinx uses zero-indexed lines; subtract one.
87        lineno = tuple((n - 1 for n in lineno))
88
89        if line.strip():
90            # not a blank line
91            self._result.append(
92                self.get_indent() + line.rstrip("\n"), source, *lineno
93            )
94        else:
95            self._result.append("", source, *lineno)
96
97    def add_line(self, content: str, info: QAPISourceInfo) -> None:
98        # NB: We *require* an info object; this works out OK because we
99        # don't document built-in objects that don't have
100        # one. Everything else should.
101        self.add_line_raw(content, info.fname, info.line)
102
103    def add_lines(
104        self,
105        content: str,
106        info: QAPISourceInfo,
107    ) -> None:
108        lines = content.splitlines(True)
109        for i, line in enumerate(lines):
110            self.add_line_raw(line, info.fname, info.line + i)
111
112    def ensure_blank_line(self) -> None:
113        # Empty document -- no blank line required.
114        if not self._result:
115            return
116
117        # Last line isn't blank, add one.
118        if self._result[-1].strip():  # pylint: disable=no-member
119            fname, line = self._result.info(-1)
120            assert isinstance(line, int)
121            # New blank line is credited to one-after the current last line.
122            # +2: correct for zero/one index, then increment by one.
123            self.add_line_raw("", fname, line + 2)
124
125    # Transmogrification core methods
126
127    def visit_module(self, path: str) -> None:
128        name = Path(path).stem
129        # module directives are credited to the first line of a module file.
130        self.add_line_raw(f".. qapi:module:: {name}", path, 1)
131        self.ensure_blank_line()
132
133
134class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
135    """A QAPI schema visitor which adds Sphinx dependencies each module
136
137    This class calls the Sphinx note_dependency() function to tell Sphinx
138    that the generated documentation output depends on the input
139    schema file associated with each module in the QAPI input.
140    """
141
142    def __init__(self, env: Any, qapidir: str) -> None:
143        self._env = env
144        self._qapidir = qapidir
145
146    def visit_module(self, name: str) -> None:
147        if name != "./builtin":
148            qapifile = self._qapidir + "/" + name
149            self._env.note_dependency(os.path.abspath(qapifile))
150        super().visit_module(name)
151
152
153class NestedDirective(Directive):
154    def run(self) -> Sequence[nodes.Node]:
155        raise NotImplementedError
156
157    def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
158        """
159        Parse rST source lines and add them to the specified node
160
161        Take the list of rST source lines rstlist, parse them as
162        rST, and add the resulting docutils nodes as children of node.
163        The nodes are parsed in a way that allows them to include
164        subheadings (titles) without confusing the rendering of
165        anything else.
166        """
167        with switch_source_input(self.state, rstlist):
168            nested_parse_with_titles(self.state, rstlist, node)
169
170
171class QAPIDocDirective(NestedDirective):
172    """Extract documentation from the specified QAPI .json file"""
173
174    required_argument = 1
175    optional_arguments = 1
176    option_spec = {
177        "qapifile": directives.unchanged_required,
178        "transmogrify": directives.flag,
179    }
180    has_content = False
181
182    def new_serialno(self) -> str:
183        """Return a unique new ID string suitable for use as a node's ID"""
184        env = self.state.document.settings.env
185        return "qapidoc-%d" % env.new_serialno("qapidoc")
186
187    def transmogrify(self, schema: QAPISchema) -> nodes.Element:
188        raise NotImplementedError
189
190    def legacy(self, schema: QAPISchema) -> nodes.Element:
191        vis = QAPISchemaGenRSTVisitor(self)
192        vis.visit_begin(schema)
193        for doc in schema.docs:
194            if doc.symbol:
195                vis.symbol(doc, schema.lookup_entity(doc.symbol))
196            else:
197                vis.freeform(doc)
198        return vis.get_document_node()  # type: ignore
199
200    def run(self) -> Sequence[nodes.Node]:
201        env = self.state.document.settings.env
202        qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
203        qapidir = os.path.dirname(qapifile)
204        transmogrify = "transmogrify" in self.options
205
206        try:
207            schema = QAPISchema(qapifile)
208
209            # First tell Sphinx about all the schema files that the
210            # output documentation depends on (including 'qapifile' itself)
211            schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
212        except QAPIError as err:
213            # Launder QAPI parse errors into Sphinx extension errors
214            # so they are displayed nicely to the user
215            raise ExtensionError(str(err)) from err
216
217        if transmogrify:
218            contentnode = self.transmogrify(schema)
219        else:
220            contentnode = self.legacy(schema)
221
222        return contentnode.children
223
224
225class QMPExample(CodeBlock, NestedDirective):
226    """
227    Custom admonition for QMP code examples.
228
229    When the :annotated: option is present, the body of this directive
230    is parsed as normal rST, but with any '::' code blocks set to use
231    the QMP lexer. Code blocks must be explicitly written by the user,
232    but this allows for intermingling explanatory paragraphs with
233    arbitrary rST syntax and code blocks for more involved examples.
234
235    When :annotated: is absent, the directive body is treated as a
236    simple standalone QMP code block literal.
237    """
238
239    required_argument = 0
240    optional_arguments = 0
241    has_content = True
242    option_spec = {
243        "annotated": directives.flag,
244        "title": directives.unchanged,
245    }
246
247    def _highlightlang(self) -> addnodes.highlightlang:
248        """Return the current highlightlang setting for the document"""
249        node = None
250        doc = self.state.document
251
252        if hasattr(doc, "findall"):
253            # docutils >= 0.18.1
254            for node in doc.findall(addnodes.highlightlang):
255                pass
256        else:
257            for elem in doc.traverse():
258                if isinstance(elem, addnodes.highlightlang):
259                    node = elem
260
261        if node:
262            return node
263
264        # No explicit directive found, use defaults
265        node = addnodes.highlightlang(
266            lang=self.env.config.highlight_language,
267            force=False,
268            # Yes, Sphinx uses this value to effectively disable line
269            # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
270            linenothreshold=sys.maxsize,
271        )
272        return node
273
274    def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
275        title = "Example:"
276        if "title" in self.options:
277            title = f"{title} {self.options['title']}"
278
279        admon = nodes.admonition(
280            "",
281            nodes.title("", title),
282            *content,
283            classes=["admonition", "admonition-example"],
284        )
285        return [admon]
286
287    def run_annotated(self) -> List[nodes.Node]:
288        lang_node = self._highlightlang()
289
290        content_node: nodes.Element = nodes.section()
291
292        # Configure QMP highlighting for "::" blocks, if needed
293        if lang_node["lang"] != "QMP":
294            content_node += addnodes.highlightlang(
295                lang="QMP",
296                force=False,  # "True" ignores lexing errors
297                linenothreshold=lang_node["linenothreshold"],
298            )
299
300        self.do_parse(self.content, content_node)
301
302        # Restore prior language highlighting, if needed
303        if lang_node["lang"] != "QMP":
304            content_node += addnodes.highlightlang(**lang_node.attributes)
305
306        return content_node.children
307
308    def run(self) -> List[nodes.Node]:
309        annotated = "annotated" in self.options
310
311        if annotated:
312            content_nodes = self.run_annotated()
313        else:
314            self.arguments = ["QMP"]
315            content_nodes = super().run()
316
317        return self.admonition_wrap(*content_nodes)
318
319
320def setup(app: Sphinx) -> ExtensionMetadata:
321    """Register qapi-doc directive with Sphinx"""
322    app.add_config_value("qapidoc_srctree", None, "env")
323    app.add_directive("qapi-doc", QAPIDocDirective)
324    app.add_directive("qmp-example", QMPExample)
325
326    return {
327        "version": __version__,
328        "parallel_read_safe": True,
329        "parallel_write_safe": True,
330    }
331